diff --git a/CHANGES/+debver.feature b/CHANGES/+debver.feature new file mode 100644 index 000000000..343d90054 --- /dev/null +++ b/CHANGES/+debver.feature @@ -0,0 +1 @@ +Add filters to content units to be diltered and ordered by their versions. diff --git a/pulp_deb/app/migrations/0032_debver.py b/pulp_deb/app/migrations/0032_debver.py new file mode 100644 index 000000000..9a645c281 --- /dev/null +++ b/pulp_deb/app/migrations/0032_debver.py @@ -0,0 +1,165 @@ +# Generated by Django 4.2.24 on 2025-09-10 07:38 + +from django.db import migrations +import pulp_deb.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("deb", "0031_add_domains"), + ] + + operations = [ + migrations.RunSQL( + sql=""" + CREATE COLLATION debver (provider='ICU', deterministic=false, locale='und', rules=$$ + [reorder digit latn symbol punct others][numericOrdering on] + &[first variable]<'~'<'\\u0000' + $$); + + CREATE TYPE debver AS + ( + sort_key TEXT COLLATE debver, + value TEXT + ); + + CREATE FUNCTION debver(value bpchar) RETURNS debver + AS $$ + DECLARE + pos integer; + rest bpchar; + epoch bpchar; + version bpchar; + revision bpchar; + BEGIN + pos := position(':' IN value); + IF pos > 0 + THEN + epoch := left(value, pos - 1); + rest := right(value, -pos); + ELSE + epoch := '0'; + rest := value; + END IF; + pos := position('-' IN reverse(rest)); + IF pos > 0 + THEN + version := left(rest, -pos); + revision := right(rest, pos -1); + ELSE + version := rest; + revision := ''; + END IF; + return (epoch || '\\ufffd' || version || '\\u0000\\ufffd' || revision || '\\u0000', value)::debver; + END; + $$ + LANGUAGE plpgsql + IMMUTABLE + RETURNS NULL ON NULL INPUT; + + CREATE FUNCTION text(version debver) RETURNS text + AS $$ + BEGIN + RETURN version.value; + END; + $$ + LANGUAGE plpgsql + IMMUTABLE + RETURNS NULL ON NULL INPUT; + + CREATE CAST (text AS debver) WITH FUNCTION debver(bpchar) AS IMPLICIT; + CREATE CAST (varchar AS debver) WITH FUNCTION debver(bpchar) AS IMPLICIT; + CREATE CAST (bpchar AS debver) WITH FUNCTION debver(bpchar) AS IMPLICIT; + CREATE CAST (debver AS text) WITH FUNCTION text(debver) AS IMPLICIT; + CREATE CAST (debver AS varchar) WITH FUNCTION text(debver) AS IMPLICIT; + CREATE CAST (debver AS bpchar) WITH FUNCTION text(debver) AS IMPLICIT; + + CREATE FUNCTION debver_eq(a debver, b debver) RETURNS boolean + LANGUAGE sql + IMMUTABLE + RETURNS NULL ON NULL INPUT + RETURN a.sort_key = b.sort_key; + + CREATE FUNCTION debver_neq(a debver, b debver) RETURNS boolean + LANGUAGE sql + IMMUTABLE + RETURNS NULL ON NULL INPUT + RETURN a.sort_key <> b.sort_key; + + CREATE FUNCTION debver_lt(a debver, b debver) RETURNS boolean + LANGUAGE sql + IMMUTABLE + RETURNS NULL ON NULL INPUT + RETURN a.sort_key < b.sort_key; + + CREATE FUNCTION debver_lte(a debver, b debver) RETURNS boolean + LANGUAGE sql + IMMUTABLE + RETURNS NULL ON NULL INPUT + RETURN a.sort_key <= b.sort_key; + + CREATE FUNCTION debver_gte(a debver, b debver) RETURNS boolean + LANGUAGE sql + IMMUTABLE + RETURNS NULL ON NULL INPUT + RETURN a.sort_key >= b.sort_key; + + CREATE FUNCTION debver_gt(a debver, b debver) RETURNS boolean + LANGUAGE sql + IMMUTABLE + RETURNS NULL ON NULL INPUT + RETURN a.sort_key > b.sort_key; + + CREATE OPERATOR = ( + LEFTARG = debver, + RIGHTARG = debver, + FUNCTION = debver_eq + ); + + CREATE OPERATOR <> ( + LEFTARG = debver, + RIGHTARG = debver, + FUNCTION = debver_neq + ); + + CREATE OPERATOR < ( + LEFTARG = debver, + RIGHTARG = debver, + FUNCTION = debver_lt + ); + + CREATE OPERATOR <= ( + LEFTARG = debver, + RIGHTARG = debver, + FUNCTION = debver_lte + ); + + CREATE OPERATOR >= ( + LEFTARG = debver, + RIGHTARG = debver, + FUNCTION = debver_gte + ); + + CREATE OPERATOR > ( + LEFTARG = debver, + RIGHTARG = debver, + FUNCTION = debver_gt + ); + """, + reverse_sql=""" + DROP TYPE IF EXISTS debver CASCADE; + DROP COLLATION IF EXISTS debver; + """, + ), + migrations.AlterField( + model_name="installerpackage", + name="version", + field=pulp_deb.fields.DebVersionField(), + ), + migrations.AlterField( + model_name="package", + name="version", + field=pulp_deb.fields.DebVersionField(), + ), + ] diff --git a/pulp_deb/app/models/content/content.py b/pulp_deb/app/models/content/content.py index 284088802..ee7b5d7e5 100644 --- a/pulp_deb/app/models/content/content.py +++ b/pulp_deb/app/models/content/content.py @@ -16,6 +16,8 @@ from pulpcore.plugin.models import Content from pulpcore.plugin.util import get_domain_pk +from pulp_deb.fields import DebVersionField + BOOL_CHOICES = [(True, "yes"), (False, "no")] @@ -33,7 +35,7 @@ class BasePackage(Content): package = models.TextField() # package name source = models.TextField(null=True) # source package name - version = models.TextField() + version = DebVersionField() architecture = models.TextField() # all, i386, ... section = models.TextField(null=True) # admin, comm, database, ... priority = models.TextField(null=True) # required, standard, optional, extra diff --git a/pulp_deb/app/viewsets/content.py b/pulp_deb/app/viewsets/content.py index ba2908e32..3de973222 100644 --- a/pulp_deb/app/viewsets/content.py +++ b/pulp_deb/app/viewsets/content.py @@ -205,7 +205,7 @@ class Meta: fields = { "package": NAME_FILTER_OPTIONS, "source": ["exact"], - "version": ["exact"], + "version": ["exact", "ne", "lt", "lte", "gt", "gte"], "architecture": ["exact"], "section": ["exact"], "priority": ["exact"], diff --git a/pulp_deb/fields.py b/pulp_deb/fields.py new file mode 100644 index 000000000..dccd26bb9 --- /dev/null +++ b/pulp_deb/fields.py @@ -0,0 +1,24 @@ +from django.db import models + + +class _DebVer(models.Value): + def __init__(self, value): + self.value = value + + def as_sql(self, compiler, connection): + return "debver(%s)", [self.value] + + +class DebVersionField(models.CharField): + description = "Debian Version" + + def db_type(self, connection): + return "debver" + + def get_prep_value(self, value): + if value is not None: + return _DebVer(value) + return value + + def select_format(self, compiler, sql, params): + return f"({sql}).value", params diff --git a/pulp_deb/tests/conftest.py b/pulp_deb/tests/conftest.py index 2a57a4d35..88c569d17 100644 --- a/pulp_deb/tests/conftest.py +++ b/pulp_deb/tests/conftest.py @@ -1,6 +1,8 @@ import pytest from pathlib import Path +from pulpcore.tests.functional.utils import BindingsNamespace + from pulp_deb.tests.functional.utils import gen_deb_remote, gen_distribution, gen_repo from pulp_deb.tests.functional.constants import DEB_FIXTURE_STANDARD_REPOSITORY_NAME @@ -16,6 +18,21 @@ ) +@pytest.fixture(scope="session") +def deb_bindings(_api_client_set, bindings_cfg): + """ + A namespace providing preconfigured pulpcore api clients. + + e.g. `pulpcore_bindings.WorkersApi.list()`. + """ + from pulpcore.client import pulp_deb as bindings_module + + api_client = bindings_module.ApiClient(bindings_cfg) + _api_client_set.add(api_client) + yield BindingsNamespace(bindings_module, api_client) + _api_client_set.remove(api_client) + + @pytest.fixture(scope="session") def apt_client(_api_client_set, bindings_cfg): """Fixture for APT client.""" @@ -124,7 +141,7 @@ def _deb_remote_factory(url, **kwargs): return _deb_remote_factory -@pytest.fixture +@pytest.fixture(scope="class") def deb_sync_repository(apt_repository_api, monitor_task): """Fixture that synchronizes a given repository with a given remote and returns the monitored task. @@ -148,7 +165,7 @@ def _deb_sync_repository(remote, repo, mirror=False, optimize=True): return _deb_sync_repository -@pytest.fixture +@pytest.fixture(scope="class") def deb_fixture_server(gen_fixture_server): """A fixture that spins up a local web server to serve test data.""" p = Path(__file__).parent.absolute() @@ -156,7 +173,7 @@ def deb_fixture_server(gen_fixture_server): yield gen_fixture_server(fixture_path, None) -@pytest.fixture +@pytest.fixture(scope="class") def deb_get_fixture_server_url(deb_fixture_server): """A fixture that provides the url of the local web server.""" @@ -171,7 +188,7 @@ def _deb_get_fixture_server_url(repo_name=DEB_FIXTURE_STANDARD_REPOSITORY_NAME): return _deb_get_fixture_server_url -@pytest.fixture +@pytest.fixture(scope="class") def deb_init_and_sync( apt_repository_api, deb_get_fixture_server_url, diff --git a/pulp_deb/tests/functional/api/test_filter.py b/pulp_deb/tests/functional/api/test_filter.py new file mode 100644 index 000000000..0b551787c --- /dev/null +++ b/pulp_deb/tests/functional/api/test_filter.py @@ -0,0 +1,36 @@ +import pytest + + +@pytest.mark.parallel +class TestPackageVersionFilter: + @pytest.fixture(scope="class") + def repository(self, deb_init_and_sync): + _repository, _ = deb_init_and_sync() + return _repository + + @pytest.mark.parametrize( + "filter,count", + [ + pytest.param({"version": "1.0"}, 4, id="exact"), + pytest.param({"version__ne": "1.0"}, 0, id="ne"), + pytest.param({"version__gt": "1.0~"}, 4, id="gt with tilde"), + pytest.param({"version__gt": "1.0"}, 0, id="gt"), + pytest.param({"version__gt": "1.0+"}, 0, id="gt with plus"), + pytest.param({"version__gte": "1.0~"}, 4, id="gte with tilde"), + pytest.param({"version__gte": "1.0"}, 4, id="gte"), + pytest.param({"version__gte": "1.0+"}, 0, id="gte with plus"), + pytest.param({"version__lt": "1.0~"}, 0, id="lt with tilde"), + pytest.param({"version__lt": "1.0"}, 0, id="lt"), + pytest.param({"version__lt": "1.0+"}, 4, id="lt with plus"), + pytest.param({"version__lte": "1.0~"}, 0, id="lte with tilde"), + pytest.param({"version__lte": "1.0"}, 4, id="lte"), + pytest.param({"version__lte": "1.0+"}, 4, id="lte with plus"), + ], + ) + def test_returns_a_certain_count_of_entries(self, deb_bindings, repository, filter, count): + """Verify that Packages can be filtered by versions.""" + # Query content units with filters + result = deb_bindings.ContentPackagesApi.list( + repository_version=repository.latest_version_href, **filter + ) + assert result.count == count diff --git a/pulp_deb/tests/unit/test_debversion_field.py b/pulp_deb/tests/unit/test_debversion_field.py new file mode 100644 index 000000000..8c4122bc6 --- /dev/null +++ b/pulp_deb/tests/unit/test_debversion_field.py @@ -0,0 +1,34 @@ +from pulp_deb.app.models import Package + + +VERSIONS = [ + "1", + "1.0~asdf", + "1.0", + "1.0-1~0", + "1.0-1~1", + "1.0-1", + "1.0-1+1", + "1.0-1+1.2", + "1.0-1+2", + "1.0-1+12", + "1.0-1+a", + "1.0-1+b~", + "1.0-1+b", + "2", + "2:1.0", + "11:1.0", +] + + +def test_sort_debver(db): + for version in reversed(VERSIONS): + Package.objects.create(relative_path=f"test_sort_debver-{version}", version=version) + + sorted_versions = ( + Package.objects.filter(relative_path__startswith="test_sort_debver-") + .order_by("version") + .values_list("version", flat=True) + ) + + assert list(sorted_versions) == VERSIONS diff --git a/pyproject.toml b/pyproject.toml index 9147c2dd9..83d2e801a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ requires-python = ">=3.11" dependencies = [ # All things django and asyncio are deliberately left to pulpcore # Example transitive requirements: asgiref, asyncio, aiohttp - "pulpcore>=3.75.0,<3.100", + "pulpcore>=3.85.0,<3.100", "python-debian>=0.1.44,<0.2.0", "python-gnupg>=0.5,<0.6", "jsonschema>=4.6,<5.0",