Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 36 additions & 1 deletion backend/infrahub/core/query/attribute.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from datetime import datetime
from typing import TYPE_CHECKING, Any

from infrahub.core.constants import AttributeDBNodeType
Expand Down Expand Up @@ -381,6 +382,29 @@ def get_previous_property_value(self) -> Any:
return None


def _datetime_range_filter_clause(
filter_name: str,
filter_value: Any,
attribute_value_label: str,
param_prefix: str,
) -> tuple[list[QueryElement], dict[str, Any], list[str]]:
"""Build subquery clauses for a DateTime attribute __after / __before filter.

Filter values arriving as datetime objects are normalized to canonical UTC
ISO strings: lexicographic Cypher comparison of stored values is only correct
when both sides share canonical form. Mirrors the metadata path in
_build_metadata_filter_requirement for node_metadata__*__before/after.
"""
operator = ">" if filter_name == "after" else "<"
if isinstance(filter_value, datetime):
filter_value = Timestamp(filter_value.isoformat()).to_string()
return (
[QueryRel(labels=[RELATIONSHIP_TO_VALUE_LABEL]), QueryNode(name="av", labels=[attribute_value_label])],
{f"{param_prefix}_{filter_name}": filter_value},
[f"av.value {operator} ${param_prefix}_{filter_name}"],
)


async def default_attribute_query_filter(
name: str,
filter_name: str,
Expand All @@ -401,7 +425,7 @@ async def default_attribute_query_filter(
query_params: dict[str, Any] = {}
query_where: list[str] = []

if filter_value and not isinstance(filter_value, str | bool | int | list):
if filter_value and not isinstance(filter_value, str | bool | int | list | datetime):
raise TypeError(f"filter {filter_name}: {filter_value} ({type(filter_value)}) is not supported.")

if isinstance(filter_value, list) and not all(isinstance(value, str | bool | int) for value in filter_value):
Expand Down Expand Up @@ -473,6 +497,17 @@ async def default_attribute_query_filter(
)
query_params[f"{param_prefix}_{filter_name}"] = filter_value

elif filter_name in ("before", "after") and filter_value is not None:
new_filter, new_params, new_where = _datetime_range_filter_clause(
filter_name=filter_name,
filter_value=filter_value,
attribute_value_label=attribute_value_label,
param_prefix=param_prefix,
)
query_filter.extend(new_filter)
query_params.update(new_params)
query_where.extend(new_where)

elif filter_name in [v.value for v in FlagProperty] and filter_value is not None:
query_filter.append(QueryRel(labels=[filter_name.upper()]))
query_filter.append(
Expand Down
15 changes: 15 additions & 0 deletions backend/infrahub/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,21 @@ class DateTime(InfrahubDataType):
graphql_filter = graphene.DateTime
infrahub = "DateTime"

@classmethod
def get_graphql_filters(
cls, name: str, include_properties: bool = True, include_isnull: bool = False
) -> dict[str, typing.Any]:
filters = super().get_graphql_filters(
name=name, include_properties=include_properties, include_isnull=include_isnull
)
filters[f"{name}__after"] = graphene.DateTime(
description=f"Filter where {name} is strictly after this timestamp"
)
filters[f"{name}__before"] = graphene.DateTime(
description=f"Filter where {name} is strictly before this timestamp"
)
return filters


class Email(InfrahubDataType):
label: str = "Email"
Expand Down
139 changes: 139 additions & 0 deletions backend/tests/component/graphql/test_graphql_query_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -1280,3 +1280,142 @@ async def test_graphql_metadata_filter_with_non_utc_timezone_offset(
assert result.data["TestCriticality"]["count"] == 1
names = {e["node"]["name"]["value"] for e in result.data["TestCriticality"]["edges"]}
assert names == {"tz-second"}


# ============================================================================
# DateTime Attribute Filter Tests
# ============================================================================


class TestDateTimeAttributeFilter:
"""Test class for DateTime attribute filtering (time__after / time__before, used independently and combined).

These tests target the GraphQL filter inputs `<attr>__after` and `<attr>__before`
for node attributes of kind `DateTime`, mirroring the existing
`node_metadata__created_at__after` / `node_metadata__updated_at__after` filters.
"""

async def _run_query(
self, db: InfrahubDatabase, query_str: str, branch: Branch, variables: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Execute a GraphQL query and return the data."""
gql_params = await prepare_graphql_params(db=db, branch=branch)
result = await graphql(
schema=gql_params.schema,
source=query_str,
context_value=gql_params.context,
root_value=None,
variable_values=variables or {},
)
assert result.errors is None, f"GraphQL errors: {result.errors}"
assert result.data
return result.data

def _get_names(self, data: dict[str, Any], query_name: str = "TestCriticality") -> set[str]:
"""Extract node names from query result."""
return {e["node"]["name"]["value"] for e in data[query_name]["edges"]}

async def test_attribute_time_after(
self, db: InfrahubDatabase, default_branch: Branch, criticality_schema: NodeSchema
) -> None:
"""Test that <attr>__after filters DateTime attribute values strictly greater than the cutoff."""
early = await Node.init(db=db, schema=criticality_schema)
await early.new(db=db, name="early", level=1, time="2026-01-01T00:00:00Z")
await early.save(db=db)

late = await Node.init(db=db, schema=criticality_schema)
await late.new(db=db, name="late", level=2, time="2026-06-01T00:00:00Z")
await late.save(db=db)

query = """
query($cutoff: DateTime!) {
TestCriticality(time__after: $cutoff) {
count
edges { node { name { value } } }
}
}
"""
data = await self._run_query(db, query, default_branch, {"cutoff": "2026-03-01T00:00:00Z"})
assert data["TestCriticality"]["count"] == 1
assert self._get_names(data) == {"late"}

async def test_attribute_time_before(
self, db: InfrahubDatabase, default_branch: Branch, criticality_schema: NodeSchema
) -> None:
"""Test that <attr>__before filters DateTime attribute values strictly less than the cutoff."""
early = await Node.init(db=db, schema=criticality_schema)
await early.new(db=db, name="early", level=1, time="2026-01-01T00:00:00Z")
await early.save(db=db)

late = await Node.init(db=db, schema=criticality_schema)
await late.new(db=db, name="late", level=2, time="2026-06-01T00:00:00Z")
await late.save(db=db)

query = """
query($cutoff: DateTime!) {
TestCriticality(time__before: $cutoff) {
count
edges { node { name { value } } }
}
}
"""
data = await self._run_query(db, query, default_branch, {"cutoff": "2026-03-01T00:00:00Z"})
assert data["TestCriticality"]["count"] == 1
assert self._get_names(data) == {"early"}

async def test_attribute_time_range(
self, db: InfrahubDatabase, default_branch: Branch, criticality_schema: NodeSchema
) -> None:
"""Test that combining <attr>__after and <attr>__before bounds the result set on both sides."""
feb = await Node.init(db=db, schema=criticality_schema)
await feb.new(db=db, name="february", level=1, time="2026-02-01T00:00:00Z")
await feb.save(db=db)

apr = await Node.init(db=db, schema=criticality_schema)
await apr.new(db=db, name="april", level=2, time="2026-04-01T00:00:00Z")
await apr.save(db=db)

aug = await Node.init(db=db, schema=criticality_schema)
await aug.new(db=db, name="august", level=3, time="2026-08-01T00:00:00Z")
await aug.save(db=db)

query = """
query($after: DateTime!, $before: DateTime!) {
TestCriticality(time__after: $after, time__before: $before) {
count
edges { node { name { value } } }
}
}
"""
data = await self._run_query(
db,
query,
default_branch,
{"after": "2026-03-01T00:00:00Z", "before": "2026-06-01T00:00:00Z"},
)
assert data["TestCriticality"]["count"] == 1
assert self._get_names(data) == {"april"}

async def test_attribute_time_isnull_unaffected(
self, db: InfrahubDatabase, default_branch: Branch, criticality_schema: NodeSchema
) -> None:
"""Regression baseline: existing time__isnull filter must keep working."""
with_time = await Node.init(db=db, schema=criticality_schema)
await with_time.new(db=db, name="has_time", level=1, time="2026-01-01T00:00:00Z")
await with_time.save(db=db)

without_time = await Node.init(db=db, schema=criticality_schema)
await without_time.new(db=db, name="no_time", level=2)
await without_time.save(db=db)

query = """
query($isnull: Boolean!) {
TestCriticality(time__isnull: $isnull) {
count
edges { node { name { value } } }
}
}
"""
data = await self._run_query(db, query, default_branch, {"isnull": True})
assert data["TestCriticality"]["count"] == 1
assert self._get_names(data) == {"no_time"}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { FormAttributeValue } from "@/shared/components/form/type";
import { FormField } from "@/shared/components/ui/form";
import useFilters, { type Filter } from "@/shared/hooks/useFilters";

import { DateRangePickerFields } from "@/entities/nodes/object/ui/filters/date-range-picker-fields";
import { DynamicFilterInput } from "@/entities/nodes/object/ui/filters/dynamic-filter-input";
import {
FILTER_CONDITION,
Expand All @@ -23,12 +24,50 @@ export function AttributeFilterForm({ attributeSchema, onSuccess }: AttributeFil
const [filters, setFilters] = useFilters();
const currentFilter = filters.find((filter) => filter.name.startsWith(attributeSchema.name));
const isDatetime = attributeSchema.kind === ATTRIBUTE_KIND.DATETIME;
const defaultCondition = isDatetime ? FILTER_CONDITION.IS_EMPTY : FILTER_CONDITION.CONTAINS;

const afterFilterName = `${attributeSchema.name}__after`;
const beforeFilterName = `${attributeSchema.name}__before`;
const afterFilter = filters.find((f) => f.name === afterFilterName);
const beforeFilter = filters.find((f) => f.name === beforeFilterName);

const toISOString = (value: unknown): string =>
value instanceof Date ? value.toISOString() : String(value);

const defaultCondition = isDatetime ? FILTER_CONDITION.AFTER : FILTER_CONDITION.CONTAINS;
const [condition, setCondition] = useState<FilterCondition>(
getCurrentFilterCondition(currentFilter) ?? defaultCondition
);

const handleSubmit = (formData: Record<string, FormAttributeValue["value"]>) => {
if (
isDatetime &&
(condition === FILTER_CONDITION.AFTER ||
condition === FILTER_CONDITION.BEFORE ||
condition === FILTER_CONDITION.BETWEEN)
) {
const { afterDate, beforeDate } = formData as {
afterDate?: unknown;
beforeDate?: unknown;
};
let newFilters = filters.filter(
(f) => f.name !== afterFilterName && f.name !== beforeFilterName
);

if (
(condition === FILTER_CONDITION.AFTER || condition === FILTER_CONDITION.BETWEEN) &&
afterDate
) {
newFilters = [...newFilters, { name: afterFilterName, value: toISOString(afterDate) }];
}
if (
(condition === FILTER_CONDITION.BEFORE || condition === FILTER_CONDITION.BETWEEN) &&
beforeDate
) {
newFilters = [...newFilters, { name: beforeFilterName, value: toISOString(beforeDate) }];
}
return setFilters(newFilters);
}

if (condition === FILTER_CONDITION.CONTAINS) {
const { attribute } = formData;

Expand Down Expand Up @@ -84,7 +123,16 @@ export function AttributeFilterForm({ attributeSchema, onSuccess }: AttributeFil
onSuccess?.();
}}
>
{condition === FILTER_CONDITION.CONTAINS && (
{isDatetime &&
(condition === FILTER_CONDITION.AFTER ||
condition === FILTER_CONDITION.BEFORE ||
condition === FILTER_CONDITION.BETWEEN) ? (
<DateRangePickerFields
condition={condition}
afterDefault={afterFilter?.value as string | undefined}
beforeDefault={beforeFilter?.value as string | undefined}
/>
) : condition === FILTER_CONDITION.CONTAINS ? (
<FormField
name="attribute"
defaultValue={
Expand All @@ -96,7 +144,7 @@ export function AttributeFilterForm({ attributeSchema, onSuccess }: AttributeFil
return <DynamicFilterInput {...field} fieldSchema={attributeSchema} />;
}}
/>
)}
) : null}
</FilterFormLayout>
);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { useState } from "react";
import DateTimePicker from "react-datepicker";

import { FormField } from "@/shared/components/ui/form";
import useFilters, { type Filter } from "@/shared/hooks/useFilters";
import { DATE_TIME_FORMAT } from "@/shared/utils/date";

import type { MetadataDateFilterDefinition } from "@/entities/nodes/object/domain/filter-definition";
import { DateRangePickerFields } from "@/entities/nodes/object/ui/filters/date-range-picker-fields";
import {
FILTER_CONDITION,
type FilterCondition,
Expand Down Expand Up @@ -68,10 +66,6 @@ export function DateMetadataFilterForm({ definition, onSuccess }: DateMetadataFi
onSuccess?.();
};

const isBetween = condition === FILTER_CONDITION.BETWEEN;
const showAfter = condition === FILTER_CONDITION.AFTER || isBetween;
const showBefore = condition === FILTER_CONDITION.BEFORE || isBetween;

return (
<FilterFormLayout
filterType="metadata-date"
Expand All @@ -81,51 +75,11 @@ export function DateMetadataFilterForm({ definition, onSuccess }: DateMetadataFi
testId="metadata-date-filter-form"
onSubmit={handleSubmit}
>
<div className={isBetween ? "flex flex-row gap-4" : "flex flex-col gap-0"}>
{showAfter && (
<FormField
name="afterDate"
defaultValue={afterFilter?.value ?? undefined}
render={({ field }) => (
<div className="flex flex-col gap-1">
{isBetween && <span className="text-gray-600 text-xs">After</span>}
<DateTimePicker
selected={field.value ? new Date(field.value as string) : null}
onChange={field.onChange}
inline
showTimeSelect
timeIntervals={1}
calendarStartDay={1}
dateFormat={DATE_TIME_FORMAT}
calendarClassName="flex!"
/>
</div>
)}
/>
)}

{showBefore && (
<FormField
name="beforeDate"
defaultValue={beforeFilter?.value ?? undefined}
render={({ field }) => (
<div className="flex flex-col gap-1">
{isBetween && <span className="text-gray-600 text-xs">Before</span>}
<DateTimePicker
selected={field.value ? new Date(field.value as string) : null}
onChange={field.onChange}
inline
showTimeSelect
timeIntervals={1}
calendarStartDay={1}
dateFormat={DATE_TIME_FORMAT}
calendarClassName="flex!"
/>
</div>
)}
/>
)}
</div>
<DateRangePickerFields
condition={condition}
afterDefault={afterFilter?.value as string | undefined}
beforeDefault={beforeFilter?.value as string | undefined}
/>
</FilterFormLayout>
);
}
Loading
Loading