Skip to content

Commit fbcf1b3

Browse files
emyllerclaude
andcommitted
Improve data model for live segments
Co-authored-by: Claude <[email protected]>
1 parent ec45a23 commit fbcf1b3

File tree

7 files changed

+58
-30
lines changed

7 files changed

+58
-30
lines changed

api/import_export/export.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import boto3
99
from django.core import serializers
1010
from django.core.serializers.json import DjangoJSONEncoder
11-
from django.db.models import F, Model, Q
11+
from django.db.models import Model, Q
1212

1313
from edge_api.identities.export import export_edge_identity_and_overrides
1414
from environments.identities.models import Identity
@@ -130,28 +130,28 @@ def export_projects(
130130
*_export_entities(
131131
_EntityExportConfig(
132132
Segment,
133-
Q(project__organisation__id=organisation_id, id=F("version_of")),
133+
Q(project__organisation__id=organisation_id, version_of__isnull=True),
134134
),
135135
_EntityExportConfig(
136136
SegmentRule,
137137
Q(
138138
segment__project__organisation__id=organisation_id,
139-
segment_id=F("segment__version_of"),
139+
segment__version_of__isnull=True,
140140
)
141141
| Q(
142142
rule__segment__project__organisation__id=organisation_id,
143-
rule__segment_id=F("rule__segment__version_of"),
143+
rule__segment__version_of__isnull=True,
144144
),
145145
),
146146
_EntityExportConfig(
147147
Condition,
148148
Q(
149149
rule__segment__project__organisation__id=organisation_id,
150-
rule__segment_id=F("rule__segment__version_of"),
150+
rule__segment__version_of__isnull=True,
151151
)
152152
| Q(
153153
rule__rule__segment__project__organisation__id=organisation_id,
154-
rule__rule__segment_id=F("rule__rule__segment__version_of"),
154+
rule__rule__segment__version_of__isnull=True,
155155
),
156156
),
157157
_EntityExportConfig(Tag, default_filter),
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 4.2.22 on 2025-11-11 03:43
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("segments", "0030_add_default_to_segment_version"),
10+
]
11+
12+
operations = [
13+
# Set version_of to NULL for canonical segments (where version_of_id = id).
14+
# This follows the same pattern as migration 0023 which originally set
15+
# version_of_id = id. Like that migration, this may block during deployment
16+
# depending on the number of canonical segments in the database.
17+
migrations.RunSQL(
18+
sql="UPDATE segments_segment SET version_of_id = NULL WHERE version_of_id = id;",
19+
reverse_sql="UPDATE segments_segment SET version_of_id = id WHERE version_of_id IS NULL;",
20+
),
21+
]

api/segments/models.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@
88
from django.core.exceptions import ValidationError
99
from django.db import models, transaction
1010
from django_lifecycle import ( # type: ignore[import-untyped]
11-
AFTER_CREATE,
11+
BEFORE_CREATE,
1212
LifecycleModelMixin,
13-
hook,
1413
)
1514
from flag_engine.segments import constants
1615

@@ -44,7 +43,7 @@ def get_queryset(self): # type: ignore[no-untyped-def]
4443
Returns only the canonical segments, which will always be
4544
the highest version.
4645
"""
47-
return super().get_queryset().filter(id=models.F("version_of"))
46+
return super().get_queryset().filter(version_of__isnull=True)
4847

4948

5049
class ConfiguredOrderManager(SoftDeleteExportableManager, models.Manager[ModelT]):
@@ -135,22 +134,22 @@ def __str__(self): # type: ignore[no-untyped-def]
135134
def get_skip_create_audit_log(self) -> bool:
136135
if self.is_system_segment:
137136
return True
138-
is_revision = bool(self.version_of_id and self.version_of_id != self.id)
137+
is_revision = self.version_of_id is not None
139138
return is_revision
140139

141-
@hook(AFTER_CREATE, when="version_of", is_now=None)
142-
def set_version_of_to_self_if_none(self): # type: ignore[no-untyped-def]
143-
"""
144-
This allows the segment model to reference all versions of
145-
itself including itself.
146-
"""
147-
self.version_of = self
148-
self.save_without_historical_record()
140+
@BEFORE_CREATE(when="version_of", is_now=None)
141+
def set_default_version_to_one_if_new_segment(self): # type: ignore[no-untyped-def]
142+
if self.version is None:
143+
self.version = 1
149144

150145
@transaction.atomic
151146
def clone(self, is_revision: bool = False, **extra_attrs: typing.Any) -> "Segment":
152147
"""
153-
Create a revision of the segment
148+
Create a revision of the segment.
149+
150+
Args:
151+
is_revision (bool): If True, creates a revision of the segment; if False, promotes the clone as canonical.
152+
**extra_attrs: Additional attributes to set on the cloned segment.
154153
"""
155154
cloned_segment = deepcopy(self)
156155
cloned_segment.pk = None
@@ -164,7 +163,7 @@ def clone(self, is_revision: bool = False, **extra_attrs: typing.Any) -> "Segmen
164163
cloned_segment.copy_rules_and_conditions_from(self)
165164

166165
# Handle versioning
167-
version_of = self if is_revision else cloned_segment
166+
version_of = self if is_revision else None
168167
cloned_segment.version_of = extra_attrs.get("version_of", version_of)
169168
cloned_segment.version = self.version if is_revision else 1
170169
Segment.objects.filter(pk=cloned_segment.pk).update(

api/tests/unit/segments/test_unit_segments_migrations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def test_add_versioning_to_segments_forwards(migrator: Migrator) -> None:
155155
# Then the version_of attribute is correctly set.
156156
NewSegment = new_state.apps.get_model("segments", "Segment")
157157
new_segment = NewSegment.objects.get(id=segment.id)
158-
assert new_segment.version_of == new_segment
158+
assert new_segment.version_of_id == new_segment.id
159159

160160

161161
@pytest.mark.skipif(

api/tests/unit/segments/test_unit_segments_models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def test_Segment_clone__can_create_standalone_segment_clone(
182182
# Then
183183
assert cloned_segment != segment
184184
assert cloned_segment.name == "another-segment"
185-
assert cloned_segment.version_of == cloned_segment
185+
assert cloned_segment.version_of is None
186186
assert cloned_segment.version == 1
187187

188188

api/tests/unit/segments/test_unit_segments_views.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,9 @@ def test_segments_limit_ignores_old_segment_versions(
207207

208208
# and create some older versions for the segment fixture
209209
segment.clone(is_revision=True)
210-
assert Segment.objects.filter(version_of=segment).count() == 2
210+
# With the NULL-based canonical representation, only revisions are counted here
211+
# (the canonical segment has version_of=None).
212+
assert Segment.objects.filter(version_of=segment).count() == 1
211213
assert Segment.live_objects.count() == 1
212214

213215
url = reverse("api-v1:projects:project-segments-list", args=[project.id])
@@ -1058,7 +1060,9 @@ def test_update_segment_versioned_segment(
10581060
assert response.status_code == status.HTTP_200_OK
10591061

10601062
# Now verify that a new versioned segment has been set.
1061-
assert Segment.objects.filter(version_of=segment).count() == 2
1063+
# With the NULL-based canonical representation, canonical segments have
1064+
# version_of=None, so only revisions are counted here.
1065+
assert Segment.objects.filter(version_of=segment).count() == 1
10621066

10631067
# Now check the previously versioned segment to match former count of conditions.
10641068

@@ -1093,7 +1097,10 @@ def test_update_segment_versioned_segment_with_thrown_exception(
10931097
rule=nested_rule, property="foo", operator=EQUAL, value="bar"
10941098
)
10951099

1096-
assert segment.version == 1 == Segment.objects.filter(version_of=segment).count()
1100+
# With the NULL-based canonical representation, canonical segments have
1101+
# version_of=None, so they are not included in this count.
1102+
assert segment.version == 1
1103+
assert Segment.objects.filter(version_of=segment).count() == 0
10971104

10981105
new_condition_property = "foo2"
10991106
new_condition_value = "bar"
@@ -1144,7 +1151,9 @@ def test_update_segment_versioned_segment_with_thrown_exception(
11441151
segment.refresh_from_db()
11451152

11461153
# Now verify that the version of the segment has not been changed.
1147-
assert segment.version == 1 == Segment.objects.filter(version_of=segment).count()
1154+
# The transaction should have rolled back, so no revisions should exist.
1155+
assert segment.version == 1
1156+
assert Segment.objects.filter(version_of=segment).count() == 0
11481157

11491158

11501159
@pytest.mark.parametrize(

api/util/mappers/engine.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,9 @@ def map_environment_to_engine(
201201
organisation: "Organisation" = project.organisation
202202

203203
# Read relationships - grab all the data needed from the ORM here.
204-
205-
project_segments = [
206-
ps for ps in project.segments.all() if ps.id == ps.version_of_id
207-
]
204+
# Note: project.segments is prefetched with Segment.live_objects which already
205+
# filters by version_of__isnull=True, so no need to filter again here.
206+
project_segments = list(project.segments.all())
208207

209208
project_segment_rules_by_segment_id: Dict[
210209
int,

0 commit comments

Comments
 (0)