Skip to content
Merged
25 changes: 23 additions & 2 deletions specifyweb/backend/stored_queries/queryfieldspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -58,7 +58,10 @@ export const fetchContext = load<SystemInfo>(
if (systemInfo.stats_url !== null) {
let counts: StatsCounts | null = null;
try {
counts = await load<StatsCounts>('/context/stats_counts.json', 'application/json');
counts = await load<StatsCounts>(
'/context/stats_counts.json',
'application/json'
);
} catch {
// If counts fetch fails, proceed without them.
counts = null;
Expand Down Expand Up @@ -102,12 +105,13 @@ export const fetchContext = load<SystemInfo>(

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;
export const getSystemInfo = (): SystemInfo => systemInfo;