Compare commits

..

No commits in common. "master" and "1.8.0" have entirely different histories.

18 changed files with 1121 additions and 1790 deletions

View file

@ -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

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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

View file

@ -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]

View file

@ -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

View file

@ -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)

View file

@ -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):

View file

@ -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])

View file

@ -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),

View file

@ -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,

View file

@ -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)
),

View file

@ -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"]
)

View file

@ -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"))

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"
]

View file

@ -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"}