Skip to content

Commit d09a457

Browse files
authored
Add Number attribute min/max/excluded values (#6498)
1 parent 95426ea commit d09a457

File tree

24 files changed

+752
-59
lines changed

24 files changed

+752
-59
lines changed

backend/infrahub/core/node/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ async def handle_pool(self, db: InfrahubDatabase, attribute: BaseAttribute, erro
287287
and number_pool.node_attribute.value == attribute.name
288288
):
289289
try:
290-
next_free = await number_pool.get_resource(db=db, branch=self._branch, node=self)
290+
next_free = await number_pool.get_resource(db=db, branch=self._branch, node=self, attribute=attribute)
291291
except PoolExhaustedError:
292292
errors.append(
293293
ValidationError({f"{attribute.name}.from_pool": f"The pool {number_pool.node.value} is exhausted."})

backend/infrahub/core/node/resource_manager/number_pool.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,44 @@
22

33
from typing import TYPE_CHECKING
44

5+
from infrahub.core import registry
56
from infrahub.core.query.resource_manager import NumberPoolGetReserved, NumberPoolGetUsed, NumberPoolSetReserved
7+
from infrahub.core.schema.attribute_parameters import NumberAttributeParameters
68
from infrahub.exceptions import PoolExhaustedError
79

810
from .. import Node
911

1012
if TYPE_CHECKING:
13+
from infrahub.core.attribute import BaseAttribute
1114
from infrahub.core.branch import Branch
1215
from infrahub.database import InfrahubDatabase
1316

1417

1518
class CoreNumberPool(Node):
19+
def get_attribute_nb_excluded_values(self) -> int:
20+
"""
21+
Returns the number of excluded values for the attribute of the number pool.
22+
"""
23+
24+
pool_node = registry.schema.get(name=self.node.value) # type: ignore [attr-defined]
25+
attribute = [attribute for attribute in pool_node.attributes if attribute.name == self.node_attribute.value][0] # type: ignore [attr-defined]
26+
if not isinstance(attribute.parameters, NumberAttributeParameters):
27+
return 0
28+
29+
sum_excluded_values = 0
30+
excluded_ranges = attribute.parameters.get_excluded_ranges()
31+
for start_range, end_range in excluded_ranges:
32+
sum_excluded_values += end_range - start_range + 1
33+
34+
res = len(attribute.parameters.get_excluded_single_values()) + sum_excluded_values
35+
return res
36+
1637
async def get_resource(
1738
self,
1839
db: InfrahubDatabase,
1940
branch: Branch,
2041
node: Node,
42+
attribute: BaseAttribute,
2143
identifier: str | None = None,
2244
) -> int:
2345
identifier = identifier or node.get_id()
@@ -31,35 +53,40 @@ async def get_resource(
3153
return reservation
3254

3355
# If we have not returned a value we need to find one if avaiable
34-
number = await self.get_next(db=db, branch=branch)
56+
number = await self.get_next(db=db, branch=branch, attribute=attribute)
3557

3658
query_set = await NumberPoolSetReserved.init(
3759
db=db, pool_id=self.get_id(), identifier=identifier, reserved=number
3860
)
3961
await query_set.execute(db=db)
4062
return number
4163

42-
async def get_next(self, db: InfrahubDatabase, branch: Branch) -> int:
64+
async def get_next(self, db: InfrahubDatabase, branch: Branch, attribute: BaseAttribute) -> int:
4365
query = await NumberPoolGetUsed.init(db=db, branch=branch, pool=self, branch_agnostic=True)
4466
await query.execute(db=db)
4567
taken = [result.get_as_optional_type("av.value", return_type=int) for result in query.results]
68+
parameters = attribute.schema.parameters
4669
next_number = find_next_free(
4770
start=self.start_range.value, # type: ignore[attr-defined]
4871
end=self.end_range.value, # type: ignore[attr-defined]
4972
taken=taken,
73+
parameters=parameters if isinstance(parameters, NumberAttributeParameters) else None,
5074
)
5175
if next_number is None:
5276
raise PoolExhaustedError("There are no more values available in this pool.")
5377

5478
return next_number
5579

5680

57-
def find_next_free(start: int, end: int, taken: list[int | None]) -> int | None:
81+
def find_next_free(
82+
start: int, end: int, taken: list[int | None], parameters: NumberAttributeParameters | None
83+
) -> int | None:
5884
used_numbers = [number for number in taken if number is not None]
5985
used_set = set(used_numbers)
6086

6187
for num in range(start, end + 1):
6288
if num not in used_set:
63-
return num
89+
if parameters is None or parameters.is_valid_value(num):
90+
return num
6491

6592
return None

backend/infrahub/core/schema/attribute_parameters.py

Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def get_attribute_parameters_class_for_kind(kind: str) -> type[AttributeParamete
1414
"NumberPool": NumberPoolParameters,
1515
"Text": TextAttributeParameters,
1616
"TextArea": TextAttributeParameters,
17+
"Number": NumberAttributeParameters,
1718
}
1819
return param_classes.get(kind, AttributeParameters)
1920

@@ -23,6 +24,88 @@ class Config:
2324
extra = "forbid"
2425

2526

27+
class TextAttributeParameters(AttributeParameters):
28+
regex: str | None = Field(
29+
default=None,
30+
description="Regular expression that attribute value must match if defined",
31+
json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
32+
)
33+
min_length: int | None = Field(
34+
default=None,
35+
description="Set a minimum number of characters allowed.",
36+
json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
37+
)
38+
max_length: int | None = Field(
39+
default=None,
40+
description="Set a maximum number of characters allowed.",
41+
json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
42+
)
43+
44+
45+
class NumberAttributeParameters(AttributeParameters):
46+
min_value: int | None = Field(
47+
default=None,
48+
description="Set a minimum value allowed.",
49+
json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
50+
)
51+
max_value: int | None = Field(
52+
default=None,
53+
description="Set a maximum value allowed.",
54+
json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
55+
)
56+
excluded_values: str | None = Field(
57+
default=None,
58+
description="List of values or range of values not allowed for the attribute, format is: '100,150-200,280,300-400'",
59+
pattern=r"^(\d+(?:-\d+)?)(?:,\d+(?:-\d+)?)*$",
60+
json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
61+
)
62+
63+
@model_validator(mode="after")
64+
def validate_ranges(self) -> Self:
65+
ranges = self.get_excluded_ranges()
66+
for i, (start_range_1, end_range_1) in enumerate(ranges):
67+
if start_range_1 > end_range_1:
68+
raise ValueError("`start_range` can't be less than `end_range`")
69+
70+
# Check for overlapping ranges
71+
for start_range_2, end_range_2 in ranges[i + 1 :]:
72+
if not (end_range_1 < start_range_2 or start_range_1 > end_range_2):
73+
raise ValueError("Excluded ranges cannot overlap")
74+
75+
return self
76+
77+
def get_excluded_single_values(self) -> list[int]:
78+
if not self.excluded_values:
79+
return []
80+
81+
results = [int(value) for value in self.excluded_values.split(",") if "-" not in value]
82+
return results
83+
84+
def get_excluded_ranges(self) -> list[tuple[int, int]]:
85+
if not self.excluded_values:
86+
return []
87+
88+
ranges = []
89+
for value in self.excluded_values.split(","):
90+
if "-" in value:
91+
start, end = map(int, value.split("-"))
92+
ranges.append((start, end))
93+
94+
return ranges
95+
96+
def is_valid_value(self, value: int) -> bool:
97+
if self.min_value is not None and value < self.min_value:
98+
return False
99+
if self.max_value is not None and value > self.max_value:
100+
return False
101+
if value in self.get_excluded_single_values():
102+
return False
103+
for start, end in self.get_excluded_ranges():
104+
if start <= value <= end:
105+
return False
106+
return True
107+
108+
26109
class NumberPoolParameters(AttributeParameters):
27110
end_range: int = Field(
28111
default=sys.maxsize,
@@ -43,23 +126,5 @@ class NumberPoolParameters(AttributeParameters):
43126
@model_validator(mode="after")
44127
def validate_ranges(self) -> Self:
45128
if self.start_range > self.end_range:
46-
raise ValueError("start_range can't be less than end_range")
129+
raise ValueError("`start_range` can't be less than `end_range`")
47130
return self
48-
49-
50-
class TextAttributeParameters(AttributeParameters):
51-
regex: str | None = Field(
52-
default=None,
53-
description="Regular expression that attribute value must match if defined",
54-
json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
55-
)
56-
min_length: int | None = Field(
57-
default=None,
58-
description="Set a minimum number of characters allowed.",
59-
json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
60-
)
61-
max_length: int | None = Field(
62-
default=None,
63-
description="Set a maximum number of characters allowed.",
64-
json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
65-
)

backend/infrahub/core/schema/attribute_schema.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from .attribute_parameters import (
1616
AttributeParameters,
17+
NumberAttributeParameters,
1718
NumberPoolParameters,
1819
TextAttributeParameters,
1920
get_attribute_parameters_class_for_kind,
@@ -33,6 +34,7 @@ def get_attribute_schema_class_for_kind(kind: str) -> type[AttributeSchema]:
3334
"NumberPool": NumberPoolSchema,
3435
"Text": TextAttributeSchema,
3536
"TextArea": TextAttributeSchema,
37+
"Number": NumberAttributeSchema,
3638
}
3739
return attribute_schema_class_by_kind.get(kind, AttributeSchema)
3840

@@ -219,19 +221,13 @@ class TextAttributeSchema(AttributeSchema):
219221
@model_validator(mode="after")
220222
def reconcile_parameters(self) -> Self:
221223
if self.regex != self.parameters.regex:
222-
final_regex = self.parameters.regex or self.regex
223-
if not final_regex: # falsy parameters.regex override falsy regex
224-
final_regex = self.parameters.regex
224+
final_regex = self.parameters.regex if self.parameters.regex is not None else self.regex
225225
self.regex = self.parameters.regex = final_regex
226226
if self.min_length != self.parameters.min_length:
227-
final_min_length = self.parameters.min_length or self.min_length
228-
if not final_min_length: # falsy parameters.min_length override falsy min_length
229-
final_min_length = self.parameters.min_length
227+
final_min_length = self.parameters.min_length if self.parameters.min_length is not None else self.min_length
230228
self.min_length = self.parameters.min_length = final_min_length
231229
if self.max_length != self.parameters.max_length:
232-
final_max_length = self.parameters.max_length or self.max_length
233-
if not final_max_length: # falsy parameters.max_length override falsy max_length
234-
final_max_length = self.parameters.max_length
230+
final_max_length = self.parameters.max_length if self.parameters.max_length is not None else self.max_length
235231
self.max_length = self.parameters.max_length = final_max_length
236232
return self
237233

@@ -243,3 +239,11 @@ def get_min_length(self) -> int | None:
243239

244240
def get_max_length(self) -> int | None:
245241
return self.parameters.max_length
242+
243+
244+
class NumberAttributeSchema(AttributeSchema):
245+
parameters: NumberAttributeParameters = Field(
246+
default_factory=NumberAttributeParameters,
247+
description="Extra parameters specific to number attributes",
248+
json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
249+
)

backend/infrahub/core/schema/definitions/internal.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@
3131
RelationshipKind,
3232
UpdateSupport,
3333
)
34-
from infrahub.core.schema.attribute_parameters import AttributeParameters, NumberPoolParameters, TextAttributeParameters
34+
from infrahub.core.schema.attribute_parameters import (
35+
AttributeParameters,
36+
NumberAttributeParameters,
37+
NumberPoolParameters,
38+
TextAttributeParameters,
39+
)
3540
from infrahub.core.schema.attribute_schema import AttributeSchema
3641
from infrahub.core.schema.computed_attribute import ComputedAttribute
3742
from infrahub.core.schema.dropdown import DropdownChoice
@@ -624,7 +629,12 @@ def to_dict(self) -> dict[str, Any]:
624629
SchemaAttribute(
625630
name="parameters",
626631
kind="JSON",
627-
internal_kind=[AttributeParameters, TextAttributeParameters, NumberPoolParameters],
632+
internal_kind=[
633+
AttributeParameters,
634+
TextAttributeParameters,
635+
NumberAttributeParameters,
636+
NumberPoolParameters,
637+
],
628638
optional=True,
629639
description="Extra parameters specific to this kind of attribute",
630640
extra={"update": UpdateSupport.VALIDATE_CONSTRAINT},

backend/infrahub/core/schema/generated/attribute_schema.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from infrahub.core.models import HashableModel
1111
from infrahub.core.schema.attribute_parameters import (
1212
AttributeParameters, # noqa: TC001
13+
NumberAttributeParameters, # noqa: TC001
1314
NumberPoolParameters, # noqa: TC001
1415
TextAttributeParameters, # noqa: TC001
1516
)
@@ -117,10 +118,12 @@ class GeneratedAttributeSchema(HashableModel):
117118
description="Type of allowed override for the attribute.",
118119
json_schema_extra={"update": "allowed"},
119120
)
120-
parameters: AttributeParameters | TextAttributeParameters | NumberPoolParameters = Field(
121-
default_factory=AttributeParameters,
122-
description="Extra parameters specific to this kind of attribute",
123-
json_schema_extra={"update": "validate_constraint"},
121+
parameters: AttributeParameters | TextAttributeParameters | NumberAttributeParameters | NumberPoolParameters = (
122+
Field(
123+
default_factory=AttributeParameters,
124+
description="Extra parameters specific to this kind of attribute",
125+
json_schema_extra={"update": "validate_constraint"},
126+
)
124127
)
125128
deprecation: str | None = Field(
126129
default=None,

backend/infrahub/core/validators/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from infrahub.core.validators.attribute.min_max import AttributeNumberChecker
2+
13
from .attribute.choices import AttributeChoicesChecker
24
from .attribute.enum import AttributeEnumChecker
35
from .attribute.kind import AttributeKindChecker
@@ -26,6 +28,9 @@
2628
"attribute.max_length.update": AttributeLengthChecker,
2729
ConstraintIdentifier.ATTRIBUTE_PARAMETERS_MIN_LENGTH_UPDATE.value: AttributeLengthChecker,
2830
ConstraintIdentifier.ATTRIBUTE_PARAMETERS_MAX_LENGTH_UPDATE.value: AttributeLengthChecker,
31+
ConstraintIdentifier.ATTRIBUTE_PARAMETERS_MIN_VALUE_UPDATE.value: AttributeNumberChecker,
32+
ConstraintIdentifier.ATTRIBUTE_PARAMETERS_MAX_VALUE_UPDATE.value: AttributeNumberChecker,
33+
ConstraintIdentifier.ATTRIBUTE_PARAMETERS_EXCLUDED_VALUES_UPDATE.value: AttributeNumberChecker,
2934
"attribute.unique.update": AttributeUniquenessChecker,
3035
"attribute.optional.update": AttributeOptionalChecker,
3136
"attribute.choices.update": AttributeChoicesChecker,

backend/infrahub/core/validators/attribute/choices.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> No
4242
LIMIT 1
4343
}
4444
WITH full_path, node, attribute_value, value_relationship
45-
WITH full_path, node, attribute_value, value_relationship
4645
WHERE all(r in relationships(full_path) WHERE r.status = "active")
4746
AND attribute_value IS NOT NULL
4847
AND attribute_value <> $null_value

backend/infrahub/core/validators/attribute/enum.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> No
4141
LIMIT 1
4242
}
4343
WITH full_path, node, attribute_value, value_relationship
44-
WITH full_path, node, attribute_value, value_relationship
4544
WHERE all(r in relationships(full_path) WHERE r.status = "active")
4645
AND attribute_value IS NOT NULL
4746
AND attribute_value <> $null_value

backend/infrahub/core/validators/attribute/kind.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> No
4848
LIMIT 1
4949
}
5050
WITH full_path, node, attribute_value, value_relationship
51-
WITH full_path, node, attribute_value, value_relationship
5251
WHERE all(r in relationships(full_path) WHERE r.status = "active")
5352
AND attribute_value IS NOT NULL
5453
AND attribute_value <> $null_value

0 commit comments

Comments
 (0)