-
Notifications
You must be signed in to change notification settings - Fork 412
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
base: main
Are you sure you want to change the base?
Changes from 11 commits
0b6c551
1e874e8
6eb58a0
80fd54e
3c5ffea
10ba9d1
11e387c
760fd6a
d349a62
3c204af
3dcede8
6351031
d97e9b3
4ee905f
e21c783
19039b2
f578619
17defee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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", | ||
), | ||
), | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Maybe the method could then be more explicit like: 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Ok I've updated the introductory paragraph to include that
I've updated the code to reference
Ok I've updated the method name and args.
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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Which segment does it modify though? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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_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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
||
|
@@ -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) | ||
|
||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -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) | ||
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea.