Skip to content

Commit 0b26030

Browse files
feat: add property filter to search API (#456)
* feat: add property filter to search API * chore: generate SDK
1 parent 4119aed commit 0b26030

File tree

5 files changed

+182
-3
lines changed

5 files changed

+182
-3
lines changed

backend/editor/models/search_models.py

+127-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import re
12
from abc import ABC, abstractmethod
23
from dataclasses import dataclass, field
34
from typing import Annotated, Literal
45

5-
from pydantic import Field, StringConstraints, TypeAdapter, computed_field
6+
from pydantic import Field, StringConstraints, TypeAdapter, computed_field, field_validator
67

78
from .base_models import BaseModel
89
from .node_models import EntryNode
@@ -115,6 +116,130 @@ def build_cypher_query(self, param_name: str) -> CypherQuery:
115116
)
116117

117118

119+
class PropertyFilterSearchTerm(AbstractFilterSearchTerm):
120+
filter_type: Literal["property"]
121+
filter_value: str
122+
123+
@field_validator("filter_value")
124+
@classmethod
125+
def validate_filter_value(cls, filter_value: str):
126+
"""
127+
The filter value is in the format `not:inherited:property_name:property_value`
128+
where `not:` and `inherited:` and `:property_value` are optional.
129+
Note that a property_name is always of format `name:lc`.
130+
"""
131+
parsed_value = filter_value
132+
if parsed_value.startswith("not:"):
133+
parsed_value = parsed_value[4:]
134+
if parsed_value.startswith("inherited:"):
135+
parsed_value = parsed_value[10:]
136+
137+
assert ":" in parsed_value, "A property_name is mandatory and must contain a colon"
138+
139+
terms = parsed_value.split(":")
140+
property_name = terms[0] + ":" + terms[1]
141+
142+
if not re.match(r"^[^:\\]+:[a-z]{2}$", property_name):
143+
raise ValueError("Invalid property_name")
144+
145+
return filter_value
146+
147+
@computed_field
148+
def negated(self) -> bool:
149+
return self.filter_value.startswith("not:")
150+
151+
@computed_field
152+
def inherited(self) -> bool:
153+
filter_value = self.get_parsed_filter_value(self.negated)
154+
return filter_value.startswith("inherited:")
155+
156+
@computed_field
157+
def property_name(self) -> str:
158+
filter_value = self.get_parsed_filter_value(self.negated, self.inherited)
159+
terms = filter_value.split(":")
160+
return terms[0] + "_" + terms[1]
161+
162+
@computed_field
163+
def property_value(self) -> str | None:
164+
filter_value = self.get_parsed_filter_value(self.negated, self.inherited)
165+
terms = filter_value.split(":")
166+
return ":".join(terms[2:]) if len(terms) > 2 else None
167+
168+
def get_parsed_filter_value(self, negated=False, inherited=False):
169+
filter_value = self.filter_value
170+
if negated:
171+
filter_value = filter_value[4:]
172+
if inherited:
173+
filter_value = filter_value[10:]
174+
return filter_value
175+
176+
def build_cypher_query(self, param_name: str) -> CypherQuery:
177+
branches = {
178+
"negated": self.negated,
179+
"inherited": self.inherited,
180+
"with_value": self.property_value is not None,
181+
}
182+
match branches:
183+
case {"negated": False, "inherited": False, "with_value": False}:
184+
return CypherQuery(f"n.prop_{self.property_name} IS NOT NULL")
185+
case {"negated": True, "inherited": False, "with_value": False}:
186+
return CypherQuery(f"n.prop_{self.property_name} IS NULL")
187+
case {"negated": False, "inherited": False, "with_value": True}:
188+
return CypherQuery(
189+
f"n.prop_{self.property_name} = ${param_name}",
190+
{param_name: self.property_value},
191+
)
192+
case {"negated": True, "inherited": False, "with_value": True}:
193+
return CypherQuery(
194+
f"n.prop_{self.property_name} <> ${param_name}",
195+
{param_name: self.property_value},
196+
)
197+
case {"negated": False, "inherited": True, "with_value": False}:
198+
return CypherQuery(
199+
f"""(n.prop_{self.property_name} IS NOT NULL OR
200+
any(
201+
ancestor IN [(n)<-[:is_child_of*]-(p:ENTRY) | p]
202+
WHERE ancestor.prop_{self.property_name} IS NOT NULL)
203+
)""",
204+
)
205+
case {"negated": True, "inherited": True, "with_value": False}:
206+
return CypherQuery(
207+
f"""(n.prop_{self.property_name} IS NULL AND
208+
all(
209+
ancestor IN [(n)<-[:is_child_of*]-(p:ENTRY) | p]
210+
WHERE ancestor.prop_{self.property_name} IS NULL)
211+
)""",
212+
)
213+
case {"negated": False, "inherited": True, "with_value": True}:
214+
return CypherQuery(
215+
f"""
216+
[
217+
property IN
218+
[n.prop_{self.property_name}] +
219+
[(n)<-[:is_child_of*]-(p:ENTRY) | p.prop_{self.property_name}]
220+
WHERE property IS NOT NULL
221+
][0]
222+
= ${param_name}""",
223+
{param_name: self.property_value},
224+
)
225+
case {"negated": True, "inherited": True, "with_value": True}:
226+
return CypherQuery(
227+
f"""((n.prop_{self.property_name} IS NULL AND
228+
all(
229+
ancestor IN [(n)<-[:is_child_of*]-(p:ENTRY) | p]
230+
WHERE ancestor.prop_{self.property_name} IS NULL)
231+
) OR
232+
[
233+
property IN
234+
[n.prop_{self.property_name}] +
235+
[(n)<-[:is_child_of*]-(p:ENTRY) | p.prop_{self.property_name}]
236+
WHERE property IS NOT NULL
237+
][0]
238+
<> ${param_name})""",
239+
{param_name: self.property_value},
240+
)
241+
242+
118243
FilterSearchTerm = Annotated[
119244
(
120245
IsFilterSearchTerm
@@ -123,6 +248,7 @@ def build_cypher_query(self, param_name: str) -> CypherQuery:
123248
| ChildFilterSearchTerm
124249
| AncestorFilterSearchTerm
125250
| DescendantFilterSearchTerm
251+
| PropertyFilterSearchTerm
126252
),
127253
Field(discriminator="filter_type"),
128254
]

backend/openapi/openapi.json

+40-2
Original file line numberDiff line numberDiff line change
@@ -1261,7 +1261,8 @@
12611261
{ "$ref": "#/components/schemas/ParentFilterSearchTerm" },
12621262
{ "$ref": "#/components/schemas/ChildFilterSearchTerm" },
12631263
{ "$ref": "#/components/schemas/AncestorFilterSearchTerm" },
1264-
{ "$ref": "#/components/schemas/DescendantFilterSearchTerm" }
1264+
{ "$ref": "#/components/schemas/DescendantFilterSearchTerm" },
1265+
{ "$ref": "#/components/schemas/PropertyFilterSearchTerm" }
12651266
],
12661267
"discriminator": {
12671268
"propertyName": "filterType",
@@ -1271,7 +1272,8 @@
12711272
"descendant": "#/components/schemas/DescendantFilterSearchTerm",
12721273
"is": "#/components/schemas/IsFilterSearchTerm",
12731274
"language": "#/components/schemas/LanguageFilterSearchTerm",
1274-
"parent": "#/components/schemas/ParentFilterSearchTerm"
1275+
"parent": "#/components/schemas/ParentFilterSearchTerm",
1276+
"property": "#/components/schemas/PropertyFilterSearchTerm"
12751277
}
12761278
}
12771279
},
@@ -1434,6 +1436,42 @@
14341436
"enum": ["OPEN", "EXPORTED", "LOADING", "FAILED"],
14351437
"title": "ProjectStatus"
14361438
},
1439+
"PropertyFilterSearchTerm": {
1440+
"properties": {
1441+
"filterType": { "const": "property", "title": "Filtertype" },
1442+
"filterValue": { "type": "string", "title": "Filtervalue" },
1443+
"negated": {
1444+
"type": "boolean",
1445+
"title": "Negated",
1446+
"readOnly": true
1447+
},
1448+
"inherited": {
1449+
"type": "boolean",
1450+
"title": "Inherited",
1451+
"readOnly": true
1452+
},
1453+
"propertyName": {
1454+
"type": "string",
1455+
"title": "Propertyname",
1456+
"readOnly": true
1457+
},
1458+
"propertyValue": {
1459+
"anyOf": [{ "type": "string" }, { "type": "null" }],
1460+
"title": "Propertyvalue",
1461+
"readOnly": true
1462+
}
1463+
},
1464+
"type": "object",
1465+
"required": [
1466+
"filterType",
1467+
"filterValue",
1468+
"negated",
1469+
"inherited",
1470+
"propertyName",
1471+
"propertyValue"
1472+
],
1473+
"title": "PropertyFilterSearchTerm"
1474+
},
14371475
"ValidationError": {
14381476
"properties": {
14391477
"loc": {

taxonomy-editor-frontend/src/client/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type { LanguageFilterSearchTerm } from "./models/LanguageFilterSearchTerm
2323
export type { ParentFilterSearchTerm } from "./models/ParentFilterSearchTerm";
2424
export type { Project } from "./models/Project";
2525
export { ProjectStatus } from "./models/ProjectStatus";
26+
export type { PropertyFilterSearchTerm } from "./models/PropertyFilterSearchTerm";
2627
export type { ValidationError } from "./models/ValidationError";
2728

2829
export { DefaultService } from "./services/DefaultService";

taxonomy-editor-frontend/src/client/models/EntryNodeSearchResult.ts

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { EntryNode } from "./EntryNode";
99
import type { IsFilterSearchTerm } from "./IsFilterSearchTerm";
1010
import type { LanguageFilterSearchTerm } from "./LanguageFilterSearchTerm";
1111
import type { ParentFilterSearchTerm } from "./ParentFilterSearchTerm";
12+
import type { PropertyFilterSearchTerm } from "./PropertyFilterSearchTerm";
1213
export type EntryNodeSearchResult = {
1314
q: string;
1415
nodeCount: number;
@@ -20,6 +21,7 @@ export type EntryNodeSearchResult = {
2021
| ChildFilterSearchTerm
2122
| AncestorFilterSearchTerm
2223
| DescendantFilterSearchTerm
24+
| PropertyFilterSearchTerm
2325
>;
2426
nodes: Array<EntryNode>;
2527
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/* generated using openapi-typescript-codegen -- do no edit */
2+
/* istanbul ignore file */
3+
/* tslint:disable */
4+
/* eslint-disable */
5+
export type PropertyFilterSearchTerm = {
6+
filterType: "property";
7+
filterValue: string;
8+
readonly negated: boolean;
9+
readonly inherited: boolean;
10+
readonly propertyName: string;
11+
readonly propertyValue: string | null;
12+
};

0 commit comments

Comments
 (0)