diff --git a/specifyweb/backend/stored_queries/batch_edit.py b/specifyweb/backend/stored_queries/batch_edit.py index a952cbd7dd7..56f3fedfaed 100644 --- a/specifyweb/backend/stored_queries/batch_edit.py +++ b/specifyweb/backend/stored_queries/batch_edit.py @@ -1014,6 +1014,7 @@ def run_batch_edit_query(props: BatchEditProps): tableid=tableid, distinct=True, series=False, + search_synonymy=False, count_only=False, field_specs=query_with_hidden, limit=limit, diff --git a/specifyweb/backend/stored_queries/execution.py b/specifyweb/backend/stored_queries/execution.py index 5dbb4228052..e20f4f8b61d 100644 --- a/specifyweb/backend/stored_queries/execution.py +++ b/specifyweb/backend/stored_queries/execution.py @@ -34,6 +34,8 @@ from specifyweb.backend.workbench.upload.auditlog import auditlog from specifyweb.backend.stored_queries.group_concat import group_by_displayed_fields from specifyweb.backend.stored_queries.queryfield import fields_from_json, QUREYFIELD_SORT_T +from specifyweb.backend.stored_queries.synonomy import synonymize_tree_query +from specifyweb.specify.datamodel import datamodel, is_tree_table logger = logging.getLogger(__name__) @@ -66,6 +68,7 @@ class BuildQueryProps(NamedTuple): formatauditobjs: bool = False distinct: bool = False series: bool = False + search_synonymy: bool = False implicit_or: bool = True formatter_props: ObjectFormatterProps = DefaultQueryFormatterProps() @@ -77,6 +80,19 @@ def set_group_concat_max_len(connection): """ connection.execute("SET group_concat_max_len = 1024 * 1024 * 1024") +def _pick_synonymy_table(field_specs, base_table): + if base_table is not None and is_tree_table(base_table): + return base_table + + for field_spec in field_specs: + if field_spec.fieldspec.contains_tree_rank(): + return field_spec.fieldspec.table + + for field_spec in field_specs: + if is_tree_table(field_spec.fieldspec.table): + return field_spec.fieldspec.table + + return None def filter_by_collection(model, query, collection): """Add predicates to the given query to filter result to items scoped @@ -585,6 +601,7 @@ def run_ephemeral_query(collection, user, spquery): recordsetid = spquery.get("recordsetid", None) distinct = spquery["selectdistinct"] series = spquery.get('smushed', None) + search_synonymy = bool(spquery.get("searchsynonymy", False)) tableid = spquery["contexttableid"] count_only = spquery["countonly"] format_audits = spquery.get("formatauditrecids", False) @@ -598,6 +615,7 @@ def run_ephemeral_query(collection, user, spquery): tableid=tableid, distinct=distinct, series=series, + search_synonymy=search_synonymy, count_only=count_only, field_specs=field_specs, limit=limit, @@ -786,6 +804,7 @@ def execute( tableid, distinct, series, + search_synonymy, count_only, field_specs, limit, @@ -811,6 +830,7 @@ def execute( formatauditobjs=formatauditobjs, distinct=distinct, series=series, + search_synonymy=search_synonymy, formatter_props=formatter_props, ), ) @@ -897,8 +917,11 @@ def build_query( series = (only for CO) if True, group by all display fields. Group catalog numbers that fall within the same range together. Return all record IDs associated with a row. + + search_synonymy = if True, search synonym nodes as well, and return all record IDs associated with parent node """ model = models.models_by_tableid[tableid] + base_table = datamodel.get_table_by_id(tableid, strict=True) id_field = model._id catalog_number_field = model.catalogNumber if hasattr(model, 'catalogNumber') else None @@ -941,8 +964,8 @@ def build_query( ) } - for table in tables_to_read: - check_table_permissions(collection, user, table, "read") + for table_to_read in tables_to_read: + check_table_permissions(collection, user, table_to_read, "read") query = filter_by_collection(model, query, collection) @@ -1014,6 +1037,15 @@ def build_query( query = group_by_displayed_fields(query, selected_fields, ignore_cat_num=True) elif props.distinct: query = group_by_displayed_fields(query, selected_fields) + + if props.search_synonymy: + synonymy_table = _pick_synonymy_table(field_specs, base_table) + if synonymy_table is None: + logger.info("search_synonymy requested but no tree table found... skipping") + else: + log_sqlalchemy_query(query.query) + synonymized_query = synonymize_tree_query(query.query, synonymy_table) + query = query._replace(query=synonymized_query) internal_predicate = query.get_internal_filters() query = query.filter(internal_predicate) diff --git a/specifyweb/backend/stored_queries/query_construct.py b/specifyweb/backend/stored_queries/query_construct.py index 230e7b788a4..328f18055a6 100644 --- a/specifyweb/backend/stored_queries/query_construct.py +++ b/specifyweb/backend/stored_queries/query_construct.py @@ -158,6 +158,12 @@ def build_join(self, table, model, join_path): # To make things "simpler", it doesn't apply any filters, but returns a single predicate # @model is an input parameter, because cannot guess if it is aliased or not (callers are supposed to know that) def get_internal_filters(self): + # If nothing to filter on, return TRUE so .where(...) can run safely + if not self.internal_filters: + return sql.true() + # Avoid OR on a single element + if len(self.internal_filters) == 1: + return self.internal_filters[0] return sql.or_(*self.internal_filters) def add_proxy_method(name): diff --git a/specifyweb/backend/stored_queries/queryfieldspec.py b/specifyweb/backend/stored_queries/queryfieldspec.py index 104af499359..52490aa3fdf 100644 --- a/specifyweb/backend/stored_queries/queryfieldspec.py +++ b/specifyweb/backend/stored_queries/queryfieldspec.py @@ -236,7 +236,10 @@ def from_stringid(cls, stringid: str, is_relation: bool): root_table = datamodel.get_table_by_id(int(path.popleft())) if is_relation: - path.pop() + extracted_fieldname, _ = extract_date_part(field_name) + root_field = root_table.get_field(extracted_fieldname, strict=False) + if isinstance(root_field, Relationship): + path.pop() join_path = [] node = root_table diff --git a/specifyweb/backend/stored_queries/synonomy.py b/specifyweb/backend/stored_queries/synonomy.py new file mode 100644 index 00000000000..7beca08854d --- /dev/null +++ b/specifyweb/backend/stored_queries/synonomy.py @@ -0,0 +1,236 @@ +from typing import Optional, Tuple, List +from sqlalchemy import select, union, func +from sqlalchemy.sql import Select +from sqlalchemy.orm import Query +from sqlalchemy.sql.selectable import FromClause, Alias, Join +from sqlalchemy.sql.schema import Table +from specifyweb.specify.models_utils.load_datamodel import Table as SpecifyTable + +def synonymize_tree_query( + query: Query, + table: "SpecifyTable", + expand_from_accepted: bool = True, +) -> Query: + """ + Expand a tree query (Taxon, Storage, Geography, TectonicUnit, Chronostratigraphy, + Lithostratigraphy) to include synonymy-related records. + + expand_from_accepted = True (default) + - Start from the records that match the original predicate. + - Resolve each match to its accepted root via COALESCE(AcceptedID, id_col). + - Then include all synonyms whose AcceptedID points to those accepted roots. + + Query Building Strategy: + target_taxon := (original FROM+JOINS + WHERE) projected as (id_col, AcceptedID) + accepted_roots := SELECT COALESCE(AcceptedID, id_col) FROM target_taxon + syn_ids := SELECT id_col FROM tree WHERE AcceptedID IN (accepted_roots) + ids := accepted_roots UNION syn_ids + + expand_from_accepted = False + - Include records whose synonymized children match the original predicate. + + Query Building Strategy: + target_taxon := (original FROM+JOINS + WHERE) projected as (id_col, AcceptedID) + ids := SELECT id_col UNION SELECT AcceptedID + FROM target_taxon (AcceptedID NOT NULL) + + In both cases: + final := (original SELECT list) + (original FROM/JOINS but no WHERE) + + WHERE tree.id_col IN (ids) + """ + base_sel: Select = query.statement if isinstance(query, Query) else query + + tree_table_name = table.table + id_col_name = table.idColumn + + # Find the tree base table and the specific FROM/alias used in the original query + taxon_table, taxon_from = _find_tree_table_and_from(base_sel, tree_table_name) + if taxon_table is None or taxon_from is None: + raise ValueError( + f"synonymize_tree_query: couldn't locate '{tree_table_name}' in the query FROMs." + ) + + # Build `target_taxon` CTE based on the given query + target_taxon_cte = _build_target_tree_cte( + base_sel, + taxon_from, + id_col_name=id_col_name, + cte_name="target_taxon", + ) + + if expand_from_accepted: + # Resolve each matched row to its accepted root so both directions work + accepted_roots = select( + func.coalesce( + target_taxon_cte.c.AcceptedID, + target_taxon_cte.c.TaxonID, + ).label("id") + ).subquery("accepted_roots") + + # Any row whose AcceptedID points at one of those accepted roots + # Use the base tree table, not the query alias, so we don't carry original WHERE + syn_ids = select(taxon_table.c[id_col_name].label("id")).where( + taxon_table.c.AcceptedID.in_(select(accepted_roots.c.id)) + ) + + ids = union(select(accepted_roots.c.id), syn_ids).subquery("ids") + + else: + # Subquery to get the relevant ids for synonymy: id_col and AcceptedID + ids = union( + select(target_taxon_cte.c.TaxonID.label("id")), + select(target_taxon_cte.c.AcceptedID.label("id")).where( + target_taxon_cte.c.AcceptedID.isnot(None) + ), + ).subquery("ids") + + # Rebuild a fresh chainable ORM Query using these ids + return _rebuild_query_with_ids( + query=query, + base_sel=base_sel, + taxon_from=taxon_from, + ids_subquery=ids, + id_col_name=id_col_name, + ) + +def _build_target_tree_cte( + base_sel: Select, + taxon_from: FromClause, + id_col_name: str, + cte_name: str = "target_taxon", +): + """ + Given the original Select and the tree FromClause/alias used in it, + build a CTE that projects (id_col, AcceptedID) with all original + FROM / WHERE / GROUP BY / HAVING preserved. + + The ID column is always labeled as "TaxonID" for downstream reuse, + even when the underlying table is not taxon. + """ + target_taxon = select( + taxon_from.c[id_col_name].label("TaxonID"), + taxon_from.c.AcceptedID.label("AcceptedID"), + ) + + # Re-attach the original FROM roots + for f in base_sel.get_final_froms(): + target_taxon = target_taxon.select_from(f) + + # Re-apply WHERE, GROUP BY, HAVING (but not ORDER BY) + for wc in getattr(base_sel, "_where_criteria", ()) or (): + target_taxon = target_taxon.where(wc) + for gb in getattr(base_sel, "_group_by_clauses", ()) or (): + target_taxon = target_taxon.group_by(gb) + if getattr(base_sel, "_having", None) is not None: + target_taxon = target_taxon.having(base_sel._having) + + return target_taxon.cte(cte_name) + +def _rebuild_query_with_ids( + query: Query, + base_sel: Select, + taxon_from: FromClause, + ids_subquery: FromClause, + id_col_name: str, +) -> Query: + """ + Take the original ORM Query + its underlying Select and rebuild a new, + chainable ORM Query: + - Same selected columns + - Same FROM (joins included) + - Same GROUP BY / HAVING / ORDER BY + - No original WHERE + - Adds WHERE tree.id_col_name IN (SELECT id FROM ids_subquery) + """ + sess = query.session + original_cols: List = list(base_sel.selected_columns) + + new_query = sess.query(*original_cols) + + # Attach the same FROM base tables as the original query; + # these already carry the join conditions. + for f in base_sel.get_final_froms(): + new_query = new_query.select_from(f) + + # Preserve GROUP BY / HAVING / ORDER BY from the original select, but not WHERE + for gb in getattr(base_sel, "_group_by_clauses", ()) or (): + new_query = new_query.group_by(gb) + if getattr(base_sel, "_having", None) is not None: + new_query = new_query.having(base_sel._having) + if getattr(base_sel, "_order_by_clauses", None): + new_query = new_query.order_by(*base_sel._order_by_clauses) + + # Apply the expansion condition on the appropriate ID column + new_query = new_query.filter( + taxon_from.c[id_col_name].in_(select(ids_subquery.c.id)) + ) + return new_query + +def _find_tree_table_and_from( + sel: Select, + tree_table_name: str = "taxon", +) -> Tuple[Optional[Table], Optional[FromClause]]: + """ + Find the underlying Table for the given tree table name + and the specific FromClause (table OR alias) used in `sel` for that table. + + Works with: Table, Alias(Table), Join trees, Alias(Join(...)). + """ + + target_name = tree_table_name.lower() + + def is_taxon_table(t: Table) -> bool: + # Compare case-insensitively and handle schema-qualified names if any + try: + return t.name is not None and t.name.lower() == target_name + except Exception: + return False + + def walk(fc: FromClause) -> Tuple[Optional[Table], Optional[FromClause]]: + # Plain Table + if isinstance(fc, Table) and is_taxon_table(fc): + return fc, fc + + # Alias of something + el = getattr(fc, "element", None) + if isinstance(fc, Alias) and el is not None: + # Alias(Table) + if isinstance(el, Table) and is_taxon_table(el): + return el, fc + # Alias(Join/Selectable): recurse into the element + if isinstance(el, Join): + t, frm = walk(el) + if t is not None: + return t, frm + + # Join: recurse both sides + if isinstance(fc, Join): + t, frm = walk(fc.left) + if t is not None: + return t, frm + t, frm = walk(fc.right) + if t is not None: + return t, frm + + # Unknown / composite + return None, None + + # Try walking all final FROM roots + for f in sel.get_final_froms(): + t, frm = walk(f) + if t is not None and frm is not None: + return t, frm + + # Fallback to scanning selected columns to deduce the alias + try: + for col in sel.selected_columns: + tbl = getattr(col, "table", None) + el = getattr(tbl, "element", None) + if isinstance(tbl, Table) and is_taxon_table(tbl): + return tbl, tbl + if isinstance(el, Table) and is_taxon_table(el): + return el, tbl # tbl is the alias here + except Exception: + pass + + return None, None diff --git a/specifyweb/backend/stored_queries/tests/static/co_query_row_plan.py b/specifyweb/backend/stored_queries/tests/static/co_query_row_plan.py index d00943eb4a6..09008570f2f 100644 --- a/specifyweb/backend/stored_queries/tests/static/co_query_row_plan.py +++ b/specifyweb/backend/stored_queries/tests/static/co_query_row_plan.py @@ -41,11 +41,11 @@ ), "collectingevent": RowPlanMap( batch_edit_pack=BatchEditPack( - id=BatchEditFieldPack(field=None, idx=19, value=None), + id=BatchEditFieldPack(field=None, idx=18, value=None), order=BatchEditFieldPack(field=None, idx=None, value=None), - version=BatchEditFieldPack(field=None, idx=20, value=None), + version=BatchEditFieldPack(field=None, idx=19, value=None), ), - columns=[BatchEditFieldPack(field=None, idx=18, value=None)], + columns=[], to_one={ "locality": RowPlanMap( batch_edit_pack=BatchEditPack( @@ -54,6 +54,7 @@ version=BatchEditFieldPack(field=None, idx=31, value=None), ), columns=[ + BatchEditFieldPack(field=None, idx=20, value=None), BatchEditFieldPack(field=None, idx=21, value=None), BatchEditFieldPack(field=None, idx=22, value=None), BatchEditFieldPack(field=None, idx=23, value=None), diff --git a/specifyweb/backend/stored_queries/tests/static/simple_static_fields.py b/specifyweb/backend/stored_queries/tests/static/simple_static_fields.py index f1349c4eca8..a84b3664c07 100644 --- a/specifyweb/backend/stored_queries/tests/static/simple_static_fields.py +++ b/specifyweb/backend/stored_queries/tests/static/simple_static_fields.py @@ -415,13 +415,19 @@ def get_sql_table(name): datamodel.get_table_strict("CollectingEvent").get_field_strict( "locality" ), + TreeRankQuery( + **{ + "name": "locality", + "relatedModelName": "Locality", + "type": "many-to-one", + "column": "localityId", + } + ), ), - "table": datamodel.get_table_strict("CollectingEvent"), + "table": datamodel.get_table_strict("Locality"), "date_part": None, - "tree_rank": None, - "tree_field": datamodel.get_table_strict( - "CollectingEvent" - ).get_field_strict("locality"), + "tree_rank": "locality", + "tree_field": None, } ), op_num=8, diff --git a/specifyweb/backend/stored_queries/tests/test_execution/test_execute.py b/specifyweb/backend/stored_queries/tests/test_execution/test_execute.py index 6c3806f4588..ad6ef34cf99 100644 --- a/specifyweb/backend/stored_queries/tests/test_execution/test_execute.py +++ b/specifyweb/backend/stored_queries/tests/test_execution/test_execute.py @@ -42,6 +42,7 @@ def test_simple_query(self): table.tableId, distinct=False, series=False, + search_synonymy=False, count_only=False, field_specs=query_fields, limit=0, @@ -74,6 +75,7 @@ def test_simple_query_count(self): table.tableId, distinct=False, series=False, + search_synonymy=False, count_only=True, field_specs=query_fields, limit=0, @@ -95,6 +97,7 @@ def test_simple_query_distinct(self): table.tableId, distinct=True, series=False, + search_synonymy=False, count_only=False, field_specs=query_fields, limit=0, @@ -130,6 +133,7 @@ def test_simple_query_distinct_count(self): table.tableId, distinct=True, series=False, + search_synonymy=False, count_only=True, field_specs=query_fields, limit=0, @@ -164,6 +168,7 @@ def test_simple_query_recordset_limit(self): self.specifyuser, table.tableId, series=False, + search_synonymy=False, count_only=False, field_specs=query_fields, limit=3, @@ -179,6 +184,7 @@ def test_simple_query_recordset_limit(self): table.tableId, distinct=False, series=False, + search_synonymy=False, count_only=True, field_specs=query_fields, limit=3, @@ -212,6 +218,7 @@ def test_simple_query_series(self): table.tableId, distinct=False, series=True, + search_synonymy=False, count_only=False, field_specs=query_fields, limit=0, @@ -225,6 +232,7 @@ def test_simple_query_series(self): table.tableId, distinct=False, series=True, + search_synonymy=False, count_only=True, field_specs=query_fields, limit=0, diff --git a/specifyweb/backend/stored_queries/tests/test_views/raw_query.py b/specifyweb/backend/stored_queries/tests/test_views/raw_query.py index ac643d0554f..a92e35a0623 100644 --- a/specifyweb/backend/stored_queries/tests/test_views/raw_query.py +++ b/specifyweb/backend/stored_queries/tests/test_views/raw_query.py @@ -4,6 +4,7 @@ "contexttableid": 1, "selectdistinct": False, "smushed": False, + "searchsynonymy": False, "countonly": False, "formatauditrecids": False, "specifyuser": f"/api/specify/specifyuser/{specifyuser.id}/", diff --git a/specifyweb/backend/stored_queries/views.py b/specifyweb/backend/stored_queries/views.py index 8ead7e580cf..7efb9c596c3 100644 --- a/specifyweb/backend/stored_queries/views.py +++ b/specifyweb/backend/stored_queries/views.py @@ -86,6 +86,7 @@ def query(request, id): sp_query = session.query(models.SpQuery).get(int(id)) distinct = sp_query.selectDistinct series = sp_query.smushed + search_synonymy = sp_query.searchSynonymy tableid = sp_query.contextTableId count_only = sp_query.countOnly @@ -99,6 +100,7 @@ def query(request, id): tableid=tableid, distinct=distinct, series=series, + search_synonymy=search_synonymy, count_only=count_only, field_specs=field_specs, limit=limit, diff --git a/specifyweb/frontend/js_src/lib/components/Forms/__tests__/DeleteButton.test.tsx b/specifyweb/frontend/js_src/lib/components/Forms/__tests__/DeleteButton.test.tsx index 56b3870334b..eda37d3d8cc 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/__tests__/DeleteButton.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/__tests__/DeleteButton.test.tsx @@ -103,7 +103,7 @@ overrideAjax( ordinal: 32_767, remarks: null, resource_uri: undefined, - searchsynonymy: null, + searchsynonymy: false, selectdistinct: false, smushed: false, specifyuser: '/api/specify/specifyuser/2/', diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/stats.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/stats.ts index 2e487b2128f..d3fe0a77da7 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/stats.ts +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/stats.ts @@ -23,11 +23,17 @@ function buildStatsLambdaUrl(base: string | null | undefined): string | null { return u; } -function buildStats2RequestKey(lambdaUrl: string, collectionGuid: string): string { +function buildStats2RequestKey( + lambdaUrl: string, + collectionGuid: string +): string { return `${stats2RequestKeyPrefix}:${collectionGuid}:${lambdaUrl}`; } -function shouldSendStats2Request(storageKey: string, now = Date.now()): boolean { +function shouldSendStats2Request( + storageKey: string, + now = Date.now() +): boolean { if (globalThis.localStorage === undefined) return true; try { diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx index d46aa5b10ce..e9124f832c4 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx @@ -149,9 +149,9 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { const loadedResults = ( undefinedResult === -1 ? results : results?.slice(0, undefinedResult) ) as RA | undefined; - + /* eslint-disable functional/prefer-readonly-type */ - const deletingRef = React.useRef>(new Set()); // Track recent deleted IDs to prevent duplicate deletion + const deletingRef = React.useRef>(new Set()); // Track recent deleted IDs to prevent duplicate deletion // TEST: try deleting while records are being fetched /** @@ -161,7 +161,7 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { (recordId: number): void => { if (deletingRef.current.has(recordId)) return; // Prevents duplicate deletion calls for the same record deletingRef.current.add(recordId); - + let removeCount = 0; function newResults(results: RA | undefined) { if (!Array.isArray(results) || totalCount === undefined) return; @@ -179,7 +179,9 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { return; } setTotalCount((totalCount) => - totalCount === undefined ? undefined : Math.max(0, totalCount - removeCount) + totalCount === undefined + ? undefined + : Math.max(0, totalCount - removeCount) ); const newSelectedRows = (selectedRows: ReadonlySet) => new Set(Array.from(selectedRows).filter((id) => id !== recordId)); diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx index 7a609c0c19e..7af71e77d03 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx @@ -15,9 +15,11 @@ export function QueryToolbar({ isDistinct, isSeries, showSeries, + searchSynonymy, onToggleHidden: handleToggleHidden, onToggleDistinct: handleToggleDistinct, onToggleSeries: handleToggleSeries, + onToggleSearchSynonymy: handleToggleSearchSynonymy, onRunCountOnly: handleRunCountOnly, onSubmitClick: handleSubmitClick, }: { @@ -26,9 +28,11 @@ export function QueryToolbar({ readonly isDistinct: boolean; readonly isSeries: boolean; readonly showSeries: boolean; + readonly searchSynonymy: boolean; readonly onToggleHidden: (value: boolean) => void; readonly onToggleDistinct: () => void; readonly onToggleSeries: () => void; + readonly onToggleSearchSynonymy: () => void; readonly onRunCountOnly: () => void; readonly onSubmitClick: () => void; }): JSX.Element { @@ -68,6 +72,15 @@ export function QueryToolbar({ {queryText.distinct()} )} + {isTreeTable(tableName) || tableName === 'CollectionObject' ? ( + + + {queryText.searchSynonyms()} + + ) : undefined} {queryText.countOnly()} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index b2f3c80ec3a..446a7ce0bcb 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -96,6 +96,7 @@ function Wrapped({ readonly onChange?: (props: { readonly fields: RA>; readonly isDistinct: boolean | null; + readonly searchSynonymy: boolean | null; readonly isSeries: boolean | null; }) => void; }): JSX.Element { @@ -161,9 +162,10 @@ function Wrapped({ handleChange?.({ fields: unParseQueryFields(state.baseTableName, state.fields), isDistinct: query.selectDistinct, + searchSynonymy: query.searchSynonymy, isSeries: query.smushed, }); - }, [state, query.selectDistinct, query.smushed]); + }, [state, query.selectDistinct, query.searchSynonymy, query.smushed]); /** * If tried to save a query, enforce the field length limit for the @@ -579,6 +581,7 @@ function Wrapped({ { + setQuery({ + ...query, + searchSynonymy: !(query.searchSynonymy ?? false), + }); + setSaveRequired(true); + }} onToggleSeries={(): void => { setQuery({ ...query, diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/__snapshots__/fromTree.test.ts.snap b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/__snapshots__/fromTree.test.ts.snap index ce5558cd8c2..3e53a5dbe9d 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/__snapshots__/fromTree.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/__snapshots__/fromTree.test.ts.snap @@ -85,6 +85,7 @@ exports[`queryFromTree 1`] = ` "isfavorite": true, "name": "Collection Object using \\"Los Angeles County\\"", "ordinal": 32767, + "searchsynonymy": false, "selectdistinct": false, "smushed": false, "specifyuser": "/api/specify/specifyuser/2/", @@ -160,6 +161,7 @@ exports[`queryFromTree 1`] = ` "isfavorite": true, "name": "Collection Object using \\"Cabinet 1\\"", "ordinal": 32767, + "searchsynonymy": false, "selectdistinct": false, "smushed": false, "specifyuser": "/api/specify/specifyuser/2/", @@ -235,6 +237,7 @@ exports[`queryFromTree 1`] = ` "isfavorite": true, "name": "Collection Object using \\"Carpiodes velifer\\"", "ordinal": 32767, + "searchsynonymy": false, "selectdistinct": false, "smushed": false, "specifyuser": "/api/specify/specifyuser/2/", @@ -310,6 +313,7 @@ exports[`queryFromTree 1`] = ` "isfavorite": true, "name": "Collection Object using \\"Paleocene\\"", "ordinal": 32767, + "searchsynonymy": false, "selectdistinct": false, "smushed": false, "specifyuser": "/api/specify/specifyuser/2/", @@ -385,6 +389,7 @@ exports[`queryFromTree 1`] = ` "isfavorite": true, "name": "Collection Object using \\"Cretaceous\\"", "ordinal": 32767, + "searchsynonymy": false, "selectdistinct": false, "smushed": false, "specifyuser": "/api/specify/specifyuser/2/", @@ -460,6 +465,7 @@ exports[`queryFromTree 1`] = ` "isfavorite": true, "name": "Collection Object using \\"Plate\\"", "ordinal": 32767, + "searchsynonymy": false, "selectdistinct": false, "smushed": false, "specifyuser": "/api/specify/specifyuser/2/", diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/index.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/index.tsx index 6d354eb5c35..ccaba484c11 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/index.tsx @@ -97,6 +97,7 @@ export function createQuery( query.set('contextName', table.name); query.set('contextTableId', table.tableId); query.set('selectDistinct', false); + query.set('searchSynonymy', false); query.set('smushed', false); query.set('countOnly', false); query.set('formatAuditRecIds', false); diff --git a/specifyweb/frontend/js_src/lib/components/QueryComboBox/helpers.ts b/specifyweb/frontend/js_src/lib/components/QueryComboBox/helpers.ts index 4d0a3dad9f5..2226a43f053 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryComboBox/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/QueryComboBox/helpers.ts @@ -35,6 +35,7 @@ export function makeComboBoxQuery({ query.set('contextName', table.name); query.set('contextTableId', table.tableId); query.set('selectDistinct', false); + query.set('searchSynonymy', false); query.set('smushed', false); query.set('countOnly', false); query.set('specifyUser', userInformation.resource_uri); diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/ResultsDialog.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/ResultsDialog.tsx index 856ec7727da..88283d41383 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/ResultsDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/ResultsDialog.tsx @@ -28,6 +28,7 @@ export const queryToSpec = (query: SerializedResource): QuerySpec => ({ tableName: query.contextName as keyof Tables, fields: addPath(query.fields), isDistinct: query.selectDistinct, + searchSynonymy: query.searchSynonymy, }); export function FrontEndStatsResultDialog({ diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/hooks.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/hooks.tsx index 186aa63821b..5c25d5ab521 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/hooks.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/hooks.tsx @@ -233,6 +233,7 @@ export const querySpecToResource = ( contextTableId: genericTables[querySpec.tableName].tableId, countOnly: false, selectDistinct: querySpec.isDistinct ?? false, + searchSynonymy: querySpec.searchSynonymy ?? false, fields: makeSerializedFieldsFromPaths( querySpec.tableName, querySpec.fields diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/types.ts b/specifyweb/frontend/js_src/lib/components/Statistics/types.ts index df586cb6725..29a349366cc 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/types.ts +++ b/specifyweb/frontend/js_src/lib/components/Statistics/types.ts @@ -51,6 +51,7 @@ export type QuerySpec = { readonly tableName: keyof Tables; readonly fields: RA; readonly isDistinct?: boolean | null; + readonly searchSynonymy?: boolean | null; }; export type StatCategoryReturn = IR<{ diff --git a/specifyweb/frontend/js_src/lib/hooks/__tests__/useDeleteBlockers.test.tsx b/specifyweb/frontend/js_src/lib/hooks/__tests__/useDeleteBlockers.test.tsx index b0f874429ec..9429b0399a4 100644 --- a/specifyweb/frontend/js_src/lib/hooks/__tests__/useDeleteBlockers.test.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/__tests__/useDeleteBlockers.test.tsx @@ -119,7 +119,7 @@ overrideAjax( ordinal: 32_767, remarks: null, resource_uri: undefined, - searchsynonymy: null, + searchsynonymy: false, selectdistinct: false, smushed: false, specifyuser: '/api/specify/specifyuser/2/', diff --git a/specifyweb/frontend/js_src/lib/localization/query.ts b/specifyweb/frontend/js_src/lib/localization/query.ts index 252e10d8a73..59bd459a35a 100644 --- a/specifyweb/frontend/js_src/lib/localization/query.ts +++ b/specifyweb/frontend/js_src/lib/localization/query.ts @@ -301,6 +301,9 @@ export const queryText = createDictionary({ 'ru-ru': 'Ряд', 'uk-ua': 'Серія', }, + searchSynonyms: { + 'en-us': 'Search Synonyms', + }, createCsv: { 'en-us': 'Create CSV', 'ru-ru': 'Создать CSV-файл', diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/index.ts b/specifyweb/frontend/js_src/lib/tests/ajax/index.ts index 8b7617e7148..a25f5915660 100644 --- a/specifyweb/frontend/js_src/lib/tests/ajax/index.ts +++ b/specifyweb/frontend/js_src/lib/tests/ajax/index.ts @@ -109,10 +109,7 @@ export async function ajaxMock( } })(); - const parsedUrl = new URL( - url, - safeOrigin - ); + const parsedUrl = new URL(url, safeOrigin); const urlWithoutQuery = `${parsedUrl.origin}${parsedUrl.pathname}`; const overwrittenData = overrides[url]?.[requestMethod] ??