| Author | HyeokJin Kim (hyeokjin@lablup.com) |
|---|---|
| Status | Implemented |
| Created | 2026-01-06 |
| Created-Version | 26.1.0 |
| Target-Version | 26.1.0 |
| Implemented-Version | 26.1.0 |
- JIRA: BA-3640
The StringFilter GraphQL input type should provide a complete set of string matching operations:
| Operation | Basic | Case-insensitive | NOT | Case-insensitive NOT |
|---|---|---|---|---|
| equals | equals |
iEquals |
notEquals |
iNotEquals |
| contains | contains |
iContains |
notContains |
iNotContains |
| starts_with | startsWith |
iStartsWith |
notStartsWith |
iNotStartsWith |
| ends_with | endsWith |
iEndsWith |
notEndsWith |
iNotEndsWith |
Total: 16 filter fields
However, the previous build_query_condition() method only implemented 4 of these fields:
equals,iEqualscontains,iContains
The remaining fields were silently ignored, causing confusion and unexpected behavior.
@strawberry.input
class StringFilter:
contains: Optional[str] = None
starts_with: Optional[str] = None
ends_with: Optional[str] = None
equals: Optional[str] = None
not_equals: Optional[str] = None
i_contains: Optional[str] = strawberry.field(name="iContains", default=None)
i_starts_with: Optional[str] = strawberry.field(name="iStartsWith", default=None)
i_ends_with: Optional[str] = strawberry.field(name="iEndsWith", default=None)
i_equals: Optional[str] = strawberry.field(name="iEquals", default=None)
i_not_equals: Optional[str] = strawberry.field(name="iNotEquals", default=None)
def build_query_condition(
self,
contains_factory: Callable[[str, bool], QueryCondition],
equals_factory: Callable[[str, bool], QueryCondition],
) -> Optional[QueryCondition]:
# Only 4 fields are handled, others are silently ignored
if self.equals:
return equals_factory(self.equals, False)
if self.i_equals:
return equals_factory(self.i_equals, True)
if self.contains:
return contains_factory(self.contains, False)
if self.i_contains:
return contains_factory(self.i_contains, True)
return None- Silent failure: Most filter fields are ignored without any error or warning
- Incomplete API: Users cannot use
starts_with,ends_with, or any negation operations - Missing NOT variants: No
not_contains,not_starts_with,not_ends_withfields - Factory signature limitation: Current factory signature
Callable[[str, bool], QueryCondition]cannot express negation
Introduce a dataclass to encapsulate match parameters:
@dataclass(frozen=True)
class StringMatchSpec:
"""Specification for string matching operations."""
value: str
case_insensitive: bool
negated: boolAll fields are required with no defaults to ensure explicit specification of matching behavior.
Change factory parameters to use StringMatchSpec:
def build_query_condition(
self,
contains_factory: Callable[[StringMatchSpec], QueryCondition],
equals_factory: Callable[[StringMatchSpec], QueryCondition],
starts_with_factory: Callable[[StringMatchSpec], QueryCondition],
ends_with_factory: Callable[[StringMatchSpec], QueryCondition],
) -> Optional[QueryCondition]:
"""Build a query condition from this filter using the provided factory callables.
Args:
contains_factory: Factory for LIKE '%value%' operations
equals_factory: Factory for exact match (=) operations
starts_with_factory: Factory for LIKE 'value%' operations
ends_with_factory: Factory for LIKE '%value' operations
Returns:
QueryCondition if any filter field is set, None otherwise
"""
# equals operations
if self.equals:
return equals_factory(
StringMatchSpec(self.equals, case_insensitive=False, negated=False)
)
if self.i_equals:
return equals_factory(
StringMatchSpec(self.i_equals, case_insensitive=True, negated=False)
)
if self.not_equals:
return equals_factory(
StringMatchSpec(self.not_equals, case_insensitive=False, negated=True)
)
if self.i_not_equals:
return equals_factory(
StringMatchSpec(self.i_not_equals, case_insensitive=True, negated=True)
)
# contains operations
if self.contains:
return contains_factory(
StringMatchSpec(self.contains, case_insensitive=False, negated=False)
)
if self.i_contains:
return contains_factory(
StringMatchSpec(self.i_contains, case_insensitive=True, negated=False)
)
if self.not_contains:
return contains_factory(
StringMatchSpec(self.not_contains, case_insensitive=False, negated=True)
)
if self.i_not_contains:
return contains_factory(
StringMatchSpec(self.i_not_contains, case_insensitive=True, negated=True)
)
# starts_with operations
if self.starts_with:
return starts_with_factory(
StringMatchSpec(self.starts_with, case_insensitive=False, negated=False)
)
if self.i_starts_with:
return starts_with_factory(
StringMatchSpec(self.i_starts_with, case_insensitive=True, negated=False)
)
if self.not_starts_with:
return starts_with_factory(
StringMatchSpec(self.not_starts_with, case_insensitive=False, negated=True)
)
if self.i_not_starts_with:
return starts_with_factory(
StringMatchSpec(self.i_not_starts_with, case_insensitive=True, negated=True)
)
# ends_with operations
if self.ends_with:
return ends_with_factory(
StringMatchSpec(self.ends_with, case_insensitive=False, negated=False)
)
if self.i_ends_with:
return ends_with_factory(
StringMatchSpec(self.i_ends_with, case_insensitive=True, negated=False)
)
if self.not_ends_with:
return ends_with_factory(
StringMatchSpec(self.not_ends_with, case_insensitive=False, negated=True)
)
if self.i_not_ends_with:
return ends_with_factory(
StringMatchSpec(self.i_not_ends_with, case_insensitive=True, negated=True)
)
return Noneclass ScalingGroupFilterAdapter:
@staticmethod
def build_name_filter(spec: StringMatchSpec) -> QueryCondition:
column = ScalingGroupRow.name
if spec.case_insensitive:
column = sa.func.lower(column)
value = spec.value.lower()
else:
value = spec.value
condition = column == value
if spec.negated:
condition = sa.not_(condition)
return condition
@staticmethod
def build_name_contains_filter(spec: StringMatchSpec) -> QueryCondition:
column = ScalingGroupRow.name
if spec.case_insensitive:
condition = column.ilike(f"%{spec.value}%")
else:
condition = column.like(f"%{spec.value}%")
if spec.negated:
condition = sa.not_(condition)
return condition
@staticmethod
def build_name_starts_with_filter(spec: StringMatchSpec) -> QueryCondition:
column = ScalingGroupRow.name
if spec.case_insensitive:
condition = column.ilike(f"{spec.value}%")
else:
condition = column.like(f"{spec.value}%")
if spec.negated:
condition = sa.not_(condition)
return condition
@staticmethod
def build_name_ends_with_filter(spec: StringMatchSpec) -> QueryCondition:
column = ScalingGroupRow.name
if spec.case_insensitive:
condition = column.ilike(f"%{spec.value}")
else:
condition = column.like(f"%{spec.value}")
if spec.negated:
condition = sa.not_(condition)
return condition- Factory signature change:
Callable[[str, bool], QueryCondition]→Callable[[StringMatchSpec], QueryCondition] - New required parameters:
starts_with_factoryandends_with_factoryare required
- All existing filter fields remain available with the same GraphQL names
- No changes to GraphQL schema or client-facing API
- Add
StringMatchSpecdataclass toai.backend.manager.api.gql.base - Update
StringFilter.build_query_condition()signature - Update all adapter implementations that use
build_query_condition() - Add unit tests for all filter field combinations
- Add
StringMatchSpecdataclass - Update
StringFilter.build_query_condition()method signature and implementation
- Update
ScalingGroupFilterGQLadapter - Update other adapters using
StringFilter(search codebase for usages)
- Add unit tests for all 16 filter fields
- Add unit tests for negation operations
- Add integration tests for GraphQL queries
We decided to add all NOT variants (not_contains, i_not_contains, not_starts_with, i_not_starts_with, not_ends_with, i_not_ends_with) to provide complete negation coverage:
- Consistency: All 4 operations (equals, contains, starts_with, ends_with) now have the same 4 variants (basic, case-insensitive, NOT, case-insensitive NOT)
- API completeness: Users can perform any combination of string matching without workarounds
- Future-proof: No need to add more fields later
StringMatchSpec requires all fields explicitly:
value: str- the search valuecase_insensitive: bool- whether to ignore casenegated: bool- whether to negate the condition
This design ensures that calling code explicitly specifies all matching behavior, reducing bugs from incorrect defaults.