From e76e86f1d6945916a42d6ad91aa678f0172d323c Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 4 Mar 2026 16:31:02 +0000 Subject: [PATCH 1/6] Add denormalised `feature_names` attribute for enhanced DB query performance --- api/projects/code_references/db_helpers.py | 27 ++++++ .../migrations/0003_add_feature_names.py | 33 ++++++++ .../0004_add_feature_names_gin_index.py | 34 ++++++++ api/projects/code_references/models.py | 23 ++++- api/projects/code_references/services.py | 14 +--- ...nit_projects_code_references_db_helpers.py | 84 +++++++++++++++++++ ...st_unit_projects_code_references_models.py | 63 ++++++++++++++ ...est_unit_projects_code_references_views.py | 37 ++++++++ 8 files changed, 304 insertions(+), 11 deletions(-) create mode 100644 api/projects/code_references/db_helpers.py create mode 100644 api/projects/code_references/migrations/0003_add_feature_names.py create mode 100644 api/projects/code_references/migrations/0004_add_feature_names_gin_index.py create mode 100644 api/tests/unit/projects/code_references/test_unit_projects_code_references_db_helpers.py create mode 100644 api/tests/unit/projects/code_references/test_unit_projects_code_references_models.py diff --git a/api/projects/code_references/db_helpers.py b/api/projects/code_references/db_helpers.py new file mode 100644 index 000000000000..14848242db79 --- /dev/null +++ b/api/projects/code_references/db_helpers.py @@ -0,0 +1,27 @@ +from django.db.models import BooleanField, Func + + +class ArrayContains(Func): + """Generates: array_col @> ARRAY[value]::text[] + + Used to check whether a text array column contains a single expression + value, in a form that PostgreSQL can satisfy with a GIN index. The + standard ArrayField __contains lookup only accepts concrete Python values, + not ORM expressions such as F() or OuterRef(), hence this helper. + """ + + output_field = BooleanField() + + def as_sql( + self, + compiler: object, + connection: object, + **extra_context: object, + ) -> tuple[str, list[object]]: + array_expr, value_expr = self.source_expressions + array_sql, array_params = compiler.compile(array_expr) # type: ignore[union-attr] + value_sql, value_params = compiler.compile(value_expr) # type: ignore[union-attr] + return f"{array_sql} @> ARRAY[{value_sql}]::text[]", [ + *array_params, + *value_params, + ] diff --git a/api/projects/code_references/migrations/0003_add_feature_names.py b/api/projects/code_references/migrations/0003_add_feature_names.py new file mode 100644 index 000000000000..801df47f0bba --- /dev/null +++ b/api/projects/code_references/migrations/0003_add_feature_names.py @@ -0,0 +1,33 @@ +from django.contrib.postgres.fields import ArrayField +from django.db import migrations, models + + +def _backfill_feature_names(apps: object, schema_editor: object) -> None: + FeatureFlagCodeReferencesScan = apps.get_model( # type: ignore[union-attr] + "code_references", "FeatureFlagCodeReferencesScan" + ) + scans = list(FeatureFlagCodeReferencesScan.objects.all()) + for scan in scans: + scan.feature_names = sorted( + {ref["feature_name"] for ref in scan.code_references} + ) + FeatureFlagCodeReferencesScan.objects.bulk_update(scans, ["feature_names"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("code_references", "0002_add_project_repo_created_index"), + ] + + operations = [ + migrations.AddField( + model_name="featureflagcodereferencesscan", + field=ArrayField(models.TextField(), default=list), + name="feature_names", + ), + migrations.RunPython( + _backfill_feature_names, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/api/projects/code_references/migrations/0004_add_feature_names_gin_index.py b/api/projects/code_references/migrations/0004_add_feature_names_gin_index.py new file mode 100644 index 000000000000..812e9d5896c3 --- /dev/null +++ b/api/projects/code_references/migrations/0004_add_feature_names_gin_index.py @@ -0,0 +1,34 @@ +from django.contrib.postgres.indexes import GinIndex +from django.db import migrations + +from core.migration_helpers import PostgresOnlyRunSQL + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ("code_references", "0003_add_feature_names"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.AddIndex( + model_name="featureflagcodereferencesscan", + index=GinIndex( + fields=["feature_names"], + name="code_refs_feat_names_gin_idx", + ), + ), + ], + database_operations=[ + PostgresOnlyRunSQL( + 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "code_refs_feat_names_gin_idx" ' + 'ON "code_references_featureflagcodereferencesscan" USING gin ("feature_names");', + reverse_sql='DROP INDEX CONCURRENTLY IF EXISTS "code_refs_feat_names_gin_idx"', + ), + ], + ), + ] diff --git a/api/projects/code_references/models.py b/api/projects/code_references/models.py index 208e74b84061..5f83f5523778 100644 --- a/api/projects/code_references/models.py +++ b/api/projects/code_references/models.py @@ -1,9 +1,16 @@ +from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.indexes import GinIndex from django.db import models +from django_lifecycle import ( # type: ignore[import-untyped] + BEFORE_SAVE, + LifecycleModel, + hook, +) from projects.code_references.types import JSONCodeReference, VCSProvider -class FeatureFlagCodeReferencesScan(models.Model): +class FeatureFlagCodeReferencesScan(LifecycleModel): """ A scan of feature flag code references in a repository """ @@ -25,6 +32,10 @@ class FeatureFlagCodeReferencesScan(models.Model): revision = models.CharField(max_length=100) code_references = models.JSONField[list[JSONCodeReference]](default=list) + # Denormalised from code_references for efficient indexed lookups. + # Populated automatically before save and kept in sorted order. + feature_names = ArrayField(models.TextField(), default=list) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) class Meta: @@ -34,4 +45,14 @@ class Meta: fields=["project", "repository_url", "-created_at"], name="code_ref_proj_repo_created_idx", ), + GinIndex( + fields=["feature_names"], + name="code_refs_feat_names_gin_idx", + ), ] + + @hook(BEFORE_SAVE) + def populate_feature_names(self) -> None: + self.feature_names = sorted( + {ref["feature_name"] for ref in self.code_references} + ) diff --git a/api/projects/code_references/services.py b/api/projects/code_references/services.py index 9e2cf6459145..6ddf490b3f5b 100644 --- a/api/projects/code_references/services.py +++ b/api/projects/code_references/services.py @@ -4,7 +4,6 @@ from django.contrib.postgres.expressions import ArraySubquery from django.contrib.postgres.fields import ArrayField from django.db.models import ( - BooleanField, F, Func, JSONField, @@ -20,6 +19,7 @@ from projects.code_references.constants import ( FEATURE_FLAG_CODE_REFERENCES_RETENTION_DAYS, ) +from projects.code_references.db_helpers import ArrayContains from projects.code_references.models import FeatureFlagCodeReferencesScan from projects.code_references.types import ( CodeReference, @@ -53,19 +53,13 @@ def annotate_feature_queryset_with_code_references_summary( last_feature_found_at = ( FeatureFlagCodeReferencesScan.objects.annotate( feature_name=OuterRef("feature_name"), - contains_feature_name=Func( - F("code_references"), - Value("$[*] ? (@.feature_name == $feature_name)"), - JSONObject(feature_name=F("feature_name")), - function="jsonb_path_exists", - output_field=BooleanField(), - ), + has_feature_name=ArrayContains(F("feature_names"), F("feature_name")), ) .filter( project=OuterRef("project_id"), created_at__gte=timezone.now() - history_delta, repository_url=OuterRef("repository_url"), - contains_feature_name=True, + has_feature_name=True, ) .values("created_at") .order_by("-created_at")[:1] @@ -122,7 +116,7 @@ def get_code_references_for_feature_flag( project=feature.project, created_at__gte=timezone.now() - history_delta, repository_url=OuterRef("repository_url"), - code_references__contains=[{"feature_name": feature.name}], + feature_names__contains=[feature.name], ) .values("created_at") .order_by("-created_at")[:1] diff --git a/api/tests/unit/projects/code_references/test_unit_projects_code_references_db_helpers.py b/api/tests/unit/projects/code_references/test_unit_projects_code_references_db_helpers.py new file mode 100644 index 000000000000..ef4af061b8f1 --- /dev/null +++ b/api/tests/unit/projects/code_references/test_unit_projects_code_references_db_helpers.py @@ -0,0 +1,84 @@ +import pytest +from django.contrib.postgres.fields import ArrayField +from django.db import connection, models +from django.db.models import F, Value +from django.test.utils import isolate_apps + +from projects.code_references.db_helpers import ArrayContains + + +@pytest.fixture() +def names_model(db): # type: ignore[misc] + with isolate_apps("projects.code_references"): + + class NamesModel(models.Model): + names = ArrayField(models.TextField(), default=list) + + class Meta: + app_label = "projects.code_references" + + with connection.schema_editor() as editor: + editor.create_model(NamesModel) + + yield NamesModel + + +def test_ArrayContains__matches_when_value_present_in_array( + names_model: models.Model, +) -> None: + # Given + names_model.objects.create(names=["john", "esme"]) + + # When + result = names_model.objects.annotate( + has_name=ArrayContains(F("names"), Value("esme")), + ).get() + + # Then + assert result.has_name is True + + +def test_ArrayContains__does_not_match_when_value_absent_from_array( + names_model: models.Model, +) -> None: + # Given + names_model.objects.create(names=["john"]) + + # When + result = names_model.objects.annotate( + has_name=ArrayContains(F("names"), Value("lisa")), + ).get() + + # Then + assert result.has_name is False + + +def test_ArrayContains__filters_queryset_correctly( + names_model: models.Model, +) -> None: + # Given + matching = names_model.objects.create(names=["john", "esme"]) + names_model.objects.create(names=["lisa"]) + + # When + results = names_model.objects.annotate( + has_name=ArrayContains(F("names"), Value("john")), + ).filter(has_name=True) + + # Then + assert list(results) == [matching] + + +def test_ArrayContains__does_not_match_empty_array( + names_model: models.Model, +) -> None: + # Given + names_model.objects.create(names=[]) + + # When + result = names_model.objects.annotate( + has_name=ArrayContains(F("names"), Value("kiefer")), + ).get() + + # Then + assert result.has_name is False diff --git a/api/tests/unit/projects/code_references/test_unit_projects_code_references_models.py b/api/tests/unit/projects/code_references/test_unit_projects_code_references_models.py new file mode 100644 index 000000000000..85029a79ec45 --- /dev/null +++ b/api/tests/unit/projects/code_references/test_unit_projects_code_references_models.py @@ -0,0 +1,63 @@ +from projects.code_references.models import FeatureFlagCodeReferencesScan +from projects.models import Project + + +def test_FeatureFlagCodeReferencesScan__save__populates_feature_names( + project: Project, +) -> None: + # Given + scan = FeatureFlagCodeReferencesScan( + project=project, + repository_url="https://github.com/test/repo", + revision="abc123", + code_references=[ + {"feature_name": "feature-a", "file_path": "src/a.py", "line_number": 1}, + {"feature_name": "feature-b", "file_path": "src/b.py", "line_number": 2}, + ], + ) + + # When + scan.save() + + # Then + assert scan.feature_names == ["feature-a", "feature-b"] + + +def test_FeatureFlagCodeReferencesScan__save__deduplicates_feature_names( + project: Project, +) -> None: + # Given - feature-a referenced in two files + scan = FeatureFlagCodeReferencesScan( + project=project, + repository_url="https://github.com/test/repo", + revision="abc123", + code_references=[ + {"feature_name": "feature-a", "file_path": "src/a.py", "line_number": 1}, + {"feature_name": "feature-a", "file_path": "src/a.py", "line_number": 5}, + {"feature_name": "feature-b", "file_path": "src/b.py", "line_number": 2}, + ], + ) + + # When + scan.save() + + # Then + assert scan.feature_names == ["feature-a", "feature-b"] + + +def test_FeatureFlagCodeReferencesScan__save__sets_empty_feature_names_for_empty_code_references( + project: Project, +) -> None: + # Given + scan = FeatureFlagCodeReferencesScan( + project=project, + repository_url="https://github.com/test/repo", + revision="abc123", + code_references=[], + ) + + # When + scan.save() + + # Then + assert scan.feature_names == [] diff --git a/api/tests/unit/projects/code_references/test_unit_projects_code_references_views.py b/api/tests/unit/projects/code_references/test_unit_projects_code_references_views.py index 497398b942c4..73e8961b23f7 100644 --- a/api/tests/unit/projects/code_references/test_unit_projects_code_references_views.py +++ b/api/tests/unit/projects/code_references/test_unit_projects_code_references_views.py @@ -381,3 +381,40 @@ def test_FeatureCodeReferencesDetailAPIView__responds_404_when_feature_not_found # Then assert response.status_code == 404 assert response.data["detail"] == "No Feature matches the given query." + + +def test_CodeReferenceCreateAPIView__responds_201__populates_feature_names( + admin_client_new: APIClient, + project: Project, +) -> None: + # When + response = admin_client_new.post( + f"/api/v1/projects/{project.pk}/code-references/", + data={ + "repository_url": "https://svn.flagsmith.com/", + "revision": "revision-hash", + "code_references": [ + { + "feature_name": "feature-1", + "file_path": "path/to/file1.py", + "line_number": 10, + }, + { + "feature_name": "feature-1", + "file_path": "path/to/file2.py", + "line_number": 20, + }, + { + "feature_name": "feature-2", + "file_path": "path/to/file3.py", + "line_number": 30, + }, + ], + }, + format="json", + ) + + # Then + assert response.status_code == 201 + scan = FeatureFlagCodeReferencesScan.objects.get() + assert scan.feature_names == ["feature-1", "feature-2"] From 3c98f2f194a63b5636004eea53d16aa983cc3725 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 4 Mar 2026 17:27:25 +0000 Subject: [PATCH 2/6] Typing fixes --- api/projects/code_references/db_helpers.py | 17 +++++++++----- .../migrations/0003_add_feature_names.py | 2 +- api/projects/code_references/models.py | 16 +++++++------- ...nit_projects_code_references_db_helpers.py | 22 ++++++++++--------- 4 files changed, 32 insertions(+), 25 deletions(-) diff --git a/api/projects/code_references/db_helpers.py b/api/projects/code_references/db_helpers.py index 14848242db79..2b0de88ecfd8 100644 --- a/api/projects/code_references/db_helpers.py +++ b/api/projects/code_references/db_helpers.py @@ -1,4 +1,8 @@ +from typing import Any + +from django.db.backends.base.base import BaseDatabaseWrapper from django.db.models import BooleanField, Func +from django.db.models.sql.compiler import SQLCompiler class ArrayContains(Func): @@ -14,13 +18,14 @@ class ArrayContains(Func): def as_sql( self, - compiler: object, - connection: object, - **extra_context: object, - ) -> tuple[str, list[object]]: + compiler: SQLCompiler, + connection: BaseDatabaseWrapper, + *_: Any, + **extra_context: Any, + ) -> tuple[str, list[str | int] | tuple[str | int, ...] | tuple[()]]: array_expr, value_expr = self.source_expressions - array_sql, array_params = compiler.compile(array_expr) # type: ignore[union-attr] - value_sql, value_params = compiler.compile(value_expr) # type: ignore[union-attr] + array_sql, array_params = compiler.compile(array_expr) + value_sql, value_params = compiler.compile(value_expr) return f"{array_sql} @> ARRAY[{value_sql}]::text[]", [ *array_params, *value_params, diff --git a/api/projects/code_references/migrations/0003_add_feature_names.py b/api/projects/code_references/migrations/0003_add_feature_names.py index 801df47f0bba..39e3996697a3 100644 --- a/api/projects/code_references/migrations/0003_add_feature_names.py +++ b/api/projects/code_references/migrations/0003_add_feature_names.py @@ -3,7 +3,7 @@ def _backfill_feature_names(apps: object, schema_editor: object) -> None: - FeatureFlagCodeReferencesScan = apps.get_model( # type: ignore[union-attr] + FeatureFlagCodeReferencesScan = apps.get_model( # type: ignore[attr-defined] "code_references", "FeatureFlagCodeReferencesScan" ) scans = list(FeatureFlagCodeReferencesScan.objects.all()) diff --git a/api/projects/code_references/models.py b/api/projects/code_references/models.py index 5f83f5523778..5fbe9a947e33 100644 --- a/api/projects/code_references/models.py +++ b/api/projects/code_references/models.py @@ -10,33 +10,33 @@ from projects.code_references.types import JSONCodeReference, VCSProvider -class FeatureFlagCodeReferencesScan(LifecycleModel): +class FeatureFlagCodeReferencesScan(LifecycleModel): # type: ignore[misc] """ A scan of feature flag code references in a repository """ - project = models.ForeignKey( + project = models.ForeignKey( # type: ignore[var-annotated] "projects.Project", on_delete=models.CASCADE, related_name="code_references", ) # Provider-agnostic URL to the web UI of the repository, e.g. https://github.flagsmith.com/backend/ - repository_url = models.URLField() + repository_url = models.URLField() # type: ignore[var-annotated] - vcs_provider = models.CharField( + vcs_provider = models.CharField( # type: ignore[var-annotated] max_length=50, choices=VCSProvider.choices, default=VCSProvider.GITHUB, # TODO: Remove when adding other providers ) - revision = models.CharField(max_length=100) + revision = models.CharField(max_length=100) # type: ignore[var-annotated] code_references = models.JSONField[list[JSONCodeReference]](default=list) # Denormalised from code_references for efficient indexed lookups. # Populated automatically before save and kept in sorted order. - feature_names = ArrayField(models.TextField(), default=list) + feature_names = ArrayField(models.TextField(), default=list) # type: ignore[var-annotated] - created_at = models.DateTimeField(auto_now_add=True, db_index=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) # type: ignore[var-annotated] class Meta: ordering = ["-created_at"] @@ -51,7 +51,7 @@ class Meta: ), ] - @hook(BEFORE_SAVE) + @hook(BEFORE_SAVE) # type: ignore[misc] def populate_feature_names(self) -> None: self.feature_names = sorted( {ref["feature_name"] for ref in self.code_references} diff --git a/api/tests/unit/projects/code_references/test_unit_projects_code_references_db_helpers.py b/api/tests/unit/projects/code_references/test_unit_projects_code_references_db_helpers.py index ef4af061b8f1..3239b3030174 100644 --- a/api/tests/unit/projects/code_references/test_unit_projects_code_references_db_helpers.py +++ b/api/tests/unit/projects/code_references/test_unit_projects_code_references_db_helpers.py @@ -1,3 +1,5 @@ +from typing import Generator + import pytest from django.contrib.postgres.fields import ArrayField from django.db import connection, models @@ -8,7 +10,7 @@ @pytest.fixture() -def names_model(db): # type: ignore[misc] +def names_model(db: None) -> Generator[type[models.Model]]: with isolate_apps("projects.code_references"): class NamesModel(models.Model): @@ -27,10 +29,10 @@ def test_ArrayContains__matches_when_value_present_in_array( names_model: models.Model, ) -> None: # Given - names_model.objects.create(names=["john", "esme"]) + names_model.objects.create(names=["john", "esme"]) # type: ignore[attr-defined] # When - result = names_model.objects.annotate( + result = names_model.objects.annotate( # type: ignore[attr-defined] has_name=ArrayContains(F("names"), Value("esme")), ).get() @@ -42,10 +44,10 @@ def test_ArrayContains__does_not_match_when_value_absent_from_array( names_model: models.Model, ) -> None: # Given - names_model.objects.create(names=["john"]) + names_model.objects.create(names=["john"]) # type: ignore[attr-defined] # When - result = names_model.objects.annotate( + result = names_model.objects.annotate( # type: ignore[attr-defined] has_name=ArrayContains(F("names"), Value("lisa")), ).get() @@ -57,11 +59,11 @@ def test_ArrayContains__filters_queryset_correctly( names_model: models.Model, ) -> None: # Given - matching = names_model.objects.create(names=["john", "esme"]) - names_model.objects.create(names=["lisa"]) + matching = names_model.objects.create(names=["john", "esme"]) # type: ignore[attr-defined] + names_model.objects.create(names=["lisa"]) # type: ignore[attr-defined] # When - results = names_model.objects.annotate( + results = names_model.objects.annotate( # type: ignore[attr-defined] has_name=ArrayContains(F("names"), Value("john")), ).filter(has_name=True) @@ -73,10 +75,10 @@ def test_ArrayContains__does_not_match_empty_array( names_model: models.Model, ) -> None: # Given - names_model.objects.create(names=[]) + names_model.objects.create(names=[]) # type: ignore[attr-defined] # When - result = names_model.objects.annotate( + result = names_model.objects.annotate( # type: ignore[attr-defined] has_name=ArrayContains(F("names"), Value("kiefer")), ).get() From 752b4fc7abee9a1a49bd8310156a9b87de86739c Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 4 Mar 2026 17:51:53 +0000 Subject: [PATCH 3/6] Minor tidy up to simplify diff --- .../code_references/migrations/0003_add_feature_names.py | 4 ++-- api/projects/code_references/models.py | 4 ++-- api/projects/code_references/services.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/projects/code_references/migrations/0003_add_feature_names.py b/api/projects/code_references/migrations/0003_add_feature_names.py index 39e3996697a3..4beeedc437a9 100644 --- a/api/projects/code_references/migrations/0003_add_feature_names.py +++ b/api/projects/code_references/migrations/0003_add_feature_names.py @@ -2,7 +2,7 @@ from django.db import migrations, models -def _backfill_feature_names(apps: object, schema_editor: object) -> None: +def backfill_feature_names(apps: object, schema_editor: object) -> None: FeatureFlagCodeReferencesScan = apps.get_model( # type: ignore[attr-defined] "code_references", "FeatureFlagCodeReferencesScan" ) @@ -27,7 +27,7 @@ class Migration(migrations.Migration): name="feature_names", ), migrations.RunPython( - _backfill_feature_names, + backfill_feature_names, reverse_code=migrations.RunPython.noop, ), ] diff --git a/api/projects/code_references/models.py b/api/projects/code_references/models.py index 5fbe9a947e33..492bac8b2393 100644 --- a/api/projects/code_references/models.py +++ b/api/projects/code_references/models.py @@ -32,12 +32,12 @@ class FeatureFlagCodeReferencesScan(LifecycleModel): # type: ignore[misc] revision = models.CharField(max_length=100) # type: ignore[var-annotated] code_references = models.JSONField[list[JSONCodeReference]](default=list) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) # type: ignore[var-annotated] + # Denormalised from code_references for efficient indexed lookups. # Populated automatically before save and kept in sorted order. feature_names = ArrayField(models.TextField(), default=list) # type: ignore[var-annotated] - created_at = models.DateTimeField(auto_now_add=True, db_index=True) # type: ignore[var-annotated] - class Meta: ordering = ["-created_at"] indexes = [ diff --git a/api/projects/code_references/services.py b/api/projects/code_references/services.py index 6ddf490b3f5b..046b3e12f8e0 100644 --- a/api/projects/code_references/services.py +++ b/api/projects/code_references/services.py @@ -53,13 +53,13 @@ def annotate_feature_queryset_with_code_references_summary( last_feature_found_at = ( FeatureFlagCodeReferencesScan.objects.annotate( feature_name=OuterRef("feature_name"), - has_feature_name=ArrayContains(F("feature_names"), F("feature_name")), + contains_feature_name=ArrayContains(F("feature_names"), F("feature_name")), ) .filter( project=OuterRef("project_id"), created_at__gte=timezone.now() - history_delta, repository_url=OuterRef("repository_url"), - has_feature_name=True, + contains_feature_name=True, ) .values("created_at") .order_by("-created_at")[:1] From 5bea66fcf8f8ebfec312bfaca7a92f0300f550bc Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 4 Mar 2026 17:57:34 +0000 Subject: [PATCH 4/6] Add migration test --- ...nit_projects_code_references_migrations.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 api/tests/unit/projects/code_references/test_unit_projects_code_references_migrations.py diff --git a/api/tests/unit/projects/code_references/test_unit_projects_code_references_migrations.py b/api/tests/unit/projects/code_references/test_unit_projects_code_references_migrations.py new file mode 100644 index 000000000000..946373c088a4 --- /dev/null +++ b/api/tests/unit/projects/code_references/test_unit_projects_code_references_migrations.py @@ -0,0 +1,54 @@ +from django_test_migrations.migrator import Migrator + + +def test_backfill_feature_names(migrator: Migrator) -> None: + # Given + old_state = migrator.apply_initial_migration( + ("code_references", "0002_add_project_repo_created_index") + ) + + Organisation = old_state.apps.get_model("organisations", "Organisation") + Project = old_state.apps.get_model("projects", "Project") + FeatureFlagCodeReferencesScan = old_state.apps.get_model( + "code_references", "FeatureFlagCodeReferencesScan" + ) + + organisation = Organisation.objects.create(name="Test Organisation") + project = Project.objects.create(name="Test Project", organisation=organisation) + + # A scan with references to multiple features (unsorted) including a duplicate + scan_with_references = FeatureFlagCodeReferencesScan.objects.create( + project=project, + repository_url="https://github.com/example/repo", + revision="abc123", + code_references=[ + {"feature_name": "zebra_flag", "file_path": "foo.py", "line_number": 1}, + {"feature_name": "alpha_flag", "file_path": "bar.py", "line_number": 2}, + {"feature_name": "zebra_flag", "file_path": "baz.py", "line_number": 3}, + ], + ) + + # A scan with no code references + scan_with_no_references = FeatureFlagCodeReferencesScan.objects.create( + project=project, + repository_url="https://github.com/example/repo", + revision="def456", + code_references=[], + ) + + # When + new_state = migrator.apply_tested_migration( + ("code_references", "0003_add_feature_names") + ) + NewScan = new_state.apps.get_model( + "code_references", "FeatureFlagCodeReferencesScan" + ) + + # Then + # Duplicates are removed and names are sorted + updated_scan = NewScan.objects.get(id=scan_with_references.id) + assert updated_scan.feature_names == ["alpha_flag", "zebra_flag"] + + # Empty code_references results in empty feature_names + updated_scan_no_refs = NewScan.objects.get(id=scan_with_no_references.id) + assert updated_scan_no_refs.feature_names == [] From 5eae45b96c50a8e79da6f451dd3b39084e5eef24 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 4 Mar 2026 18:00:15 +0000 Subject: [PATCH 5/6] Typing improvement --- .../code_references/migrations/0003_add_feature_names.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/projects/code_references/migrations/0003_add_feature_names.py b/api/projects/code_references/migrations/0003_add_feature_names.py index 4beeedc437a9..6a54fd70ffba 100644 --- a/api/projects/code_references/migrations/0003_add_feature_names.py +++ b/api/projects/code_references/migrations/0003_add_feature_names.py @@ -1,9 +1,11 @@ +from django.apps.registry import Apps from django.contrib.postgres.fields import ArrayField from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor -def backfill_feature_names(apps: object, schema_editor: object) -> None: - FeatureFlagCodeReferencesScan = apps.get_model( # type: ignore[attr-defined] +def backfill_feature_names(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + FeatureFlagCodeReferencesScan = apps.get_model( "code_references", "FeatureFlagCodeReferencesScan" ) scans = list(FeatureFlagCodeReferencesScan.objects.all()) From 2b03644e4d164995b243757e0d2f2ab755eab948 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 4 Mar 2026 18:08:52 +0000 Subject: [PATCH 6/6] Another one... --- .../test_unit_projects_code_references_db_helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/tests/unit/projects/code_references/test_unit_projects_code_references_db_helpers.py b/api/tests/unit/projects/code_references/test_unit_projects_code_references_db_helpers.py index 3239b3030174..1a4eb80d588a 100644 --- a/api/tests/unit/projects/code_references/test_unit_projects_code_references_db_helpers.py +++ b/api/tests/unit/projects/code_references/test_unit_projects_code_references_db_helpers.py @@ -1,4 +1,4 @@ -from typing import Generator +from typing import Iterator import pytest from django.contrib.postgres.fields import ArrayField @@ -10,7 +10,7 @@ @pytest.fixture() -def names_model(db: None) -> Generator[type[models.Model]]: +def names_model(db: None) -> Iterator[type[models.Model]]: with isolate_apps("projects.code_references"): class NamesModel(models.Model):