diff --git a/specifyweb/backend/stored_queries/queryfieldspec.py b/specifyweb/backend/stored_queries/queryfieldspec.py index e09728719f6..01469e73b8c 100644 --- a/specifyweb/backend/stored_queries/queryfieldspec.py +++ b/specifyweb/backend/stored_queries/queryfieldspec.py @@ -161,11 +161,28 @@ def get_workbench_name(self): # Treedef id included to make it easier to pass it to batch edit return f"{self.treedef_name}{RANK_KEY_DELIMITER}{self.name}{RANK_KEY_DELIMITER}{self.treedef_id}" +def null_safe_not(field_expr, predicate): + + """Return a NOT clause that still matches NULL values on the target field. + + SQL's ``NOT IN`` and similar predicates exclude rows where the filtered column + is ``NULL``. Historical Specify 6 behaviour (and user expectation) is to keep + those "empty" rows when a negated filter is applied. This helper wraps the + negated predicate in an OR that explicitly re-includes NULL rows for the + relevant field expression. + + """ + if predicate is None or isinstance(predicate, Query): + return predicate + target = field_expr if field_expr is not None else getattr(predicate, "left", None) + if target is None: + return sql.not_(predicate) + return sql.or_(target.is_(None), sql.not_(predicate)) + QueryNode = Field | Relationship | TreeRankQuery FieldSpecJoinPath = tuple[QueryNode] - class QueryFieldSpec( namedtuple( "QueryFieldSpec", @@ -383,6 +400,7 @@ def apply_filter( query_op = QueryOps(uiformatter) op = query_op.by_op_num(op_num) + mod_orm_field = orm_field if query_op.is_precalculated(op_num): f = op( orm_field, value, query, is_strict=strict @@ -399,7 +417,10 @@ def apply_filter( op, mod_orm_field, value = apply_special_filter_cases(orm_field, field, table, value, op, op_num, uiformatter, collection, user) f = op(mod_orm_field, value) - predicate = sql.not_(f) if negate else f + if negate: + predicate = null_safe_not(mod_orm_field or orm_field, f) + else: + predicate = f else: predicate = None diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/systemInfo.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/systemInfo.ts index da2414c3854..21518f41335 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/systemInfo.ts +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/systemInfo.ts @@ -44,7 +44,7 @@ function buildStatsLambdaUrl(base: string | null | undefined): string | null { if (!hasRoute) { const stage = 'prod'; const route = 'AggrgatedSp7Stats'; - u = `${u.replace(/\/$/, '') }/${stage}/${route}`; + u = `${u.replace(/\/$/, '')}/${stage}/${route}`; } return u; } @@ -58,7 +58,10 @@ export const fetchContext = load( if (systemInfo.stats_url !== null) { let counts: StatsCounts | null = null; try { - counts = await load('/context/stats_counts.json', 'application/json'); + counts = await load( + '/context/stats_counts.json', + 'application/json' + ); } catch { // If counts fetch fails, proceed without them. counts = null; @@ -102,12 +105,13 @@ export const fetchContext = load( const lambdaUrl = buildStatsLambdaUrl(systemInfo.stats_2_url); if (lambdaUrl) { - await ping(formatUrl(lambdaUrl, parameters, false), { errorMode: 'silent' }) - .catch(softFail); + await ping(formatUrl(lambdaUrl, parameters, false), { + errorMode: 'silent', + }).catch(softFail); } } return systemInfo; }); -export const getSystemInfo = (): SystemInfo => systemInfo; \ No newline at end of file +export const getSystemInfo = (): SystemInfo => systemInfo;