Refactor and enhance group handling in database utilities
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
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:
parent
964e95019d
commit
f15ba0e489
@ -73,7 +73,7 @@ jest.unstable_mockModule('../schemas/management/file.schema.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Now import the database utilities
|
// Now import the database utilities
|
||||||
const { listObjects, getObject, newObject, editObject, deleteObject } =
|
const { listObjects, getObject, newObject, editObject, deleteObject, nestGroups } =
|
||||||
await import('../database.js');
|
await import('../database.js');
|
||||||
const { deleteAuditLog, deleteNotification, editAuditLog, editNotification, newAuditLog } =
|
const { deleteAuditLog, deleteNotification, editAuditLog, editNotification, newAuditLog } =
|
||||||
await import('../../utils.js');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -891,46 +891,79 @@ const getPropertyValuesUnsafe = async ({ model, property, filter = {} }) => {
|
|||||||
return result.map((item) => expandObjectIds(item));
|
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
|
// 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;
|
if (idx >= props.length) return groups;
|
||||||
const prop = props[idx];
|
const prop = props[idx];
|
||||||
const filterPresent = Object.prototype.hasOwnProperty.call(filter, prop);
|
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
|
// Build a map of key -> groups for this property
|
||||||
const keyToGroups = {};
|
const keyToGroups = new Map();
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
const val = group._id[prop];
|
const val = group._id?.[prop];
|
||||||
const { key } = getKeyAndFilterVals(val);
|
const { key } = getKeyAndFilterVals(val);
|
||||||
if (!keyToGroups[key]) {
|
if (!keyToGroups.has(key)) {
|
||||||
keyToGroups[key] = {
|
keyToGroups.set(key, {
|
||||||
groups: [],
|
groups: [],
|
||||||
value: val,
|
value: val ?? null,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
keyToGroups[key].groups.push(group);
|
keyToGroups.get(key).groups.push(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = [];
|
let result = [];
|
||||||
|
|
||||||
if (filterPresent) {
|
if (filterPresent) {
|
||||||
const filterValue = filter[prop]?.toString?.() ?? filter[prop];
|
for (const data of keyToGroups.values()) {
|
||||||
for (const [key, data] of Object.entries(keyToGroups)) {
|
|
||||||
const { groups: groupList, value } = data;
|
const { groups: groupList, value } = data;
|
||||||
// Check if any group in this key matches the filter (by _id or name)
|
// Check if any group in this key matches the filter (by _id, name, or empty)
|
||||||
const matches = groupList.filter((group) => {
|
const matches = groupList.filter((group) =>
|
||||||
const { filterVals } = getKeyAndFilterVals(group._id[prop]);
|
groupValueMatchesFilter(filter[prop], group._id?.[prop])
|
||||||
return filterVals.some((val) => val?.toString() === filterValue);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
let children = [];
|
let children = [];
|
||||||
if (matches.length > 0) {
|
if (matches.length > 0) {
|
||||||
@ -946,16 +979,16 @@ function nestGroups(groups, props, filter, idx = 0) {
|
|||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
property: prop,
|
property: prop,
|
||||||
value: expandObjectIds(value),
|
value: groupNodeValue(value),
|
||||||
children: children,
|
children: children,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No filter for this property, just show all keys at this level with empty objects
|
// 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({
|
result.push({
|
||||||
property: prop,
|
property: prop,
|
||||||
value: expandObjectIds(data.value),
|
value: groupNodeValue(data.value),
|
||||||
children: [],
|
children: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user