Skip to content

Commit 085ae59

Browse files
authored
Merge branch 'main' into issue-7262
2 parents c8ab553 + 6e3aa6d commit 085ae59

File tree

4 files changed

+45
-17
lines changed

4 files changed

+45
-17
lines changed

specifyweb/backend/stored_queries/execution.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,24 @@ class QuerySort:
5151
def by_id(sort_id: QUREYFIELD_SORT_T):
5252
return QuerySort.SORT_TYPES[sort_id]
5353

54+
55+
def DefaultQueryFormatterProps():
56+
return ObjectFormatterProps(
57+
format_agent_type=False,
58+
format_picklist=False,
59+
format_types=True,
60+
numeric_catalog_number=True,
61+
format_expr=True
62+
)
63+
5464
class BuildQueryProps(NamedTuple):
5565
recordsetid: int | None = None
5666
replace_nulls: bool = False
5767
formatauditobjs: bool = False
5868
distinct: bool = False
5969
series: bool = False
6070
implicit_or: bool = True
61-
formatter_props: ObjectFormatterProps = ObjectFormatterProps(
62-
format_agent_type = False,
63-
format_picklist = False,
64-
format_types = True,
65-
numeric_catalog_number = True,
66-
format_expr = True,
67-
)
71+
formatter_props: ObjectFormatterProps = DefaultQueryFormatterProps()
6872

6973

7074
def set_group_concat_max_len(connection):
@@ -789,10 +793,13 @@ def execute(
789793
offset,
790794
recordsetid=None,
791795
formatauditobjs=False,
792-
formatter_props=ObjectFormatterProps(),
796+
formatter_props=None,
793797
):
794798
"Build and execute a query, returning the results as a data structure for json serialization"
795799

800+
if formatter_props is None:
801+
formatter_props = DefaultQueryFormatterProps()
802+
796803
set_group_concat_max_len(session.info["connection"])
797804
query, order_by_exprs = build_query(
798805
session,

specifyweb/backend/stored_queries/format.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,10 @@
4141
Spauditlog_model = datamodel.get_table('SpAuditLog')
4242

4343
class ObjectFormatterProps(NamedTuple):
44-
format_agent_type: bool = False,
45-
format_picklist: bool = False,
46-
format_types: bool = True,
47-
numeric_catalog_number: bool = True,
44+
format_agent_type: bool = False
45+
format_picklist: bool = False
46+
format_types: bool = True
47+
numeric_catalog_number: bool = True
4848
# format_expr determines if make_expr should call _fieldformat, like in versions before 7.10.2.
4949
# Batch edit expects it to be false to correctly handle some edge cases.
5050
format_expr: bool = False

specifyweb/backend/stored_queries/queryfieldspec.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,28 @@ def get_workbench_name(self):
161161
# Treedef id included to make it easier to pass it to batch edit
162162
return f"{self.treedef_name}{RANK_KEY_DELIMITER}{self.name}{RANK_KEY_DELIMITER}{self.treedef_id}"
163163

164+
def null_safe_not(field_expr, predicate):
165+
166+
"""Return a NOT clause that still matches NULL values on the target field.
167+
168+
SQL's ``NOT IN`` and similar predicates exclude rows where the filtered column
169+
is ``NULL``. Historical Specify 6 behaviour (and user expectation) is to keep
170+
those "empty" rows when a negated filter is applied. This helper wraps the
171+
negated predicate in an OR that explicitly re-includes NULL rows for the
172+
relevant field expression.
173+
174+
"""
175+
if predicate is None or isinstance(predicate, Query):
176+
return predicate
177+
target = field_expr if field_expr is not None else getattr(predicate, "left", None)
178+
if target is None:
179+
return sql.not_(predicate)
180+
return sql.or_(target.is_(None), sql.not_(predicate))
181+
164182

165183
QueryNode = Field | Relationship | TreeRankQuery
166184
FieldSpecJoinPath = tuple[QueryNode]
167185

168-
169186
class QueryFieldSpec(
170187
namedtuple(
171188
"QueryFieldSpec",
@@ -383,6 +400,7 @@ def apply_filter(
383400

384401
query_op = QueryOps(uiformatter)
385402
op = query_op.by_op_num(op_num)
403+
mod_orm_field = orm_field
386404
if query_op.is_precalculated(op_num):
387405
f = op(
388406
orm_field, value, query, is_strict=strict
@@ -399,7 +417,10 @@ def apply_filter(
399417
op, mod_orm_field, value = apply_special_filter_cases(orm_field, field, table, value, op, op_num, uiformatter, collection, user)
400418
f = op(mod_orm_field, value)
401419

402-
predicate = sql.not_(f) if negate else f
420+
if negate:
421+
predicate = null_safe_not(mod_orm_field or orm_field, f)
422+
else:
423+
predicate = f
403424
else:
404425
predicate = None
405426

specifyweb/frontend/js_src/lib/localization/preferences.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2091,13 +2091,13 @@ export const preferencesText = createDictionary({
20912091
'en-us':
20922092
'Catalog Number field need to be unique across Component and CO tables',
20932093
'de-ch':
2094-
'Das Feld „Katalognummer“ muss in allen Komponenten- und CO-Tabellen eindeutig sein',
2094+
'Das Feld „Katalognummer“ muss in allen Komponenten- und CO-Tabellen eindeutig sein.',
20952095
'es-es':
2096-
'El campo Número de catálogo debe ser único en las tablas de componentes y CO',
2096+
'El campo Número de catálogo debe ser único en las tablas de componentes y CO.',
20972097
'fr-fr':
20982098
'Le champ Numéro de catalogue doit être unique dans les tables Composant et CO',
20992099
'pt-br':
2100-
'O campo Número de catálogo precisa ser exclusivo nas tabelas Componente e CO',
2100+
'O campo Número de Catálogo precisa ser único em todas as tabelas de Componente e CO.',
21012101
'ru-ru':
21022102
'Поле «Номер каталога» должно быть уникальным в таблицах «Компонент» и «CO».',
21032103
'uk-ua':

0 commit comments

Comments
 (0)