Skip to content

Commit fa45ab7

Browse files
authored
fix: collection is not always searchable (#304)
1 parent 9f8392f commit fa45ab7

File tree

4 files changed

+111
-21
lines changed

4 files changed

+111
-21
lines changed

.github/workflows/generic.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ jobs:
3333
- uses: actions/checkout@v4
3434
- id: changes
3535
uses: ./.github/actions/changes
36-
3736
lint:
3837
name: Linting
3938
needs: [ changes ]

src/datasource_toolkit/forestadmin/datasource_toolkit/datasource_customizer/collection_customizer.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,22 @@ async def _replace_search():
573573
self.stack.queue_customization(_replace_search)
574574
return self
575575

576+
def disable_search(self) -> Self:
577+
"""Disable the search bar
578+
579+
Documentation:
580+
https://docs.forestadmin.com/developer-guide-agents-python/agent-customization/search
581+
582+
Example:
583+
.disable_search()
584+
"""
585+
586+
async def _disable_search():
587+
cast(SearchCollectionDecorator, self.stack.search.get_collection(self.collection_name)).disable_search()
588+
589+
self.stack.queue_customization(_disable_search)
590+
return self
591+
576592
def add_chart(self, name: str, definition: CollectionChartDefinition) -> Self:
577593
"""Create a new API chart
578594

src/datasource_toolkit/forestadmin/datasource_toolkit/decorators/search/collections.py

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,22 @@ class SearchCollectionDecorator(CollectionDecorator):
3232
def __init__(self, collection: Collection, datasource: Datasource[BoundCollection]):
3333
super().__init__(collection, datasource)
3434
self._replacer: SearchDefinition = None
35+
self._searchable = len(self._get_searchable_fields(self.child_collection, False)) > 0
36+
37+
def disable_search(self):
38+
self._searchable = False
39+
self.mark_schema_as_dirty()
3540

3641
def replace_search(self, replacer: SearchDefinition):
3742
self._replacer = replacer
43+
self._searchable = True
44+
self.mark_schema_as_dirty()
3845

3946
def _refine_schema(self, sub_schema: CollectionSchema) -> CollectionSchema:
40-
return {**sub_schema, "searchable": True}
47+
return {
48+
**sub_schema,
49+
"searchable": self._searchable,
50+
}
4151

4252
def _default_replacer(self, search: str, extended: bool) -> ConditionTree:
4353
searchable_fields = self._get_searchable_fields(self.child_collection, extended)
@@ -77,18 +87,14 @@ async def _refine_filter(
7787
return _filter
7888

7989
def _build_condition(self, field: str, schema: Column, search: str) -> Union[ConditionTree, None]:
80-
if (
81-
schema["column_type"] == PrimitiveType.NUMBER
82-
and search.isnumeric()
83-
and Operator.EQUAL in schema.get("filter_operators", [])
84-
):
90+
if schema["column_type"] == PrimitiveType.NUMBER and search.isnumeric():
8591
try:
8692
value = int(search)
8793
except ValueError:
8894
value = float(search)
8995
return ConditionTreeLeaf(field, Operator.EQUAL, value)
9096

91-
if schema["column_type"] == PrimitiveType.ENUM and Operator.EQUAL in schema.get("filter_operators", []):
97+
if schema["column_type"] == PrimitiveType.ENUM:
9298
search_value = self.lenient_find(schema["enum_values"], search)
9399
if search_value is not None:
94100
return ConditionTreeLeaf(field, Operator.EQUAL, search_value)
@@ -103,17 +109,11 @@ def _build_condition(self, field: str, schema: Column, search: str) -> Union[Con
103109
operator = Operator.CONTAINS
104110
elif support_equal:
105111
operator = Operator.EQUAL
106-
else:
107-
operator = None
108112

109113
if operator:
110114
return ConditionTreeLeaf(field, operator, search)
111115

112-
if (
113-
schema["column_type"] == PrimitiveType.UUID
114-
and is_valid_uuid(search)
115-
and Operator.EQUAL in schema.get("filter_operators", [])
116-
):
116+
if schema["column_type"] == PrimitiveType.UUID and is_valid_uuid(search):
117117
return ConditionTreeLeaf(field, Operator.EQUAL, search)
118118

119119
def lenient_find(self, haystack: List[str], needle: str) -> Union[str, None]:
@@ -126,14 +126,14 @@ def _get_searchable_fields(self, collection: Collection, extended: bool) -> List
126126
fields: List[Tuple[str, ColumnAlias]] = []
127127

128128
for name, field in collection.schema["fields"].items():
129-
if is_column(field):
129+
if is_column(field) and self._is_searchable_field(field):
130130
fields.append((name, field))
131131

132132
if extended and (is_many_to_one(field) or is_one_to_one(field) or is_polymorphic_one_to_one(field)):
133133
related = collection.datasource.get_collection(field["foreign_collection"])
134134

135135
for sub_name, sub_field in related.schema["fields"].items():
136-
if is_column(sub_field):
136+
if is_column(sub_field) and self._is_searchable_field(sub_field):
137137
fields.append((f"{name}:{sub_name}", sub_field))
138138

139139
if extended and is_polymorphic_many_to_one(field):
@@ -145,3 +145,18 @@ def _get_searchable_fields(self, collection: Collection, extended: bool) -> List
145145
)
146146

147147
return fields
148+
149+
def _is_searchable_field(self, field: Column) -> bool:
150+
operators = field.get("filter_operators", [])
151+
152+
if field["column_type"] == PrimitiveType.STRING and (
153+
Operator.CONTAINS in operators or Operator.EQUAL in operators
154+
):
155+
return True
156+
157+
if field["column_type"] in [PrimitiveType.NUMBER, PrimitiveType.UUID, PrimitiveType.ENUM] and (
158+
Operator.EQUAL in operators
159+
):
160+
return True
161+
162+
return False

src/datasource_toolkit/tests/decorators/search/test_search_collection_decorator.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,39 @@ def setUpClass(cls) -> None:
5252
def setUp(self) -> None:
5353
self.datasource: Datasource = Datasource()
5454
self.collection_person = Collection("Person", self.datasource)
55+
self.collection_person.add_fields(
56+
{
57+
"id": {
58+
"is_primary_key": True,
59+
"type": FieldType.COLUMN,
60+
"column_type": PrimitiveType.NUMBER,
61+
"filter_operators": set([Operator.EQUAL, Operator.IN]),
62+
}
63+
}
64+
)
65+
66+
self.no_searchable_fields_collection = Collection("NotSearchable", self.datasource)
67+
self.no_searchable_fields_collection.add_fields(
68+
{
69+
"id": {
70+
"is_primary_key": True,
71+
"type": FieldType.COLUMN,
72+
"column_type": PrimitiveType.NUMBER,
73+
"filter_operators": set(),
74+
}
75+
}
76+
)
5577
self.datasource.add_collection(self.collection_person)
78+
self.datasource.add_collection(self.no_searchable_fields_collection)
5679

5780
self.datasource_decorator = DatasourceDecorator(self.datasource, SearchCollectionDecorator)
58-
self.decorated_collection_person = self.datasource_decorator.get_collection("Person")
81+
self.decorated_collection_person: SearchCollectionDecorator = self.datasource_decorator.get_collection(
82+
"Person"
83+
) # type:ignore
84+
85+
self.decorated_not_searchable_collection: SearchCollectionDecorator = self.datasource_decorator.get_collection(
86+
"NotSearchable"
87+
) # type:ignore
5988

6089
def test_replace_search_should_work(self):
6190
def replacer(search: Any, search_extended: bool, context: CollectionCustomizationContext):
@@ -64,9 +93,28 @@ def replacer(search: Any, search_extended: bool, context: CollectionCustomizatio
6493
self.decorated_collection_person.replace_search(replacer)
6594
assert self.decorated_collection_person._replacer == replacer
6695

67-
def test_schema_is_searchable_should_be_true(self):
96+
def test_schema_is_searchable_should_be_true_by_default_when_fields_can_be_searched(self):
6897
assert self.decorated_collection_person.schema["searchable"] is True
6998

99+
def test_schema_is_searchable_should_be_false_when_no_fields_can_be_searched(self):
100+
assert self.decorated_not_searchable_collection.schema["searchable"] is False
101+
102+
def test_schema_conflict_on_replace_and_disable_apply_the_latest_one(self):
103+
self.decorated_collection_person.mark_schema_as_dirty()
104+
assert self.decorated_collection_person.schema["searchable"] is True
105+
106+
self.decorated_collection_person.disable_search()
107+
self.decorated_collection_person.mark_schema_as_dirty()
108+
assert self.decorated_collection_person.schema["searchable"] is False
109+
110+
self.decorated_collection_person.replace_search(None)
111+
self.decorated_collection_person.mark_schema_as_dirty()
112+
assert self.decorated_collection_person.schema["searchable"] is True
113+
114+
def test_schema_is_searchable_should_be_false_when_disabling_search(self):
115+
self.decorated_collection_person.disable_search()
116+
assert self.decorated_collection_person.schema["searchable"] is False
117+
70118
def test_refine_filter_should_return_the_given_filter_for_empty_filter(self):
71119
filter_ = Filter({"search": None})
72120

@@ -169,6 +217,7 @@ def test_search_must_be_applied_on_all_fields(self):
169217
"condition_tree": ConditionTreeBranch(
170218
Aggregator.OR,
171219
conditions=[
220+
ConditionTreeLeaf("id", Operator.EQUAL, 1584),
172221
ConditionTreeLeaf("number", Operator.EQUAL, 1584),
173222
ConditionTreeLeaf("label", Operator.CONTAINS, "1584"),
174223
],
@@ -206,11 +255,11 @@ def test_for_enum_value(self):
206255
def test_search_number_in_all_field(self):
207256
self.collection_person.add_field(
208257
"field1",
209-
Column(column_type=PrimitiveType.NUMBER, filter_operators=[Operator.EQUAL], type=FieldType.COLUMN),
258+
Column(column_type=PrimitiveType.NUMBER, filter_operators=set([Operator.EQUAL]), type=FieldType.COLUMN),
210259
)
211260
self.collection_person.add_field(
212261
"field2",
213-
Column(column_type=PrimitiveType.NUMBER, filter_operators=[Operator.EQUAL], type=FieldType.COLUMN),
262+
Column(column_type=PrimitiveType.NUMBER, filter_operators=set([Operator.EQUAL]), type=FieldType.COLUMN),
214263
)
215264

216265
self.collection_person.add_field(
@@ -230,6 +279,7 @@ def test_search_number_in_all_field(self):
230279
"condition_tree": ConditionTreeBranch(
231280
Aggregator.OR,
232281
conditions=[
282+
ConditionTreeLeaf("id", Operator.EQUAL, 1584),
233283
ConditionTreeLeaf("field1", Operator.EQUAL, 1584),
234284
ConditionTreeLeaf("field2", Operator.EQUAL, 1584),
235285
],
@@ -572,3 +622,13 @@ async def replacer_fn(value, extended, context):
572622
filter_ = Filter({"search": "something", "search_extended": True})
573623
self.loop.run_until_complete(self.decorated_collection_person._refine_filter(self.mocked_caller, filter_))
574624
spy_replacer_fn.assert_awaited_with("something", True, ANY)
625+
626+
def test_disable_search_should_mark_schema_as_dirty(self):
627+
with patch.object(self.decorated_collection_person, "mark_schema_as_dirty") as mark_schema_as_dirty:
628+
self.decorated_collection_person.disable_search()
629+
mark_schema_as_dirty.assert_called_once()
630+
631+
def test_replace_search_should_mark_schema_as_dirty(self):
632+
with patch.object(self.decorated_collection_person, "mark_schema_as_dirty") as mark_schema_as_dirty:
633+
self.decorated_collection_person.replace_search(None)
634+
mark_schema_as_dirty.assert_called_once()

0 commit comments

Comments
 (0)