import log4js from 'log4js'; import mongoose from 'mongoose'; import config from '../config.js'; import { listObjects } from './database.js'; const logger = log4js.getLogger('DatabaseOData'); logger.level = config.server.logLevel; const EXCLUDED_PATHS = ['appPasswordHash', 'secret', '__v']; /** Check if path is an embedded object (has nested schema, not ObjectId ref). Excludes arrays. */ function isEmbeddedObject(path) { if (!path) return false; if (path.options?.type === mongoose.Schema.Types.ObjectId) return false; if (path.instance === 'Array') return false; return path.schema != null || path.caster?.schema != null; } /** Get all top-level property keys declared in OData metadata for a schema. */ function getDeclaredPropertyKeys(schema) { if (!schema?.paths) return []; return Object.keys(schema.paths) .filter((k) => !k.includes('.')) .filter((k) => !EXCLUDED_PATHS.includes(k)) .filter((k) => schema.paths[k]?.options?.select !== false); } /** * Ensure all declared properties are present in each document. OData clients (e.g. Excel) * expect every metadata property to exist; omit none. Use null for missing/invalid values. */ function normalizeODataResponse(data, schema) { const declaredKeys = getDeclaredPropertyKeys(schema); if (declaredKeys.length === 0) return data; function normalize(doc) { if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return doc; const result = { ...doc }; for (const key of declaredKeys) { const val = result[key]; if (isEmbeddedObject(schema.paths[key])) { // ComplexType: object or null only const isValid = val != null && typeof val === 'object' && !Array.isArray(val); result[key] = isValid ? val : null; } else { // Primitive, array, etc.: value or null (never omit) result[key] = val !== undefined && val !== null ? val : null; } } return result; } return Array.isArray(data) ? data.map(normalize) : normalize(data); } /** * Convert expanded ObjectId refs ({ _id: x }) back to primitive strings for OData. * OData metadata declares refs as Edm.String; clients expect primitives, not objects. */ function compactObjectIdsForOData(data) { function isExpandedRef(val) { return val && typeof val === 'object' && !Array.isArray(val) && Object.keys(val).length === 1 && '_id' in val; } function compact(val) { if (Array.isArray(val)) return val.map(compact); if (isExpandedRef(val)) return val._id?.toString?.() ?? val._id; if (val instanceof Date || val instanceof Buffer) return val; // pass through primitives if (val && typeof val === 'object') { const result = {}; for (const [k, v] of Object.entries(val)) result[k] = compact(v); return result; } return val; } return Array.isArray(data) ? data.map(compact) : compact(data); } /** * Translate filter keys ending with ._id for Mongoose compatibility * @param {Object} filter - Filter object (will be mutated) */ function translateFilterKeys(filter) { Object.keys(filter).forEach((key) => { if (key.endsWith('._id')) { const baseKey = key.slice(0, -4); filter[baseKey] = filter[key]; delete filter[key]; } }); } /** * List objects with OData-compatible response format. * Works like listObjects but returns { @odata.context, @odata.count?, value }. * * @param {Object} options - Same options as listObjects, plus: * @param {boolean} [options.count=false] - When true, include @odata.count (total before $skip/$top) * @returns {Promise} OData-formatted response or { error, code } on failure */ export const listObjectsOData = async ({ model, populate = [], page = 1, limit = 25, filter = {}, sort = '', order = 'ascend', pagination = true, project, count = false, }) => { try { const filterCopy = JSON.parse(JSON.stringify(filter)); translateFilterKeys(filterCopy); const [data, total] = await Promise.all([ listObjects({ model, populate, page, limit, filter: filterCopy, sort, order, pagination, project, }), count ? model.countDocuments(filterCopy) : Promise.resolve(undefined), ]); if (data?.error) { return data; } // Strip __v from each document (Mongoose version key - causes OData client issues) const sanitizedData = Array.isArray(data) ? data.map(({ __v, ...doc }) => doc) : data; // Convert expanded ObjectId refs ({ _id: x }) to primitive strings - OData clients expect Edm.String let odataData = compactObjectIdsForOData(sanitizedData); // Ensure all declared properties are present; use null for missing values (Excel requires this) odataData = normalizeODataResponse(odataData, model.schema); const baseUrl = config.app?.urlApi || ''; const modelName = model.modelName; const context = `${baseUrl}/odata/$metadata#${modelName}`; const response = { '@odata.context': context, value: odataData, }; if (count && total !== undefined) { response['@odata.count'] = total; } return response; } catch (error) { logger.error('OData list error:', error); return { error, code: 500 }; } };