Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add version_of and matches to rules and conditions #4988

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0b6c551
Version rules and conditions
zachaysan Jan 10, 2025
1e874e8
Create tests for versioned rules and conditions as well as matches
zachaysan Jan 10, 2025
6eb58a0
Add version_of fields and add matches for segments rules and conditions
zachaysan Jan 10, 2025
80fd54e
Merge branch 'main' into feat/add_version_of_to_rules_and_conditions
zachaysan Jan 10, 2025
3c5ffea
Update lock file and pyproject.toml
zachaysan Jan 10, 2025
10ba9d1
Alter test to cover case where rule is already parted of the matched …
zachaysan Jan 16, 2025
11e387c
Add docstrings, change variables to `self_` and remove pragma: no cover
zachaysan Jan 16, 2025
760fd6a
Fix conflicts and merge branch 'main' into feat/add_version_of_to_rul…
zachaysan Jan 16, 2025
d349a62
Update method name and associated tests
zachaysan Jan 20, 2025
3c204af
Update method names and add more info to docstrings
zachaysan Jan 20, 2025
3dcede8
Fix conflicts and merge branch 'main' into feat/add_version_of_to_rul…
zachaysan Jan 20, 2025
6351031
Update docstrings, add comments, and refactor condition matching logic
zachaysan Jan 23, 2025
d97e9b3
Add comments, update conditions for test, and create new condition ex…
zachaysan Jan 23, 2025
4ee905f
Fix conflicts and merge branch 'main' into feat/add_version_of_to_rul…
zachaysan Jan 23, 2025
e21c783
Create test for case where no conditions match
zachaysan Jan 24, 2025
19039b2
Update docstrings and variable names
zachaysan Jan 24, 2025
f578619
Update method names
zachaysan Jan 24, 2025
17defee
Update comments and docstring
zachaysan Jan 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions api/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ hubspot-api-client = "^8.2.1"
djangorestframework-dataclasses = "^1.3.1"
pyotp = "^2.9.0"
flagsmith-task-processor = { git = "https://github.com/Flagsmith/flagsmith-task-processor", tag = "v1.2.0" }
flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.4.2" }
flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", branch = "feat/add_version_of_to_rules_and_conditions" }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remember to update these once we've got builds in those packages.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea.

tzdata = "^2024.1"
djangorestframework-simplejwt = "^5.3.1"
structlog = "^24.4.0"
Expand All @@ -197,7 +197,7 @@ flagsmith-ldap = { git = "https://github.com/flagsmith/flagsmith-ldap", tag = "v
optional = true

[tool.poetry.group.workflows.dependencies]
workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", tag = "v2.7.5" }
workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", branch = "feat/add_matches_to_workflows" }
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved

[tool.poetry.group.licensing]
optional = true
Expand Down
48 changes: 48 additions & 0 deletions api/segments/migrations/0027_version_rules_and_conditions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 4.2.17 on 2025-01-08 15:21

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
("segments", "0026_add_change_request_to_segments"),
]

operations = [
migrations.AddField(
model_name="condition",
name="version_of",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="versioned_conditions",
to="segments.condition",
),
),
migrations.AddField(
model_name="historicalcondition",
name="version_of",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="segments.condition",
),
),
migrations.AddField(
model_name="segmentrule",
name="version_of",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="versioned_rules",
to="segments.segmentrule",
),
),
]
147 changes: 147 additions & 0 deletions api/segments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,76 @@ def deep_clone(self) -> "Segment":

return cloned_segment

def assign_matching_rules_to_segment(self, segment: "Segment") -> bool:
"""
Assign matching rules of the current object to the rules of the given segment.

This method iterates through the rules of the provided `Segment` and
attempts to match them to the current object's rules and sub-rules,
updating versioning where matches are found.

This is done in order to set the `version_of` field for matched rules
and conditions so that the frontend can more reliably diff rules and
conditions between a change request for a segment and the target segment
itself.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still doesn't really talk about when it should be used? One of the biggest things I'm finding is that I'm consistently struggling to follow which segment is which. Like should we be more explicit here (and in all of the rule / condition variables) to say something like current_segment, modified_segment or something?

Maybe the method could then be more explicit like: update_segment_with_matches_from_current_segment(self, current_segment: "Segment")?

This feels like it's pigeon-hoing us a little bit into only using this functionality for change requests, but since we're modifying the segments, it feels like this functionality can only ever be used for that purpose anyway?

Copy link
Contributor Author

@zachaysan zachaysan Jan 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still doesn't really talk about when it should be used?

Ok I've updated the introductory paragraph to include that self is the target of the changes and that this is called in the context of a change request.

One of the biggest things I'm finding is that I'm consistently struggling to follow which segment is which. Like should we be more explicit here (and in all of the rule / condition variables) to say something like current_segment, modified_segment or something?

I've updated the code to reference current_[segment,rule,sub_rule,condition] and I've added references to which object is being updated by referencing them as modified_[rule,sub_rule,condition] to make it clear which objects are optionally being modified.

Maybe the method could then be more explicit like: update_segment_with_matches_from_current_segment(self, current_segment: "Segment")?

Ok I've updated the method name and args.

This feels like it's pigeon-hoing us a little bit into only using this functionality for change requests, but since we're modifying the segments, it feels like this functionality can only ever be used for that purpose anyway?

Yes this functionality is only going to be used for change requests.


Args:
segment (Segment): The segment whose rules are being matched
against the current object's rules.

Returns:
bool:
- `True` if any rules or sub-rules match between the current
object and the segment.
- `False` if no matches are found.

Process:
1. Retrieve all rules associated with the current object and the segment.
2. For each rule in the segment:
- Check its sub-rules against the current object's rules and sub-rules.
- A match is determined if the sub-rule's type and properties align
with those of the current object's sub-rules.
- If a match is found:
- Update the `version_of` field for the matched sub-rule and rule.
- Track the matched rules and sub-rules to avoid duplicate processing.
3. Perform a bulk update on matched rules and sub-rules to persist
versioning changes.

Side Effects:
- Updates the `version_of` field for matched rules and sub-rules.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which segment does it modify though?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I've updated this to explicitly say that it calls the segment self.

"""

self_rules = self.rules.all()
matched_rules = set()
matched_sub_rules = set()

for rule in segment.rules.all():
for sub_rule in rule.rules.all():
sub_rule_matched = False
for self_rule in self_rules:
if sub_rule_matched:
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved
break

if self_rule in matched_rules and self_rule.version_of != rule:
continue

if rule.type != self_rule.type:
continue
for self_sub_rule in self_rule.rules.all():
if self_sub_rule in matched_sub_rules:
continue
if self_sub_rule.assign_conditions_if_matching_rule(sub_rule):
self_sub_rule.version_of = sub_rule
sub_rule_matched = True
matched_sub_rules.add(self_sub_rule)
self_rule.version_of = rule
matched_rules.add(self_rule)
break
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @zachaysan , this code is still unreadable to me. I was hoping that the docstring and renaming of the variables would help, but it hasn't really. Can we add some inline comments on each of the conditional statements perhaps?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels to me like this is perhaps evidence that we're thinking about this wrong? Could we instead just store a 'diff' object (probably in JSON), or set of objects, which look(s) something like:

class SegmentConditionChanges:
    condition: Condition

    new_operator: str
    new_property: str
    new_value: str

class SegmentRuleChanges:
    rule: SegmentRule

    new_type: str

    new_rules: list[SegmentRule]
    deleted_rule_ids: list[int]
    updated_rules: list[SegmentRuleChanges]

    new_conditions: list[Condition]
    delete_condition_ids: list[int]
    updated_conditions: list[SegmentConditionChanges]

class SegmentChangeSet:
    segment: Segment

    new_name: str
    new_description: str

    new_rules: list[SegmentRule]
    deleted_rule_ids: list[int]
    updated_rules: list[SegmentRuleChanges]

... maybe it's a bit late in the day for this, but I feel like this might be more flexible?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I've added some inline comments as we discussed.

SegmentRule.objects.bulk_update(
matched_rules | matched_sub_rules, fields=["version_of"]
)
return bool(matched_rules | matched_sub_rules)

def get_create_log_message(self, history_instance) -> typing.Optional[str]:
return SEGMENT_CREATED_MESSAGE % self.name

Expand All @@ -224,6 +294,13 @@ class SegmentRule(SoftDeleteExportableModel):
rule = models.ForeignKey(
"self", on_delete=models.CASCADE, related_name="rules", null=True, blank=True
)
version_of = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
related_name="versioned_rules",
null=True,
blank=True,
)

type = models.CharField(max_length=50, choices=RULE_TYPES)

Expand Down Expand Up @@ -261,13 +338,74 @@ def get_segment(self):
rule = rule.rule
return rule.segment

def assign_conditions_if_matching_rule(self, rule: "SegmentRule") -> bool:
"""
Determines whether the current object matches the given rule
and assigns conditions with the `version_of` field.

These assignments are done in order to allow the frontend to
provide a diff capability during change requests for segments.
By knowing which version a condition is for the frontend can
show a more accurate diff between the segment and the change request.

This method compares the type and conditions of the current object with
the specified `SegmentRule` to determine if they are compatible.

Returns:
bool:
- `True` if the current object's type matches the rule's type
and the conditions are compatible.
- `False` if the types do not match or no conditions are compatible.

Process:
1. If the types do not match, return `False`.
2. If both the rule and current object have no conditions, return `True`.
3. Compare each condition in the rule against the current object's conditions:
- A condition matches if both `operator` and `property` are equal.
- Mark matched conditions and update the versioning.
4. Return `True` if at least one condition matches; otherwise, return `False`.

Side Effects:
Updates the `version_of` field for matched conditions using a bulk update.
"""

if rule.type != self.type:
return False

conditions = rule.conditions.all()
self_conditions = self.conditions.all()

if not conditions and not self_conditions:
# Empty rule with the same type matches.
return True

matched_conditions = set()

for condition in conditions:
for self_condition in self_conditions:
if self_condition in matched_conditions:
continue
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really can't see a scenario where this could be the case?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the condition had previously matched another condition, then when the outer loop starts again we don't want to match it again. This is useful for conditions that have the same property and could match different conditions.

if (
condition.operator == self_condition.operator
and condition.property == self_condition.property
):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we only care about the property here, rather than the operator as well? What if they want to change it from e.g. organisation_id EQUALS 123 to organisation_id IN 123,456 - that seems like a legitimate use case that we want to cater for, right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, but there's perhaps an issue with operators like percentage split in that case? But I think the logic should be something like:

if not condition.property: 
    if condition.operator == self_condition.operator:
        # do valid match logic
elif condition.property == self_condition.property:
    # do valid match logic

My concern here as well is that I don't think there is anyone stopping someone from adding e.g. 2 percentage split conditions to a segment. It would be a stupid idea, but there's nothing stopping it from happening as far as I know, and there are likely other scenarios where this is the case.

For example, there are a lot of segments that look something like:

organisation_id IN 123,456,...
OR 
organisation_id IN abc,def,...

This is to get around our limitation on the size of the value in a segment condition. I don't think your logic here (or mine suggested above for that matter) will handle this scenario very well, unless I'm missing something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the condition matching logic to now include operator differences as well as adding a section to the code to look for exact condition matches first.

matched_conditions.add(self_condition)
self_condition.version_of = condition
break
if not matched_conditions:
return False

Condition.objects.bulk_update(matched_conditions, fields=["version_of"])
return True

def deep_clone(self, cloned_segment: Segment) -> "SegmentRule":
if self.rule:
# Since we're expecting a rule that is only belonging to a
# segment, since a rule either belongs to a segment xor belongs
# to a rule, we don't expect there also to be a rule associated.
assert False, "Unexpected rule, expecting segment set not rule"
cloned_rule = deepcopy(self)
cloned_rule.version_of = self
cloned_rule.segment = cloned_segment
cloned_rule.uuid = uuid.uuid4()
cloned_rule.id = None
Expand All @@ -284,6 +422,7 @@ def deep_clone(self, cloned_segment: Segment) -> "SegmentRule":
assert False, "Expected two layers of rules, not more"

cloned_sub_rule = deepcopy(sub_rule)
cloned_sub_rule.version_of = sub_rule
cloned_sub_rule.rule = cloned_rule
cloned_sub_rule.uuid = uuid.uuid4()
cloned_sub_rule.id = None
Expand All @@ -296,6 +435,7 @@ def deep_clone(self, cloned_segment: Segment) -> "SegmentRule":
cloned_conditions = []
for condition in sub_rule.conditions.all():
cloned_condition = deepcopy(condition)
cloned_condition.version_of = condition
cloned_condition.rule = cloned_sub_rule
cloned_condition.uuid = uuid.uuid4()
cloned_condition.id = None
Expand Down Expand Up @@ -348,6 +488,13 @@ class Condition(
rule = models.ForeignKey(
SegmentRule, on_delete=models.CASCADE, related_name="conditions"
)
version_of = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
related_name="versioned_conditions",
null=True,
blank=True,
)

created_at = models.DateTimeField(null=True, auto_now_add=True)
updated_at = models.DateTimeField(null=True, auto_now=True)
Expand Down
Loading
Loading