From 6ae876fe9bceb1c4ab5120ca9655af67e60c68c5 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Wed, 24 Jul 2024 10:33:35 -0700 Subject: [PATCH 01/24] Add search on synonyms in the QB Fixes #752 --- .../Forms/__tests__/DeleteButton.test.tsx | 2 +- .../js_src/lib/components/QueryBuilder/Toolbar.tsx | 13 +++++++++++++ .../js_src/lib/components/QueryBuilder/Wrapped.tsx | 11 ++++++++++- .../__tests__/__snapshots__/fromTree.test.ts.snap | 5 +++++ .../js_src/lib/components/QueryBuilder/index.tsx | 1 + .../js_src/lib/components/QueryComboBox/helpers.ts | 1 + .../lib/components/Statistics/ResultsDialog.tsx | 1 + .../js_src/lib/components/Statistics/hooks.tsx | 1 + .../js_src/lib/components/Statistics/types.ts | 1 + .../frontend/js_src/lib/localization/query.ts | 3 +++ 10 files changed, 37 insertions(+), 2 deletions(-) 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 c6191830a03..76cbc4bc533 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 @@ -101,7 +101,7 @@ overrideAjax( ordinal: 32_767, remarks: null, resource_uri: undefined, - searchsynonymy: null, + searchsynonymy: false, selectdistinct: false, smushed: null, specifyuser: '/api/specify/specifyuser/2/', diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx index 4dc73dbf72c..794d5cafa44 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx @@ -13,16 +13,20 @@ export function QueryToolbar({ showHiddenFields, tableName, isDistinct, + searchSynonymy, onToggleHidden: handleToggleHidden, onToggleDistinct: handleToggleDistinct, + onToggleSearchSynonymy: handleToggleSearchSynonymy, onRunCountOnly: handleRunCountOnly, onSubmitClick: handleSubmitClick, }: { readonly showHiddenFields: boolean; readonly tableName: keyof Tables; readonly isDistinct: boolean; + readonly searchSynonymy: boolean; readonly onToggleHidden: (value: boolean) => void; readonly onToggleDistinct: () => void; + readonly onToggleSearchSynonymy: () => void; readonly onRunCountOnly: () => void; readonly onSubmitClick: () => void; }): JSX.Element { @@ -51,6 +55,15 @@ export function QueryToolbar({ {queryText.distinct()} )} + {isTreeTable(tableName) && ( + + + {queryText.searchSynonyms()} + + )} {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 7cfe981d1f8..f240a8b3657 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -94,6 +94,7 @@ function Wrapped({ readonly onChange?: (props: { readonly fields: RA>; readonly isDistinct: boolean | null; + readonly searchSynonymy: boolean | null; }) => void; }): JSX.Element { const [query, setQuery] = useResource(queryResource); @@ -157,8 +158,9 @@ function Wrapped({ handleChange?.({ fields: unParseQueryFields(state.baseTableName, state.fields), isDistinct: query.selectDistinct, + searchSynonymy: query.searchSynonymy, }); - }, [state, query.selectDistinct]); + }, [state, query.selectDistinct, query.searchSynonymy]); /** * If tried to save a query, enforce the field length limit for the @@ -556,6 +558,7 @@ function Wrapped({ /> runQuery('count')} @@ -571,6 +574,12 @@ function Wrapped({ }) } onToggleHidden={setShowHiddenFields} + onToggleSearchSynonymy={(): void => + setQuery({ + ...query, + searchSynonymy: !(query.searchSynonymy ?? false), + }) + } /> {hasPermission('/querybuilder/query', 'execute') && ( 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 261cf37389b..0892cab4df4 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, "specifyuser": "/api/specify/specifyuser/2/", }, @@ -159,6 +160,7 @@ exports[`queryFromTree 1`] = ` "isfavorite": true, "name": "Collection Object using \\"Cabinet 1\\"", "ordinal": 32767, + "searchsynonymy": false, "selectdistinct": false, "specifyuser": "/api/specify/specifyuser/2/", }, @@ -233,6 +235,7 @@ exports[`queryFromTree 1`] = ` "isfavorite": true, "name": "Collection Object using \\"Carpiodes velifer\\"", "ordinal": 32767, + "searchsynonymy": false, "selectdistinct": false, "specifyuser": "/api/specify/specifyuser/2/", }, @@ -307,6 +310,7 @@ exports[`queryFromTree 1`] = ` "isfavorite": true, "name": "Collection Object using \\"Paleocene\\"", "ordinal": 32767, + "searchsynonymy": false, "selectdistinct": false, "specifyuser": "/api/specify/specifyuser/2/", }, @@ -381,6 +385,7 @@ exports[`queryFromTree 1`] = ` "isfavorite": true, "name": "Collection Object using \\"Cretaceous\\"", "ordinal": 32767, + "searchsynonymy": false, "selectdistinct": 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 37e36803e15..35b8ca1a431 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('countOnly', false); query.set('formatAuditRecIds', false); query.set('specifyUser', userInformation.resource_uri); diff --git a/specifyweb/frontend/js_src/lib/components/QueryComboBox/helpers.ts b/specifyweb/frontend/js_src/lib/components/QueryComboBox/helpers.ts index 0a3651d8e8f..d9d1a6affbd 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('countOnly', false); query.set('specifyUser', userInformation.resource_uri); query.set('isFavorite', false); 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 b4fefe75a41..05e596dc648 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/localization/query.ts b/specifyweb/frontend/js_src/lib/localization/query.ts index 3e6785b534f..d87f647c0a0 100644 --- a/specifyweb/frontend/js_src/lib/localization/query.ts +++ b/specifyweb/frontend/js_src/lib/localization/query.ts @@ -303,6 +303,9 @@ export const queryText = createDictionary({ 'uk-ua': 'Виразний', 'de-ch': 'Unterscheidbar', }, + searchSynonyms: { + 'en-us': 'Search Synonyms', + }, createCsv: { 'en-us': 'Create CSV', 'ru-ru': 'Создать CSV-файл', From 40d4916cd955742c2e0d44a530bafc8910f54206 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Wed, 24 Jul 2024 10:43:04 -0700 Subject: [PATCH 02/24] Add search synonym param to build query def --- specifyweb/stored_queries/execution.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/specifyweb/stored_queries/execution.py b/specifyweb/stored_queries/execution.py index 2aaa201dcab..2109cd9f5ab 100644 --- a/specifyweb/stored_queries/execution.py +++ b/specifyweb/stored_queries/execution.py @@ -386,6 +386,7 @@ def run_ephemeral_query(collection, user, spquery): offset = spquery.get('offset', 0) recordsetid = spquery.get('recordsetid', None) distinct = spquery['selectdistinct'] + searchSynonymy = spquery['searchsynonymy'] tableid = spquery['contexttableid'] count_only = spquery['countonly'] try: @@ -395,7 +396,8 @@ def run_ephemeral_query(collection, user, spquery): with models.session_context() as session: field_specs = field_specs_from_json(spquery['fields']) - return execute(session, collection, user, tableid, distinct, count_only, + return execute(session, collection, user, tableid, distinct, + searchSynonymy, count_only, field_specs, limit, offset, recordsetid, formatauditobjs=format_audits) def augment_field_specs(field_specs, formatauditobjs=False): @@ -526,11 +528,11 @@ def return_loan_preps(collection, user, agent, data): ]) return to_return -def execute(session, collection, user, tableid, distinct, count_only, field_specs, limit, offset, recordsetid=None, formatauditobjs=False): +def execute(session, collection, user, tableid, distinct, searchSynonymy, count_only, field_specs, limit, offset, recordsetid=None, formatauditobjs=False): "Build and execute a query, returning the results as a data structure for json serialization" set_group_concat_max_len(session) - query, order_by_exprs = build_query(session, collection, user, tableid, field_specs, recordsetid=recordsetid, formatauditobjs=formatauditobjs, distinct=distinct) + query, order_by_exprs = build_query(session, collection, user, tableid, field_specs, recordsetid=recordsetid, formatauditobjs=formatauditobjs, distinct=distinct, searchSynonymy=searchSynonymy) if count_only: return {'count': query.count()} @@ -543,7 +545,7 @@ def execute(session, collection, user, tableid, distinct, count_only, field_spec return {'results': list(query)} def build_query(session, collection, user, tableid, field_specs, - recordsetid=None, replace_nulls=False, formatauditobjs=False, distinct=False, implicit_or=True): + recordsetid=None, replace_nulls=False, formatauditobjs=False, distinct=False, searchSynonymy=False, implicit_or=True): """Build a sqlalchemy query using the QueryField objects given by field_specs. @@ -568,6 +570,8 @@ def build_query(session, collection, user, tableid, field_specs, replace_nulls = if True, replace null values with "" distinct = if True, group by all display fields, and return all record IDs associated with a row + + searchSynonymy = if True, search synonym nodes as well, and return all record IDs associated with parent node """ model = models.models_by_tableid[tableid] id_field = getattr(model, model._id) From c147466d62cd930b0764644b4b92ce3d69a6f69d Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Wed, 31 Jul 2024 16:18:27 -0500 Subject: [PATCH 03/24] Add attribute --- specifyweb/stored_queries/execution.py | 1 + specifyweb/stored_queries/query_construct.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/stored_queries/execution.py b/specifyweb/stored_queries/execution.py index 2109cd9f5ab..9b350931e0a 100644 --- a/specifyweb/stored_queries/execution.py +++ b/specifyweb/stored_queries/execution.py @@ -584,6 +584,7 @@ def build_query(session, collection, user, tableid, field_specs, collection=collection, objectformatter=ObjectFormatter(collection, user, replace_nulls), query=session.query(func.group_concat(id_field.distinct(), separator=',')) if distinct else session.query(id_field), + searchSynonymy=searchSynonymy ) tables_to_read = set([ diff --git a/specifyweb/stored_queries/query_construct.py b/specifyweb/stored_queries/query_construct.py index 00fda9a4bc2..cc840c69da9 100644 --- a/specifyweb/stored_queries/query_construct.py +++ b/specifyweb/stored_queries/query_construct.py @@ -15,7 +15,7 @@ def get_treedef(collection, tree_name): if tree_name == 'Storage' else getattr(collection.discipline, tree_name.lower() + "treedef")) -class QueryConstruct(namedtuple('QueryConstruct', 'collection objectformatter query join_cache param_count tree_rank_count')): +class QueryConstruct(namedtuple('QueryConstruct', 'collection objectformatter query join_cache param_count tree_rank_count searchSynonymy')): def __new__(cls, *args, **kwargs): kwargs['join_cache'] = dict() From 3a29237a850cf24812561af4ef229d0ac81ffefa Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 24 Oct 2025 14:57:32 -0500 Subject: [PATCH 04/24] cleanup searchsynonymy merge on QB execution --- specifyweb/backend/stored_queries/execution.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/specifyweb/backend/stored_queries/execution.py b/specifyweb/backend/stored_queries/execution.py index e03f7446aed..2ba6af289a1 100644 --- a/specifyweb/backend/stored_queries/execution.py +++ b/specifyweb/backend/stored_queries/execution.py @@ -57,6 +57,7 @@ class BuildQueryProps(NamedTuple): formatauditobjs: bool = False distinct: bool = False series: bool = False + search_synonymy: bool = False implicit_or: bool = True formatter_props: ObjectFormatterProps = ObjectFormatterProps( format_agent_type = False, @@ -582,6 +583,7 @@ def run_ephemeral_query(collection, user, spquery): recordsetid = spquery.get("recordsetid", None) distinct = spquery["selectdistinct"] series = spquery.get('smushed', None) + search_synonymy = spquery['searchsynonymy'] tableid = spquery["contexttableid"] count_only = spquery["countonly"] format_audits = spquery.get("formatauditrecids", False) @@ -595,6 +597,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, @@ -783,6 +786,7 @@ def execute( tableid, distinct, series, + search_synonymy, count_only, field_specs, limit, @@ -805,6 +809,7 @@ def execute( formatauditobjs=formatauditobjs, distinct=distinct, series=series, + search_synonymy=search_synonymy, formatter_props=formatter_props, ), ) @@ -891,6 +896,8 @@ 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] id_field = model._id From f1ca8b3c88932d54fcd6949ee52fafdcad4ea9c9 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 31 Oct 2025 01:03:32 -0500 Subject: [PATCH 05/24] create synonymize_taxon_query function --- .../backend/stored_queries/execution.py | 6 + .../backend/stored_queries/query_construct.py | 2 +- specifyweb/backend/stored_queries/synonomy.py | 136 ++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 specifyweb/backend/stored_queries/synonomy.py diff --git a/specifyweb/backend/stored_queries/execution.py b/specifyweb/backend/stored_queries/execution.py index 2ba6af289a1..5ae72473a3f 100644 --- a/specifyweb/backend/stored_queries/execution.py +++ b/specifyweb/backend/stored_queries/execution.py @@ -34,6 +34,7 @@ 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_taxon_query logger = logging.getLogger(__name__) @@ -1015,6 +1016,11 @@ 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: + log_sqlalchemy_query(query.query) + synonymized_query = synonymize_taxon_query(query.query) + 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 10b13c45939..7197163dcc0 100644 --- a/specifyweb/backend/stored_queries/query_construct.py +++ b/specifyweb/backend/stored_queries/query_construct.py @@ -22,7 +22,7 @@ def get_treedef(collection, tree_name): if tree_name == 'Storage' else getattr(collection.discipline, tree_name.lower() + "treedef")) -class QueryConstruct(namedtuple('QueryConstruct', 'collection objectformatter query join_cache tree_rank_count internal_filters searchSynonymy')): +class QueryConstruct(namedtuple('QueryConstruct', 'collection objectformatter query join_cache tree_rank_count internal_filters')): def __new__(cls, *args, **kwargs): kwargs['join_cache'] = dict() diff --git a/specifyweb/backend/stored_queries/synonomy.py b/specifyweb/backend/stored_queries/synonomy.py new file mode 100644 index 00000000000..17c311f2ebb --- /dev/null +++ b/specifyweb/backend/stored_queries/synonomy.py @@ -0,0 +1,136 @@ +from typing import Optional, Tuple, List +from sqlalchemy import select, union, join +from sqlalchemy.sql import Select +from sqlalchemy.orm import Query +from sqlalchemy.sql.selectable import FromClause, Alias, Join +from sqlalchemy.sql.schema import Table + +def synonymize_taxon_query(query: Query) -> Query: + """ + Expand a Taxon query to include taxa whose synonymized children match the original predicate. + - If input is ORM Query: returns a *chainable* ORM Query (no from_statement), so .filter() still works. + - If input is Core Select: returns a Select. + + Strategy (same semantics as before): + target_taxon := (original FROM+JOINS + WHERE) projected as (t.TaxonID, t.AcceptedID) + ids := SELECT TaxonID UNION SELECT AcceptedID FROM target_taxon (AcceptedID NOT NULL) + final := (original SELECT list) + (original FROM/JOINS but *no WHERE*) + WHERE t.TaxonID IN (ids) + """ + base_sel: Select = query.statement if isinstance(query, Query) else query + + # Find the Taxon base table and the specific FROM/alias used in the original query + taxon_table, taxon_from = _find_taxon_table_and_from(base_sel) + if taxon_table is None or taxon_from is None: + raise ValueError("include_synonyms_preserve_projection: couldn't locate 'taxon' in the query FROMs.") + + # Build `target_taxon` CTE based on the given query + target_taxon = select( + taxon_from.c.TaxonID.label("TaxonID"), + taxon_from.c.AcceptedID.label("AcceptedID"), + ) + for f in base_sel.get_final_froms(): + target_taxon = target_taxon.select_from(f) + 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) + + target_taxon_cte = target_taxon.cte("target_taxon") + + # Subquery to get the relevant ids for synonymy: TaxonID 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") + + # Build a fresh chainable ORM Query with the same SELECT and FROM statements, but no WHERE clause. + # Add the 'WHERE t.TaxonID IN (ids)' clause at the end. This preserves ability to .filter() later. + 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 orignal 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) + + # Add the synonym expansion as that clause WHERE .. IN (ids) + new_query = new_query.filter(taxon_from.c.TaxonID.in_(select(ids.c.id))) + return new_query + +def _find_taxon_table_and_from(sel: Select) -> Tuple[Optional[Table], Optional[FromClause]]: + """ + Robustly find: + - the underlying Table for 'taxon' (the real Table object) + - the specific FromClause (table OR alias) used in `sel` for 'taxon' + Works with: Table, Alias(Table), Join trees, Alias(Join(...)). + """ + + target_name = "taxon" + + def is_taxon_table(t: Table) -> bool: + # Compare case-insensitively; 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: + # Keep the alias as the "from" if it aliases a Table directly; but for Alias(Join), + # we still want the real FromClause for 'taxon' inside that join tree. + return t, frm + + # Join: recurse both sides (left, right can themselves be Alias, Join, Table, etc.) + 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: give up on this node + 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 taxon 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 From 222b8b5199f110d5c5f4d97014e33a97428534ab Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 31 Oct 2025 16:06:47 -0500 Subject: [PATCH 06/24] remove unused function --- specifyweb/backend/stored_queries/query_construct.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/specifyweb/backend/stored_queries/query_construct.py b/specifyweb/backend/stored_queries/query_construct.py index 7197163dcc0..848795bf7e2 100644 --- a/specifyweb/backend/stored_queries/query_construct.py +++ b/specifyweb/backend/stored_queries/query_construct.py @@ -17,11 +17,6 @@ def _safe_filter(query): return query.first() raise Exception(f"Got more than one matching: {list(query)}") -def get_treedef(collection, tree_name): - return (collection.discipline.division.institution.storagetreedef - if tree_name == 'Storage' else - getattr(collection.discipline, tree_name.lower() + "treedef")) - class QueryConstruct(namedtuple('QueryConstruct', 'collection objectformatter query join_cache tree_rank_count internal_filters')): def __new__(cls, *args, **kwargs): From 8c952d2b15f722f474a5590ff893ec4b573c4dc4 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 3 Nov 2025 13:03:04 -0600 Subject: [PATCH 07/24] add search_synonymy to get_simple_query --- specifyweb/backend/stored_queries/tests/test_views/raw_query.py | 1 + 1 file changed, 1 insertion(+) 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}/", From c1a9a0a2c194334fb4ab1c558723b95b7f1ec921 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 3 Nov 2025 13:11:08 -0600 Subject: [PATCH 08/24] fix batch edit unit tests --- specifyweb/backend/stored_queries/batch_edit.py | 1 + specifyweb/backend/stored_queries/query_construct.py | 6 ++++++ 2 files changed, 7 insertions(+) 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/query_construct.py b/specifyweb/backend/stored_queries/query_construct.py index 848795bf7e2..62b2c9c2d78 100644 --- a/specifyweb/backend/stored_queries/query_construct.py +++ b/specifyweb/backend/stored_queries/query_construct.py @@ -146,6 +146,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): From 8450777eaf05fbd48e99b36674fea9d238cbe06a Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 3 Nov 2025 13:16:41 -0600 Subject: [PATCH 09/24] add search_synonymy to TestExecute calls to qb execute function --- .../stored_queries/tests/test_execution/test_execute.py | 7 +++++++ 1 file changed, 7 insertions(+) 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..2483d9e62ea 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, From fe6bcf077f710be116dc3f462503ffcede2aada4 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 3 Nov 2025 14:43:16 -0600 Subject: [PATCH 10/24] fix sp_query sqlalchemy getter call to searchSynonymy --- .../backend/stored_queries/tests/test_execution/test_execute.py | 1 + specifyweb/backend/stored_queries/views.py | 2 ++ 2 files changed, 3 insertions(+) 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 2483d9e62ea..ad6ef34cf99 100644 --- a/specifyweb/backend/stored_queries/tests/test_execution/test_execute.py +++ b/specifyweb/backend/stored_queries/tests/test_execution/test_execute.py @@ -232,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/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, From 4d906873e2112fd572442c868026977852bc0b57 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 3 Nov 2025 15:12:50 -0600 Subject: [PATCH 11/24] fix searchsynonymy in front-end test --- .../js_src/lib/hooks/__tests__/useDeleteBlockers.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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/', From 62b85d0f025608f156a8bf9634ca02148519466c Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 3 Nov 2025 16:33:10 -0600 Subject: [PATCH 12/24] add missing searchsynonymy in front-end unit test --- .../QueryBuilder/__tests__/__snapshots__/fromTree.test.ts.snap | 1 + 1 file changed, 1 insertion(+) 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 3857c9a3297..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 @@ -465,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/", From 5e2cfaf2a62b0f367ec5d4ec25c749940e029eb0 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 13 Nov 2025 11:40:25 -0600 Subject: [PATCH 13/24] add synonymize_by_expanding_accepted_taxon_query --- .../backend/stored_queries/execution.py | 4 +- specifyweb/backend/stored_queries/synonomy.py | 119 ++++++++++++++---- 2 files changed, 98 insertions(+), 25 deletions(-) diff --git a/specifyweb/backend/stored_queries/execution.py b/specifyweb/backend/stored_queries/execution.py index 2e64e26de3f..d2f3772e43e 100644 --- a/specifyweb/backend/stored_queries/execution.py +++ b/specifyweb/backend/stored_queries/execution.py @@ -34,7 +34,7 @@ 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_taxon_query +from specifyweb.backend.stored_queries.synonomy import synonymize_by_expanding_accepted_taxon_query logger = logging.getLogger(__name__) @@ -1026,7 +1026,7 @@ def build_query( if props.search_synonymy: log_sqlalchemy_query(query.query) - synonymized_query = synonymize_taxon_query(query.query) + synonymized_query = synonymize_by_expanding_accepted_taxon_query(query.query) query = query._replace(query=synonymized_query) internal_predicate = query.get_internal_filters() diff --git a/specifyweb/backend/stored_queries/synonomy.py b/specifyweb/backend/stored_queries/synonomy.py index 17c311f2ebb..2ece5ce2606 100644 --- a/specifyweb/backend/stored_queries/synonomy.py +++ b/specifyweb/backend/stored_queries/synonomy.py @@ -1,5 +1,5 @@ from typing import Optional, Tuple, List -from sqlalchemy import select, union, join +from sqlalchemy import select, union from sqlalchemy.sql import Select from sqlalchemy.orm import Query from sqlalchemy.sql.selectable import FromClause, Alias, Join @@ -8,28 +8,94 @@ def synonymize_taxon_query(query: Query) -> Query: """ Expand a Taxon query to include taxa whose synonymized children match the original predicate. - - If input is ORM Query: returns a *chainable* ORM Query (no from_statement), so .filter() still works. - - If input is Core Select: returns a Select. - Strategy (same semantics as before): + Strategy: target_taxon := (original FROM+JOINS + WHERE) projected as (t.TaxonID, t.AcceptedID) - ids := SELECT TaxonID UNION SELECT AcceptedID FROM target_taxon (AcceptedID NOT NULL) - final := (original SELECT list) + (original FROM/JOINS but *no WHERE*) + WHERE t.TaxonID IN (ids) + ids := SELECT TaxonID UNION SELECT AcceptedID FROM target_taxon (AcceptedID NOT NULL) + final := (original SELECT list) + (original FROM/JOINS but *no WHERE*) + + WHERE t.TaxonID IN (ids) """ base_sel: Select = query.statement if isinstance(query, Query) else query # Find the Taxon base table and the specific FROM/alias used in the original query taxon_table, taxon_from = _find_taxon_table_and_from(base_sel) if taxon_table is None or taxon_from is None: - raise ValueError("include_synonyms_preserve_projection: couldn't locate 'taxon' in the query FROMs.") + raise ValueError("synonymize_taxon_query: couldn't locate 'taxon' in the query FROMs.") # Build `target_taxon` CTE based on the given query + target_taxon_cte = _build_target_taxon_cte(base_sel, taxon_from, cte_name="target_taxon") + + # Subquery to get the relevant ids for synonymy: TaxonID 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, base_sel, taxon_from, ids) + +def synonymize_by_expanding_accepted_taxon_query(query: Query) -> Query: + """ + Expand a Taxon query in the *opposite* direction of synonymize_taxon_query: + - Start from the taxa that match the original predicate (usually accepted taxa), + - Then include all synonyms whose AcceptedID points to those taxa. + + Strategy: + target_taxon := (original FROM+JOINS + WHERE) projected as (t.TaxonID, t.AcceptedID) + root_ids := SELECT TaxonID FROM target_taxon -- the "root" taxa + syn_ids := SELECT TaxonID FROM taxon WHERE AcceptedID IN (root_ids) + ids := root_ids UNION syn_ids + final := (original SELECT list) + (original FROM/JOINS but *no WHERE*) + + WHERE t.TaxonID IN (ids) + """ + base_sel: Select = query.statement if isinstance(query, Query) else query + + # Find the Taxon base table and the specific FROM/alias used in the original query + taxon_table, taxon_from = _find_taxon_table_and_from(base_sel) + if taxon_table is None or taxon_from is None: + raise ValueError("expand_accepted_taxon_query: couldn't locate 'taxon' in the query FROMs.") + + # Build `target_taxon` CTE based on the given query + target_taxon_cte = _build_target_taxon_cte(base_sel, taxon_from, cte_name="target_taxon") + + # root_ids: the taxa that actually matched the original predicate + root_ids = select(target_taxon_cte.c.TaxonID.label("id")) + + # syn_ids: any taxon whose AcceptedID points at one of those root_ids + # Use the underlying taxon_table (not the alias) so we don't bring over the original WHERE. + syn_ids = select(taxon_table.c.TaxonID.label("id")).where( + taxon_table.c.AcceptedID.in_( + select(target_taxon_cte.c.TaxonID) + ) + ) + + ids = union(root_ids, syn_ids).subquery("ids") + + # Rebuild a fresh chainable ORM Query using these ids + return _rebuild_query_with_ids(query, base_sel, taxon_from, ids) + +def _build_target_taxon_cte( + base_sel: Select, + taxon_from: FromClause, + cte_name: str = "target_taxon", +): + """ + Given the original Select and the Taxon FromClause/alias used in it, + build a CTE that projects (TaxonID, AcceptedID) with all original + FROM / WHERE / GROUP BY / HAVING preserved. + """ target_taxon = select( taxon_from.c.TaxonID.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 (): @@ -37,21 +103,30 @@ def synonymize_taxon_query(query: Query) -> Query: if getattr(base_sel, "_having", None) is not None: target_taxon = target_taxon.having(base_sel._having) - target_taxon_cte = target_taxon.cte("target_taxon") + return target_taxon.cte(cte_name) - # Subquery to get the relevant ids for synonymy: TaxonID 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") - - # Build a fresh chainable ORM Query with the same SELECT and FROM statements, but no WHERE clause. - # Add the 'WHERE t.TaxonID IN (ids)' clause at the end. This preserves ability to .filter() later. +def _rebuild_query_with_ids( + query: Query, + base_sel: Select, + taxon_from: FromClause, + ids_subquery: FromClause, +) -> 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 taxon.TaxonID 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 orignal query, these already carry the join conditions + + # 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) @@ -63,15 +138,13 @@ def synonymize_taxon_query(query: Query) -> Query: if getattr(base_sel, "_order_by_clauses", None): new_query = new_query.order_by(*base_sel._order_by_clauses) - # Add the synonym expansion as that clause WHERE .. IN (ids) - new_query = new_query.filter(taxon_from.c.TaxonID.in_(select(ids.c.id))) + # Apply the expansion condition + new_query = new_query.filter(taxon_from.c.TaxonID.in_(select(ids_subquery.c.id))) return new_query def _find_taxon_table_and_from(sel: Select) -> Tuple[Optional[Table], Optional[FromClause]]: """ - Robustly find: - - the underlying Table for 'taxon' (the real Table object) - - the specific FromClause (table OR alias) used in `sel` for 'taxon' + Find the underlying Table for 'taxon' (the real Table object) and the specific FromClause (table OR alias) used in `sel` for 'taxon' Works with: Table, Alias(Table), Join trees, Alias(Join(...)). """ @@ -125,7 +198,7 @@ def walk(fc: FromClause) -> Tuple[Optional[Table], Optional[FromClause]]: try: for col in sel.selected_columns: tbl = getattr(col, "table", None) - el = getattr(tbl, "element", 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): From daeeb8bdd3213ab06b01c9039497c89d626c234c Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 13 Nov 2025 12:38:58 -0600 Subject: [PATCH 14/24] synonymize_tree_query on all tree types --- .../backend/stored_queries/execution.py | 4 +- specifyweb/backend/stored_queries/synonomy.py | 167 ++++++++++-------- 2 files changed, 97 insertions(+), 74 deletions(-) diff --git a/specifyweb/backend/stored_queries/execution.py b/specifyweb/backend/stored_queries/execution.py index d2f3772e43e..e738094cc42 100644 --- a/specifyweb/backend/stored_queries/execution.py +++ b/specifyweb/backend/stored_queries/execution.py @@ -34,7 +34,7 @@ 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_by_expanding_accepted_taxon_query +from specifyweb.backend.stored_queries.synonomy import synonymize_tree_query logger = logging.getLogger(__name__) @@ -1026,7 +1026,7 @@ def build_query( if props.search_synonymy: log_sqlalchemy_query(query.query) - synonymized_query = synonymize_by_expanding_accepted_taxon_query(query.query) + synonymized_query = synonymize_tree_query(query.query, table) query = query._replace(query=synonymized_query) internal_predicate = query.get_internal_filters() diff --git a/specifyweb/backend/stored_queries/synonomy.py b/specifyweb/backend/stored_queries/synonomy.py index 2ece5ce2606..c52e8a7e347 100644 --- a/specifyweb/backend/stored_queries/synonomy.py +++ b/specifyweb/backend/stored_queries/synonomy.py @@ -4,90 +4,107 @@ 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_taxon_query(query: Query) -> Query: +def synonymize_tree_query( + query: Query, + table: "SpecifyTable", + expand_from_accepted: bool = True, +) -> Query: """ - Expand a Taxon query to include taxa whose synonymized children match the original predicate. - - Strategy: - target_taxon := (original FROM+JOINS + WHERE) projected as (t.TaxonID, t.AcceptedID) - ids := SELECT TaxonID UNION SELECT AcceptedID FROM target_taxon (AcceptedID NOT NULL) - final := (original SELECT list) + (original FROM/JOINS but *no WHERE*) - + WHERE t.TaxonID IN (ids) + 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. + - Then include all synonyms whose AcceptedID points to those records. + + Query Building Strategy: + target_taxon := (original FROM+JOINS + WHERE) projected as (id_col, AcceptedID) + root_ids := SELECT id_col FROM target_taxon + syn_ids := SELECT id_col FROM tree WHERE AcceptedID IN (root_ids) + ids := root_ids 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 - # Find the Taxon base table and the specific FROM/alias used in the original query - taxon_table, taxon_from = _find_taxon_table_and_from(base_sel) - if taxon_table is None or taxon_from is None: - raise ValueError("synonymize_taxon_query: couldn't locate 'taxon' in the query FROMs.") - - # Build `target_taxon` CTE based on the given query - target_taxon_cte = _build_target_taxon_cte(base_sel, taxon_from, cte_name="target_taxon") + tree_table_name = table.table + id_col_name = table.idColumn - # Subquery to get the relevant ids for synonymy: TaxonID 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, base_sel, taxon_from, ids) - -def synonymize_by_expanding_accepted_taxon_query(query: Query) -> Query: - """ - Expand a Taxon query in the *opposite* direction of synonymize_taxon_query: - - Start from the taxa that match the original predicate (usually accepted taxa), - - Then include all synonyms whose AcceptedID points to those taxa. - - Strategy: - target_taxon := (original FROM+JOINS + WHERE) projected as (t.TaxonID, t.AcceptedID) - root_ids := SELECT TaxonID FROM target_taxon -- the "root" taxa - syn_ids := SELECT TaxonID FROM taxon WHERE AcceptedID IN (root_ids) - ids := root_ids UNION syn_ids - final := (original SELECT list) + (original FROM/JOINS but *no WHERE*) - + WHERE t.TaxonID IN (ids) - """ - base_sel: Select = query.statement if isinstance(query, Query) else query - - # Find the Taxon base table and the specific FROM/alias used in the original query - taxon_table, taxon_from = _find_taxon_table_and_from(base_sel) + # 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("expand_accepted_taxon_query: couldn't locate 'taxon' in the query FROMs.") + 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_taxon_cte(base_sel, taxon_from, cte_name="target_taxon") + target_taxon_cte = _build_target_tree_cte( + base_sel, + taxon_from, + id_col_name=id_col_name, + cte_name="target_taxon", + ) - # root_ids: the taxa that actually matched the original predicate - root_ids = select(target_taxon_cte.c.TaxonID.label("id")) + if expand_from_accepted: + # root_ids: the records that actually matched the original predicate + root_ids = select(target_taxon_cte.c.TaxonID.label("id")) - # syn_ids: any taxon whose AcceptedID points at one of those root_ids - # Use the underlying taxon_table (not the alias) so we don't bring over the original WHERE. - syn_ids = select(taxon_table.c.TaxonID.label("id")).where( - taxon_table.c.AcceptedID.in_( - select(target_taxon_cte.c.TaxonID) + # syn_ids: any record whose AcceptedID points at one of those root_ids + # Use the underlying tree table (not the alias) so we don't bring over the original WHERE. + syn_ids = select(taxon_table.c[id_col_name].label("id")).where( + taxon_table.c.AcceptedID.in_( + select(target_taxon_cte.c.TaxonID) + ) ) - ) - ids = union(root_ids, syn_ids).subquery("ids") + ids = union(root_ids, 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, base_sel, taxon_from, 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_taxon_cte( +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 Taxon FromClause/alias used in it, - build a CTE that projects (TaxonID, AcceptedID) with all original + 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.TaxonID.label("TaxonID"), + taxon_from.c[id_col_name].label("TaxonID"), taxon_from.c.AcceptedID.label("AcceptedID"), ) @@ -110,6 +127,7 @@ def _rebuild_query_with_ids( 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, @@ -118,7 +136,7 @@ def _rebuild_query_with_ids( - Same FROM (joins included) - Same GROUP BY / HAVING / ORDER BY - No original WHERE - - Adds WHERE taxon.TaxonID IN (SELECT id FROM ids_subquery) + - Adds WHERE tree.id_col_name IN (SELECT id FROM ids_subquery) """ sess = query.session original_cols: List = list(base_sel.selected_columns) @@ -138,20 +156,27 @@ def _rebuild_query_with_ids( if getattr(base_sel, "_order_by_clauses", None): new_query = new_query.order_by(*base_sel._order_by_clauses) - # Apply the expansion condition - new_query = new_query.filter(taxon_from.c.TaxonID.in_(select(ids_subquery.c.id))) + # 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_taxon_table_and_from(sel: Select) -> Tuple[Optional[Table], Optional[FromClause]]: +def _find_tree_table_and_from( + sel: Select, + tree_table_name: str = "taxon", +) -> Tuple[Optional[Table], Optional[FromClause]]: """ - Find the underlying Table for 'taxon' (the real Table object) and the specific FromClause (table OR alias) used in `sel` for 'taxon' + 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 = "taxon" + target_name = tree_table_name.lower() def is_taxon_table(t: Table) -> bool: - # Compare case-insensitively; handle schema-qualified names if any + # 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: @@ -172,11 +197,9 @@ def walk(fc: FromClause) -> Tuple[Optional[Table], Optional[FromClause]]: if isinstance(el, Join): t, frm = walk(el) if t is not None: - # Keep the alias as the "from" if it aliases a Table directly; but for Alias(Join), - # we still want the real FromClause for 'taxon' inside that join tree. return t, frm - # Join: recurse both sides (left, right can themselves be Alias, Join, Table, etc.) + # Join: recurse both sides if isinstance(fc, Join): t, frm = walk(fc.left) if t is not None: @@ -185,7 +208,7 @@ def walk(fc: FromClause) -> Tuple[Optional[Table], Optional[FromClause]]: if t is not None: return t, frm - # Unknown / composite: give up on this node + # Unknown / composite return None, None # Try walking all final FROM roots @@ -194,7 +217,7 @@ def walk(fc: FromClause) -> Tuple[Optional[Table], Optional[FromClause]]: if t is not None and frm is not None: return t, frm - # Fallback to scanning selected columns to deduce the taxon alias + # Fallback to scanning selected columns to deduce the alias try: for col in sel.selected_columns: tbl = getattr(col, "table", None) From 72f46811558867cb33486ef812c12d96d5074277 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 15 Jan 2026 18:08:32 +0000 Subject: [PATCH 15/24] Lint code with ESLint and Prettier Triggered by a44165a6aad6d883e6430041e7b0e9f148335e51 on branch refs/heads/issue-752 --- specifyweb/frontend/js_src/lib/components/Atoms/Form.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/Form.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/Form.tsx index 3d0277166d5..07902bbb7aa 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/Form.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/Form.tsx @@ -353,9 +353,9 @@ export const Select = wrap< * the background in dark-mode. This is a fix: */ if (props.required !== true && props.multiple === true) { - selected.map((option) => option.classList.add('dark:bg-neutral-500')); // highlights selected object less bright - unselected.map((option) => - option.classList.remove('dark:bg-neutral-500') // prevents a previously selected option from remaining highlighted + selected.map((option) => option.classList.add('dark:bg-neutral-500')); // Highlights selected object less bright + unselected.map( + (option) => option.classList.remove('dark:bg-neutral-500') // Prevents a previously selected option from remaining highlighted ); } const value = (event.target as HTMLSelectElement).value; From 980ce26369f358aaecf7d03299941da3573ef948 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:28:06 -0500 Subject: [PATCH 16/24] Fix: Add search synonyms option to CO base table in QB --- .../frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx index 6e8d2c26fb1..7af71e77d03 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx @@ -72,7 +72,7 @@ export function QueryToolbar({ {queryText.distinct()} )} - {isTreeTable(tableName) && ( + {isTreeTable(tableName) || tableName === 'CollectionObject' ? ( {queryText.searchSynonyms()} - )} + ) : undefined} {queryText.countOnly()} From ee690e705abe2304f9e820b5b58c4433ed67533f Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 5 Feb 2026 14:07:53 -0600 Subject: [PATCH 17/24] make synonymy expansion deterministic --- .../backend/stored_queries/execution.py | 29 +++++++++++++++---- .../js_src/lib/components/Atoms/Form.tsx | 2 -- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/specifyweb/backend/stored_queries/execution.py b/specifyweb/backend/stored_queries/execution.py index e738094cc42..5c17dc4cb6a 100644 --- a/specifyweb/backend/stored_queries/execution.py +++ b/specifyweb/backend/stored_queries/execution.py @@ -35,6 +35,7 @@ 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__) @@ -80,6 +81,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 @@ -908,6 +922,7 @@ def build_query( 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 @@ -950,8 +965,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) @@ -1025,9 +1040,13 @@ def build_query( query = group_by_displayed_fields(query, selected_fields) if props.search_synonymy: - log_sqlalchemy_query(query.query) - synonymized_query = synonymize_tree_query(query.query, table) - query = query._replace(query=synonymized_query) + 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/frontend/js_src/lib/components/Atoms/Form.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/Form.tsx index 0c1f6b47fb7..0c925b1eb2a 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/Form.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/Form.tsx @@ -354,8 +354,6 @@ export const Select = wrap< */ if (props.required !== true && props.multiple === true) { selected.map((option) => option.classList.add('dark:bg-neutral-500')); // Highlights selected object less bright - unselected.map( - (option) => option.classList.remove('dark:bg-neutral-500') // Prevents a previously selected option from remaining highlighted unselected.map((option) => option.classList.remove('dark:bg-neutral-500') // Prevents a previously selected option from remaining highlighted ); From 4706528ff77173934bebec028faf7c82fc06d299 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 6 Feb 2026 10:15:02 -0600 Subject: [PATCH 18/24] Synonyms return count correction --- specifyweb/backend/stored_queries/queryfieldspec.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 84e6b44577b0c85069b430e7547cf78038d73349 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 19 Feb 2026 20:36:57 +0000 Subject: [PATCH 19/24] Lint code with ESLint and Prettier Triggered by defac6a5a8cf8bded19bf2615f2e536c497cbbe2 on branch refs/heads/issue-752 --- .../js_src/lib/components/QueryBuilder/Results.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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)); From 9db7a8036c75de34f6cde1507e6c5ba33efe0d1a Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 20 Feb 2026 15:11:44 -0600 Subject: [PATCH 20/24] Fix synonym query expansion for accepted/synonym terms --- .../backend/stored_queries/execution.py | 2 +- specifyweb/backend/stored_queries/synonomy.py | 32 +++++++++++-------- .../lib/components/QueryBuilder/Wrapped.tsx | 7 ++-- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/specifyweb/backend/stored_queries/execution.py b/specifyweb/backend/stored_queries/execution.py index c1226612a20..e20f4f8b61d 100644 --- a/specifyweb/backend/stored_queries/execution.py +++ b/specifyweb/backend/stored_queries/execution.py @@ -601,7 +601,7 @@ def run_ephemeral_query(collection, user, spquery): recordsetid = spquery.get("recordsetid", None) distinct = spquery["selectdistinct"] series = spquery.get('smushed', None) - search_synonymy = spquery['searchsynonymy'] + search_synonymy = bool(spquery.get("searchsynonymy", False)) tableid = spquery["contexttableid"] count_only = spquery["countonly"] format_audits = spquery.get("formatauditrecids", False) diff --git a/specifyweb/backend/stored_queries/synonomy.py b/specifyweb/backend/stored_queries/synonomy.py index c52e8a7e347..7beca08854d 100644 --- a/specifyweb/backend/stored_queries/synonomy.py +++ b/specifyweb/backend/stored_queries/synonomy.py @@ -1,5 +1,5 @@ from typing import Optional, Tuple, List -from sqlalchemy import select, union +from sqlalchemy import select, union, func from sqlalchemy.sql import Select from sqlalchemy.orm import Query from sqlalchemy.sql.selectable import FromClause, Alias, Join @@ -17,13 +17,14 @@ def synonymize_tree_query( expand_from_accepted = True (default) - Start from the records that match the original predicate. - - Then include all synonyms whose AcceptedID points to those records. + - 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) - root_ids := SELECT id_col FROM target_taxon - syn_ids := SELECT id_col FROM tree WHERE AcceptedID IN (root_ids) - ids := root_ids UNION syn_ids + 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. @@ -58,18 +59,21 @@ def synonymize_tree_query( ) if expand_from_accepted: - # root_ids: the records that actually matched the original predicate - root_ids = select(target_taxon_cte.c.TaxonID.label("id")) - - # syn_ids: any record whose AcceptedID points at one of those root_ids - # Use the underlying tree table (not the alias) so we don't bring over the original WHERE. + # 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(target_taxon_cte.c.TaxonID) - ) + taxon_table.c.AcceptedID.in_(select(accepted_roots.c.id)) ) - ids = union(root_ids, syn_ids).subquery("ids") + ids = union(select(accepted_roots.c.id), syn_ids).subquery("ids") else: # Subquery to get the relevant ids for synonymy: id_col and AcceptedID diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 18e4b6d6198..3f552d4c272 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -599,12 +599,13 @@ function Wrapped({ setSaveRequired(true); }} onToggleHidden={setShowHiddenFields} - onToggleSearchSynonymy={(): void => + onToggleSearchSynonymy={(): void => { setQuery({ ...query, searchSynonymy: !(query.searchSynonymy ?? false), - }) - } + }); + setSaveRequired(true); + }} onToggleSeries={(): void => { setQuery({ ...query, From be694e9bef8f6a8cfb69efc883fbd1d99e65b594 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 20 Feb 2026 21:15:55 +0000 Subject: [PATCH 21/24] Lint code with ESLint and Prettier Triggered by 9db7a8036c75de34f6cde1507e6c5ba33efe0d1a on branch refs/heads/issue-752 --- .../frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 3f552d4c272..446a7ce0bcb 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -580,8 +580,8 @@ function Wrapped({ /> Date: Mon, 23 Feb 2026 15:14:20 +0000 Subject: [PATCH 22/24] Lint code with ESLint and Prettier Triggered by b9ac633c0f1c52cf060c08005dc9f5490ce87a4f on branch refs/heads/issue-752 --- .../js_src/lib/components/InitialContext/stats.ts | 10 ++++++++-- specifyweb/frontend/js_src/lib/tests/ajax/index.ts | 5 +---- 2 files changed, 9 insertions(+), 6 deletions(-) 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/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] ?? From 0fd24feff22f0b8c245b598eac71132c024e1f53 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 23 Feb 2026 17:05:57 -0600 Subject: [PATCH 23/24] unit test fix --- .../tests/static/co_query_row_plan.py | 7 ++++--- .../tests/static/simple_static_fields.py | 16 +++++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) 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, From dc8f67c0fa752983fd1388938ff95dd35ecd68fe Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 12 Mar 2026 20:22:34 +0000 Subject: [PATCH 24/24] Lint code with ESLint and Prettier Triggered by 2f8e381ffbb419df487deba752e5ba64b6ecb15c on branch refs/heads/issue-752 --- .../Attachments/__tests__/uploadFile.test.ts | 11 +++++++---- .../lib/components/FormSliders/RecordSelector.tsx | 7 ++++--- .../lib/components/WbImportAttachments/index.tsx | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/uploadFile.test.ts b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/uploadFile.test.ts index 8eddbdf7907..7439350e268 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/uploadFile.test.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/uploadFile.test.ts @@ -31,12 +31,15 @@ describe('uploadFile', () => { return { open: jest.fn(), send: jest.fn((..._args: readonly unknown[]) => listeners[nextEvent]?.()), - addEventListener: jest.fn((eventName: EventName, callback: () => void) => { - listeners[eventName] = callback; - }), + addEventListener: jest.fn( + (eventName: EventName, callback: () => void) => { + listeners[eventName] = callback; + } + ), removeEventListener: jest.fn( (eventName: EventName, callback: () => void) => { - if (listeners[eventName] === callback) listeners[eventName] = undefined; + if (listeners[eventName] === callback) + listeners[eventName] = undefined; } ), upload: { diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx index 8c203cfc15e..ea57737d30d 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx @@ -84,9 +84,10 @@ export function useRecordSelector({ [index] ); - const isToOne = field === undefined - ? false - : !relationshipIsToMany(field) || shouldBeToOne(field); + const isToOne = + field === undefined + ? false + : !relationshipIsToMany(field) || shouldBeToOne(field); const handleResourcesSelected = React.useMemo( () => diff --git a/specifyweb/frontend/js_src/lib/components/WbImportAttachments/index.tsx b/specifyweb/frontend/js_src/lib/components/WbImportAttachments/index.tsx index aeee2eb53b1..4ef8a49f3c2 100644 --- a/specifyweb/frontend/js_src/lib/components/WbImportAttachments/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbImportAttachments/index.tsx @@ -138,7 +138,7 @@ function FilesPicked({ files }: { readonly files: RA }): JSX.Element { setFileUploadProgress(0); return Promise.resolve() - .then(() => + .then(async () => Promise.all( uploadFiles(files, setFileUploadProgress, attachmentIsPublicDefault) )