Refactor and enhance group handling in database utilities
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good

This commit introduces several utility functions to improve the handling of group values in the `nestGroups` function. It adds checks for empty group values and refines the logic for extracting stable keys and filter values. The `nestGroups` function is updated to utilize a `Map` for better performance and clarity. Additionally, tests are added to ensure correct behavior when filtering groups, including handling of explicit null values for missing references. These changes enhance the robustness and maintainability of the database utilities.
This commit is contained in:
Tom Butcher 2026-11-08 23:55:06 +00:00
parent 964e95019d
commit f15ba0e489
2 changed files with 128 additions and 28 deletions

View File

@ -73,7 +73,7 @@ jest.unstable_mockModule('../schemas/management/file.schema.js', () => ({
}));
// Now import the database utilities
const { listObjects, getObject, newObject, editObject, deleteObject } =
const { listObjects, getObject, newObject, editObject, deleteObject, nestGroups } =
await import('../database.js');
const { deleteAuditLog, deleteNotification, editAuditLog, editNotification, newAuditLog } =
await import('../../utils.js');
@ -303,3 +303,70 @@ describe('Database Utilities (CRUD)', () => {
});
});
});
describe('nestGroups', () => {
const filament = { _id: '691a2d22d69f3e4be70a1347', name: 'CC3D PETG' };
const sku = { _id: '69adc07ed99b07c037815af0', name: 'Orange' };
const selected = { _id: 'file-1', name: 'Clothes Peg' };
const groups = [
{
_id: { filament, filamentSku: sku },
objects: [selected],
},
{
_id: { filament: null, filamentSku: { _id: 'sku-empty', name: 'Blank' } },
objects: [{ _id: 'file-2', name: 'No filament' }],
},
{
_id: { filament: undefined, filamentSku: null },
objects: [{ _id: 'file-3', name: 'Missing filament' }],
},
];
test('keeps an explicit null value for missing references', () => {
const result = nestGroups(groups, ['filament', 'filamentSku'], {});
const empty = result.find((group) => group.value == null);
expect(empty).toEqual(
expect.objectContaining({
property: 'filament',
value: null,
children: [],
})
);
expect(Object.prototype.hasOwnProperty.call(empty, 'value')).toBe(true);
expect(result.filter((group) => group.value == null)).toHaveLength(1);
});
test('nests the selected item and the empty group when filtered', () => {
const selectedTree = nestGroups(groups, ['filament', 'filamentSku'], {
filament: filament._id,
filamentSku: sku._id,
});
const selectedFilament = selectedTree.find((group) => group.value?._id === filament._id);
expect(selectedFilament.children).toEqual([
expect.objectContaining({
property: 'filamentSku',
value: sku,
children: [selected],
}),
]);
const emptyTree = nestGroups(groups, ['filament', 'filamentSku'], {
filament: '__null__',
filamentSku: '__null__',
});
const emptyFilament = emptyTree.find((group) => group.value == null);
const emptySku = emptyFilament.children.find((group) => group.value == null);
expect(emptySku).toEqual(
expect.objectContaining({
property: 'filamentSku',
children: [{ _id: 'file-3', name: 'Missing filament' }],
})
);
expect(emptySku.value).toBeNull();
});
});

View File

@ -891,46 +891,79 @@ const getPropertyValuesUnsafe = async ({ model, property, filter = {} }) => {
return result.map((item) => expandObjectIds(item));
};
// Sentinel for a missing reference (null / undefined). Object keys cannot hold
// null, and JSON drops undefined, so an empty group has to be explicit.
const EMPTY_GROUP_TOKEN = '__null__';
function isEmptyGroupValue(value) {
return value == null || value === '' || value === EMPTY_GROUP_TOKEN;
}
// Helper to extract a stable group key and the values a filter may match.
function getKeyAndFilterVals(value) {
if (isEmptyGroupValue(value)) {
return { key: EMPTY_GROUP_TOKEN, filterVals: [null] };
}
if (typeof value === 'object') {
if (value.name) {
return {
key: value.name,
filterVals: [value._id?.toString?.(), value.name].filter((val) => val != null && val !== ''),
};
}
if (value._id != null && value._id !== '') {
const id = value._id.toString();
return { key: id, filterVals: [id] };
}
if (typeof value.toHexString === 'function') {
const id = value.toHexString();
return { key: id, filterVals: [id] };
}
}
return { key: String(value), filterVals: [value] };
}
function groupValueMatchesFilter(filterValue, rawValue) {
if (isEmptyGroupValue(filterValue)) return isEmptyGroupValue(rawValue);
const expected = String(filterValue);
const { filterVals } = getKeyAndFilterVals(rawValue);
return filterVals.some((val) => val != null && String(val) === expected);
}
function groupNodeValue(value) {
const expanded = expandObjectIds(value ?? null);
return expanded == null ? null : expanded;
}
// Helper to build nested structure for listObjectsByProperty
function nestGroups(groups, props, filter, idx = 0) {
export function nestGroups(groups, props, filter, idx = 0) {
if (idx >= props.length) return groups;
const prop = props[idx];
const filterPresent = Object.prototype.hasOwnProperty.call(filter, prop);
// Helper to extract a display key and possible filter values from a value
function getKeyAndFilterVals(value) {
if (value && typeof value === 'object') {
if (value.name) return { key: value.name, filterVals: [value._id?.toString?.(), value.name] };
if (value._id) return { key: value._id.toString(), filterVals: [value._id.toString()] };
}
return { key: value, filterVals: [value] };
}
// Build a map of key -> groups for this property
const keyToGroups = {};
const keyToGroups = new Map();
for (const group of groups) {
const val = group._id[prop];
const val = group._id?.[prop];
const { key } = getKeyAndFilterVals(val);
if (!keyToGroups[key]) {
keyToGroups[key] = {
if (!keyToGroups.has(key)) {
keyToGroups.set(key, {
groups: [],
value: val,
};
value: val ?? null,
});
}
keyToGroups[key].groups.push(group);
keyToGroups.get(key).groups.push(group);
}
let result = [];
if (filterPresent) {
const filterValue = filter[prop]?.toString?.() ?? filter[prop];
for (const [key, data] of Object.entries(keyToGroups)) {
for (const data of keyToGroups.values()) {
const { groups: groupList, value } = data;
// Check if any group in this key matches the filter (by _id or name)
const matches = groupList.filter((group) => {
const { filterVals } = getKeyAndFilterVals(group._id[prop]);
return filterVals.some((val) => val?.toString() === filterValue);
});
// Check if any group in this key matches the filter (by _id, name, or empty)
const matches = groupList.filter((group) =>
groupValueMatchesFilter(filter[prop], group._id?.[prop])
);
let children = [];
if (matches.length > 0) {
@ -946,16 +979,16 @@ function nestGroups(groups, props, filter, idx = 0) {
result.push({
property: prop,
value: expandObjectIds(value),
value: groupNodeValue(value),
children: children,
});
}
} else {
// No filter for this property, just show all keys at this level with empty objects
for (const [key, data] of Object.entries(keyToGroups)) {
for (const data of keyToGroups.values()) {
result.push({
property: prop,
value: expandObjectIds(data.value),
value: groupNodeValue(data.value),
children: [],
});
}