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)
)