mirror of
https://github.com/jazzband/django-eav2.git
synced 2026-05-23 14:45:55 +00:00
Compare commits
No commits in common. "master" and "1.8.0" have entirely different histories.
18 changed files with 1121 additions and 1790 deletions
47
.github/dependabot.yml
vendored
47
.github/dependabot.yml
vendored
|
|
@ -1,38 +1,15 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: pip
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "02:00"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
test-dependencies:
|
||||
patterns:
|
||||
- "pytest*"
|
||||
- "mypy"
|
||||
- "ruff"
|
||||
- "hypothesis"
|
||||
- "safety"
|
||||
- "doc8"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
include: "scope"
|
||||
- package-ecosystem: pip
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "02:00"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "02:00"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "02:00"
|
||||
open-pull-requests-limit: 10
|
||||
|
|
|
|||
25
.github/workflows/dependabot-auto-merge.yml
vendored
25
.github/workflows/dependabot-auto-merge.yml
vendored
|
|
@ -1,25 +0,0 @@
|
|||
name: Dependabot auto-merge
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
steps:
|
||||
- name: Fetch Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v3
|
||||
|
||||
- name: Auto-merge patch and minor updates
|
||||
if: |
|
||||
steps.metadata.outputs.update-type == 'version-update:semver-patch' ||
|
||||
steps.metadata.outputs.update-type == 'version-update:semver-minor'
|
||||
run: gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -11,12 +11,12 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.8
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
|
||||
- name: Upload packages to Jazzband
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
with:
|
||||
user: jazzband
|
||||
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
||||
|
|
|
|||
39
.github/workflows/test.yml
vendored
39
.github/workflows/test.yml
vendored
|
|
@ -1,32 +1,31 @@
|
|||
# https://docs.djangoproject.com/en/stable/faq/install/#what-python-version-can-i-use-with-django
|
||||
name: test
|
||||
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
"on": [push, pull_request, workflow_dispatch]
|
||||
|
||||
jobs:
|
||||
test-matrix:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12', '3.13']
|
||||
django-version: ['5.2', '6.0']
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
django-version: ["4.2", "5.0", "5.1"]
|
||||
exclude:
|
||||
# Django 6.0 requires Python 3.12+
|
||||
- python-version: '3.10'
|
||||
django-version: '6.0'
|
||||
- python-version: '3.11'
|
||||
django-version: '6.0'
|
||||
|
||||
# Exclude Python 3.8 and 3.9 with Django 5.0
|
||||
- python-version: '3.8'
|
||||
django-version: '5.0'
|
||||
- python-version: '3.9'
|
||||
django-version: '5.0'
|
||||
# Exclude Python 3.8 and 3.9 with Django 5.1
|
||||
- python-version: '3.8'
|
||||
django-version: '5.1'
|
||||
- python-version: '3.9'
|
||||
django-version: '5.1'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
|
|
@ -39,7 +38,7 @@ jobs:
|
|||
installer-parallel: true
|
||||
|
||||
- name: Set up cache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }}
|
||||
|
|
@ -57,6 +56,6 @@ jobs:
|
|||
poetry run pip check
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
file: ./coverage.xml
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# See https://pre-commit.com for more information
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
|
|
@ -11,7 +11,7 @@ repos:
|
|||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.13
|
||||
rev: v0.9.7
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
|
@ -24,4 +24,4 @@ repos:
|
|||
hooks:
|
||||
- id: check-migrations-created
|
||||
args: [--manage-path=manage.py]
|
||||
additional_dependencies: [django==5.2]
|
||||
additional_dependencies: [django==4.1]
|
||||
|
|
|
|||
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -2,27 +2,6 @@
|
|||
|
||||
We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` release.
|
||||
|
||||
## 1.8.2 (2026-05-22)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed `TypeError: 'Q' object is not subscriptable` when combining EAV filters with negated Q objects by @Dresdn in https://github.com/jazzband/django-eav2/pull/846
|
||||
|
||||
### Compatibility
|
||||
|
||||
- Added Django 6.0 support
|
||||
- Dropped support for EOL Django versions — minimum is now Django 5.2
|
||||
- Dropped support for EOL Python versions — minimum is now Python 3.10
|
||||
|
||||
## 1.8.1 (2025-06-02)
|
||||
|
||||
## What's Changed
|
||||
|
||||
- Added support for Django 5.2
|
||||
- Updated dependencies to their latest versions
|
||||
|
||||
## 1.8.0 (2025-02-24)
|
||||
|
||||
## What's Changed
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
def register(model_cls, config_cls=None):
|
||||
from eav.registry import Registry # noqa: PLC0415
|
||||
from eav.registry import Registry
|
||||
|
||||
Registry.register(model_cls, config_cls)
|
||||
|
||||
|
||||
def unregister(model_cls):
|
||||
from eav.registry import Registry # noqa: PLC0415
|
||||
from eav.registry import Registry
|
||||
|
||||
Registry.unregister(model_cls)
|
||||
|
|
|
|||
|
|
@ -3,10 +3,6 @@ This module contains pure wrapper functions used as decorators.
|
|||
Functions in this module should be simple and not involve complex logic.
|
||||
"""
|
||||
|
||||
from django.db.models import Model
|
||||
|
||||
from eav import register
|
||||
|
||||
|
||||
def register_eav(**kwargs):
|
||||
"""
|
||||
|
|
@ -17,6 +13,9 @@ def register_eav(**kwargs):
|
|||
class Author(models.Model):
|
||||
pass
|
||||
"""
|
||||
from django.db.models import Model
|
||||
|
||||
from eav import register
|
||||
|
||||
def _model_eav_wrapper(model_class):
|
||||
if not issubclass(model_class, Model):
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ class ValueManager(models.Manager):
|
|||
Returns:
|
||||
Value: The instance matching the provided keys.
|
||||
"""
|
||||
from eav.models import Attribute # noqa: PLC0415
|
||||
from eav.models import Attribute
|
||||
|
||||
attribute = Attribute.objects.get(name=attribute[0], slug=attribute[1])
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class Migration(migrations.Migration):
|
|||
migrations.AddConstraint(
|
||||
model_name="value",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
check=models.Q(
|
||||
models.Q(
|
||||
("entity_id__isnull", False),
|
||||
("entity_uuid__isnull", True),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
# ruff: noqa: UP007
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
|
|
@ -163,7 +165,7 @@ class Attribute(models.Model):
|
|||
:meth:`~eav.registry.EavConfig.get_attributes` method of that entity's config.
|
||||
"""
|
||||
|
||||
enum_group: ForeignKey[EnumGroup | None] = ForeignKey(
|
||||
enum_group: ForeignKey[Optional[EnumGroup]] = ForeignKey(
|
||||
"eav.EnumGroup",
|
||||
on_delete=models.PROTECT,
|
||||
blank=True,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# ruff: noqa: UP007
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
from typing import TYPE_CHECKING, ClassVar, Optional
|
||||
|
||||
from django.contrib.contenttypes import fields as generic
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
|
@ -136,7 +137,7 @@ class Value(models.Model):
|
|||
verbose_name=_("Value JSON"),
|
||||
)
|
||||
|
||||
value_enum: ForeignKey[EnumValue | None] = ForeignKey(
|
||||
value_enum: ForeignKey[Optional[EnumValue]] = ForeignKey(
|
||||
"eav.EnumValue",
|
||||
blank=True,
|
||||
null=True,
|
||||
|
|
@ -182,7 +183,7 @@ class Value(models.Model):
|
|||
name="unique_entity_id_per_attribute",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=(
|
||||
check=(
|
||||
models.Q(entity_id__isnull=False, entity_uuid__isnull=True)
|
||||
| models.Q(entity_id__isnull=True, entity_uuid__isnull=False)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -12,27 +12,11 @@ Q-expressions need to be rewritten for two reasons:
|
|||
|
||||
city_values = Value.objects.filter(value__text__startswith='New')
|
||||
Supplier.objects.filter(eav_values__in=city_values)
|
||||
|
||||
For details see: :func:`eav_filter`.
|
||||
|
||||
2. To ensure that Q-expression tree is compiled to valid SQL.
|
||||
For details see: :func:`rewrite_q_expr`.
|
||||
|
||||
.. note:: EAV negation limitation
|
||||
|
||||
Because EAV values are stored as individual rows in a shared table,
|
||||
using ``~Q(eav__attr=value)`` inside ``.filter()`` negates at the
|
||||
**row** level rather than the **entity** level. This can produce
|
||||
unexpected results: an entity that has *other* EAV rows not matching
|
||||
the negation will still appear in the result set.
|
||||
|
||||
The correct way to express "entities where attr A = x AND attr B ≠ y"
|
||||
is to chain ``.exclude()`` instead::
|
||||
|
||||
# Wrong - row-level negation, may return unexpected results
|
||||
MyModel.objects.filter(Q(eav__a=x) & ~Q(eav__b=y))
|
||||
|
||||
# Correct - entity-level negation
|
||||
MyModel.objects.filter(eav__a=x).exclude(eav__b=y)
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
|
|
@ -60,7 +44,6 @@ def is_eav_and_leaf(expr, gr_name):
|
|||
return (
|
||||
getattr(expr, "connector", None) == "AND"
|
||||
and len(expr.children) == 1
|
||||
and isinstance(expr.children[0], tuple)
|
||||
and expr.children[0][0] in ["pk__in", f"{gr_name}__in"]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ def validate_enum(value):
|
|||
Raises ``ValidationError`` unless *value* is a saved
|
||||
:class:`~eav.models.EnumValue` model instance.
|
||||
"""
|
||||
from eav.models import EnumValue # noqa: PLC0415
|
||||
from eav.models import EnumValue
|
||||
|
||||
if isinstance(value, EnumValue) and not value.pk:
|
||||
raise ValidationError(_("EnumValue has not been saved yet"))
|
||||
|
|
|
|||
19
manage.py
19
manage.py
|
|
@ -3,15 +3,6 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
from django.core import management
|
||||
except ImportError as err:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
+ "available on your PYTHONPATH environment variable? Did you "
|
||||
+ "forget to activate a virtual environment?",
|
||||
) from err
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
|
|
@ -23,6 +14,16 @@ def main() -> None:
|
|||
3. Executes any given command
|
||||
"""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings")
|
||||
|
||||
try:
|
||||
from django.core import management
|
||||
except ImportError as err:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
+ "available on your PYTHONPATH environment variable? Did you "
|
||||
+ "forget to activate a virtual environment?",
|
||||
) from err
|
||||
|
||||
management.execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
|
|
|
|||
2522
poetry.lock
generated
2522
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,7 @@ build-backend = "poetry.core.masonry.api"
|
|||
[tool.poetry]
|
||||
name = "django-eav2"
|
||||
description = "Entity-Attribute-Value storage for Django"
|
||||
version = "1.8.2"
|
||||
version = "1.8.0"
|
||||
license = "GNU Lesser General Public License (LGPL), Version 3"
|
||||
packages = [
|
||||
{ include = "eav" }
|
||||
|
|
@ -37,15 +37,17 @@ classifiers = [
|
|||
"License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Database",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Framework :: Django",
|
||||
"Framework :: Django :: 5.2",
|
||||
"Framework :: Django :: 6.0",
|
||||
"Framework :: Django :: 4.2",
|
||||
"Framework :: Django :: 5.0",
|
||||
"Framework :: Django :: 5.1",
|
||||
]
|
||||
|
||||
[tool.semantic_release]
|
||||
|
|
@ -58,32 +60,32 @@ upload_to_release = false
|
|||
build_command = "pip install poetry && poetry build"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
django = ">=5.2"
|
||||
python = "^3.8"
|
||||
django = ">=4.2,<5.2"
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
mypy = ">=1.6,<3.0"
|
||||
ruff = ">=0.6.3,<0.16.0"
|
||||
mypy = "^1.6"
|
||||
ruff = ">=0.6.3,<0.10.0"
|
||||
|
||||
safety = ">=2.3,<4.0"
|
||||
|
||||
pytest = ">=7.4.3,<10.0.0"
|
||||
pytest-cov = ">=4.1,<8.0"
|
||||
pytest-randomly = ">=3.15,<5.0"
|
||||
pytest = ">=7.4.3,<9.0.0"
|
||||
pytest-cov = ">=4.1,<6.0"
|
||||
pytest-randomly = "^3.15"
|
||||
pytest-django = "^4.5.2"
|
||||
hypothesis = "^6.87.1"
|
||||
|
||||
doc8 = ">=0.11.2,<2.1.0"
|
||||
doc8 = ">=0.11.2,<1.2.0"
|
||||
|
||||
[tool.poetry.group.docs]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
sphinx = ">=5.0,<9.0"
|
||||
sphinx = ">=5.0,<8.0"
|
||||
sphinx-rtd-theme = ">=1.3,<4.0"
|
||||
sphinx-autodoc-typehints = ">=1.19.5,<3.0.0"
|
||||
m2r2 = "^0.3"
|
||||
tomlkit = ">=0.13.0,<0.16"
|
||||
tomlkit = ">=0.13.0,<0.14"
|
||||
|
||||
|
||||
[tool.ruff]
|
||||
|
|
@ -121,10 +123,8 @@ allow-multiline = false
|
|||
"tests/*" = [
|
||||
"INP001", # "Add an __init__.py"
|
||||
"PLR2004", # "Magic value used in comparison"
|
||||
"PLW0108", # "Lambda may be unnecessary" - used with assertRaises
|
||||
"PT009", # "Use a regular assert instead of unittest-style"
|
||||
"PT027", # "Use pytest.raises instead of unittest-style"
|
||||
"PT030", # "pytest.warns match parameter" - broad warnings intentional in tests
|
||||
"S101", # "Use of assert detected"
|
||||
"SLF001" # "Private member accessed"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -407,148 +407,3 @@ class Queries(TestCase):
|
|||
# Assert that the expected patient is returned
|
||||
self.assertEqual(len(patients), 1)
|
||||
self.assertEqual(patients[0].name, "Anne")
|
||||
|
||||
def test_filter_eav_and_negated_non_eav_field(self) -> None:
|
||||
"""
|
||||
Regression test for #634: EAV Q combined with a negated non-EAV Q.
|
||||
|
||||
Q(eav__attr=val) & ~Q(non_eav_field=val) raised:
|
||||
TypeError: 'Q' object is not subscriptable
|
||||
"""
|
||||
self.init_data()
|
||||
# fever=yes → Cyrill, Eugene; name≠"Bob" excludes nobody here
|
||||
p = Patient.objects.filter(Q(eav__fever=self.yes) & ~Q(name="Bob"))
|
||||
assert p.count() == 2
|
||||
assert set(p.values_list("name", flat=True)) == {"Cyrill", "Eugene"}
|
||||
|
||||
def test_filter_negated_non_eav_field_and_eav(self) -> None:
|
||||
"""
|
||||
Regression test for #634: negated non-EAV Q combined with an EAV Q
|
||||
(operand order reversed from the previous test).
|
||||
|
||||
~Q(non_eav_field=val) & Q(eav__attr=val) raised:
|
||||
TypeError: 'Q' object is not subscriptable
|
||||
"""
|
||||
self.init_data()
|
||||
# Same expectation as above; order of operands should not matter.
|
||||
p = Patient.objects.filter(~Q(name="Bob") & Q(eav__fever=self.yes))
|
||||
assert p.count() == 2
|
||||
assert set(p.values_list("name", flat=True)) == {"Cyrill", "Eugene"}
|
||||
|
||||
def test_filter_eav_and_negated_eav_field(self) -> None:
|
||||
"""
|
||||
Regression test for #634: EAV Q combined with a negated EAV Q.
|
||||
|
||||
Q(eav__attr1=val) & ~Q(eav__attr2=val) raised:
|
||||
TypeError: 'Q' object is not subscriptable
|
||||
|
||||
Note: negating an EAV filter inside filter() has known SQL JOIN
|
||||
semantics limitations. Because each EAV attribute is stored as a
|
||||
separate row in the values table, the negation operates on individual
|
||||
rows rather than on entities. This means a patient with both a
|
||||
fever=no value AND a city='Nice' value will still be included because
|
||||
their fever=no row passes the NOT city='Nice' row-level check.
|
||||
The primary purpose of this test is to verify no TypeError is raised.
|
||||
"""
|
||||
self.init_data()
|
||||
# fever=no → Anne, Bob, Daniel
|
||||
# ~city='Nice' does NOT reliably exclude Daniel due to EAV row semantics
|
||||
p = Patient.objects.filter(Q(eav__fever=self.no) & ~Q(eav__city="Nice"))
|
||||
# At minimum, the EAV-only patients (no city='Nice') must be present
|
||||
names = set(p.values_list("name", flat=True))
|
||||
assert {"Anne", "Bob"}.issubset(names)
|
||||
# Result is bounded by the fever=no set
|
||||
assert names.issubset({"Anne", "Bob", "Daniel"})
|
||||
|
||||
def test_filter_solo_negated_eav_field(self) -> None:
|
||||
"""
|
||||
~Q(eav__attr=val) as the sole argument should work correctly.
|
||||
|
||||
This produces entity-level exclusion because there are no other EAV
|
||||
JOIN conditions to interfere: entities with no matching value row are
|
||||
simply absent from the JOIN, so NOT eav_values__in correctly excludes
|
||||
entities that DO have that value row.
|
||||
"""
|
||||
self.init_data()
|
||||
# age != 3 → Bob (15), Cyrill (15), Eugene (2)
|
||||
p = Patient.objects.filter(~Q(eav__age=3))
|
||||
assert p.count() == 3
|
||||
assert set(p.values_list("name", flat=True)) == {"Bob", "Cyrill", "Eugene"}
|
||||
|
||||
def test_filter_or_eav_and_non_eav_no_duplicates(self) -> None:
|
||||
"""
|
||||
OR between an EAV Q and a non-EAV Q must not return duplicate rows.
|
||||
|
||||
When an EAV condition is used in OR with a non-EAV condition, the EAV
|
||||
side remains as a JOIN condition (it cannot be merged via rewrite_q_expr
|
||||
because there is only one EAV leaf). This JOIN can produce multiple rows
|
||||
per entity. Results should be distinct.
|
||||
|
||||
This is a known limitation: callers should use .distinct() when mixing
|
||||
EAV and non-EAV conditions in OR.
|
||||
"""
|
||||
self.init_data()
|
||||
# fever=yes → Cyrill, Eugene; name='Bob' → Bob
|
||||
p = Patient.objects.filter(Q(eav__fever=self.yes) | Q(name="Bob"))
|
||||
# Correct distinct count is 3; without distinct() duplicates may appear
|
||||
assert p.distinct().count() == 3
|
||||
assert set(p.distinct().values_list("name", flat=True)) == {
|
||||
"Bob",
|
||||
"Cyrill",
|
||||
"Eugene",
|
||||
}
|
||||
|
||||
def test_q_object_not_mutated_by_filter(self) -> None:
|
||||
"""
|
||||
Q objects passed to filter() must not be mutated.
|
||||
|
||||
expand_q_filters() rewrites Q children in-place. If the original Q
|
||||
object is mutated, reusing it in a second query will operate on the
|
||||
already-expanded (internal) form and may produce incorrect results or
|
||||
errors.
|
||||
|
||||
This test documents the current behaviour (mutation occurs) so that
|
||||
any future fix is captured as a regression guard.
|
||||
"""
|
||||
self.init_data()
|
||||
q = Q(eav__fever=self.yes)
|
||||
original_children = list(q.children) # [('eav__fever', <EnumValue>)]
|
||||
|
||||
Patient.objects.filter(q)
|
||||
|
||||
# Document current (broken) behaviour: children are mutated.
|
||||
# When this is fixed, change the assertion to assertEqual.
|
||||
assert q.children != original_children, (
|
||||
"Q mutation is fixed - update this test to assert no mutation"
|
||||
)
|
||||
|
||||
def test_negated_eav_field_workaround_chained_exclude(self) -> None:
|
||||
"""
|
||||
Documents the correct way to express "EAV attr A = x AND EAV attr B != y".
|
||||
|
||||
Because EAV values are stored one-per-row, using ~Q() inside filter()
|
||||
negates at the *row* level, not the *entity* level. The correct
|
||||
approach is to chain .exclude() which operates at the entity level::
|
||||
|
||||
# WRONG - may return entities that should be excluded
|
||||
Patient.objects.filter(Q(eav__fever=no) & ~Q(eav__city="Nice"))
|
||||
|
||||
# CORRECT - exclude() works at entity level
|
||||
Patient.objects.filter(eav__fever=no).exclude(eav__city="Nice")
|
||||
# or equivalently:
|
||||
Patient.objects.filter(Q(eav__fever=no)).exclude(Q(eav__city="Nice"))
|
||||
"""
|
||||
self.init_data()
|
||||
# fever=no → Anne, Bob, Daniel; chained exclude removes Daniel
|
||||
p_chained = Patient.objects.filter(eav__fever=self.no).exclude(
|
||||
eav__city="Nice",
|
||||
)
|
||||
assert p_chained.count() == 2
|
||||
assert set(p_chained.values_list("name", flat=True)) == {"Anne", "Bob"}
|
||||
|
||||
# Q-based form of the same thing
|
||||
p_q = Patient.objects.filter(Q(eav__fever=self.no)).exclude(
|
||||
Q(eav__city="Nice"),
|
||||
)
|
||||
assert p_q.count() == 2
|
||||
assert set(p_q.values_list("name", flat=True)) == {"Anne", "Bob"}
|
||||
|
|
|
|||
Loading…
Reference in a new issue