UNPKG

@decaf-ts/for-couchdb

Version:
448 lines 18.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.normalizeSortField = normalizeSortField; exports.getSortFields = getSortFields; exports.getSortDirection = getSortDirection; exports.getEqualitySelectorFields = getEqualitySelectorFields; exports.getRangeSelectorFields = getRangeSelectorFields; exports.buildGeneratedIndexCandidates = buildGeneratedIndexCandidates; exports.findGeneratedIndexCandidateByName = findGeneratedIndexCandidateByName; exports.resolveGeneratedIndexForQuery = resolveGeneratedIndexForQuery; exports.getRequiredMangoIndexShape = getRequiredMangoIndexShape; exports.reverseRequiredShapeToIndexDeclaration = reverseRequiredShapeToIndexDeclaration; exports.attachGeneratedUseIndex = attachGeneratedUseIndex; exports.requireGeneratedUseIndex = requireGeneratedUseIndex; exports.warnScanProneMangoOperators = warnScanProneMangoOperators; exports.ensureDeterministicSort = ensureDeterministicSort; exports.enableMangoExecutionStats = enableMangoExecutionStats; const core_1 = require("@decaf-ts/core"); const decorator_validation_1 = require("@decaf-ts/decorator-validation"); const constants_js_1 = require("./../constants.cjs"); const constants_js_2 = require("./constants.cjs"); const generator_js_1 = require("./../indexes/generator.cjs"); const IndexPlanningError_js_1 = require("./../errors/IndexPlanningError.cjs"); const SCAN_PRONE_OPERATORS = new Set([ "$or", "$in", "$regex", "$nin", "$not", "$ne", "$nor", ]); function isPlainObject(value) { return !!value && typeof value === "object" && !Array.isArray(value); } function selectorContainsOperator(node, operator) { if (!node || typeof node !== "object") return false; if (Array.isArray(node)) { return node.some((child) => selectorContainsOperator(child, operator)); } return Object.entries(node).some(([key, value]) => key === operator || selectorContainsOperator(value, operator)); } function normalizeSortField(entry) { if (typeof entry === "string") { return { field: entry }; } if (Array.isArray(entry)) { return entry.length ? { field: String(entry[0]) } : undefined; } const [field, direction] = Object.entries(entry || {})[0] || []; if (!field) return undefined; return { field, direction: String(direction || core_1.OrderDirection.ASC).toLowerCase(), }; } function getSortFields(query) { return (query.sort || []) .map(normalizeSortField) .filter(Boolean) .map((entry) => entry.field); } function getSortDirection(query) { return (query.sort || []) .map(normalizeSortField) .find(Boolean)?.direction; } function getEqualitySelectorFields(selector = {}) { const fields = new Set(); const visit = (node) => { if (!node || typeof node !== "object") return; if (Array.isArray(node)) { node.forEach(visit); return; } if (Array.isArray(node[constants_js_2.CouchDBGroupOperator.AND])) { node[constants_js_2.CouchDBGroupOperator.AND].forEach(visit); return; } for (const [key, value] of Object.entries(node)) { if (key === constants_js_2.CouchDBGroupOperator.OR || key === constants_js_2.CouchDBGroupOperator.AND) { visit(value); continue; } if (!isPlainObject(value)) { fields.add(key); continue; } if (Object.prototype.hasOwnProperty.call(value, constants_js_2.CouchDBOperator.EQUAL)) { fields.add(key); } } }; visit(selector); return fields; } function getRangeSelectorFields(selector = {}) { const fields = new Set(); const visit = (node) => { if (!node || typeof node !== "object") return; if (Array.isArray(node)) { node.forEach(visit); return; } if (Array.isArray(node[constants_js_2.CouchDBGroupOperator.AND])) { node[constants_js_2.CouchDBGroupOperator.AND].forEach(visit); return; } for (const [key, value] of Object.entries(node)) { if (key === constants_js_2.CouchDBGroupOperator.OR || key === constants_js_2.CouchDBGroupOperator.AND) { visit(value); continue; } if (!isPlainObject(value)) continue; if (Object.prototype.hasOwnProperty.call(value, constants_js_2.CouchDBOperator.BIGGER) || Object.prototype.hasOwnProperty.call(value, constants_js_2.CouchDBOperator.BIGGER_EQ) || Object.prototype.hasOwnProperty.call(value, constants_js_2.CouchDBOperator.SMALLER) || Object.prototype.hasOwnProperty.call(value, constants_js_2.CouchDBOperator.SMALLER_EQ)) { fields.add(key); } } }; visit(selector); return fields; } function collectGeneratedIndexEntries(clazz) { const tableName = decorator_validation_1.Model.tableName(clazz); const candidates = []; const tableIndexName = (0, generator_js_1.generateIndexName)([constants_js_1.CouchDBKeys.TABLE]); candidates.push({ name: tableIndexName, ddoc: tableIndexName, fields: [constants_js_1.CouchDBKeys.TABLE], source: "table", }); let defaultQueryAttrs = []; try { defaultQueryAttrs = decorator_validation_1.Model.defaultQueryAttributes(clazz); } catch { defaultQueryAttrs = []; } for (const attr of defaultQueryAttrs) { const baseName = (0, generator_js_1.generateIndexName)([tableName, attr, "defaultQuery"]); candidates.push({ name: baseName, ddoc: baseName, fields: [constants_js_1.CouchDBKeys.TABLE, attr], source: "defaultQuery", }); for (const direction of [core_1.OrderDirection.ASC, core_1.OrderDirection.DSC]) { const sortedName = (0, generator_js_1.generateIndexName)([tableName, attr, "defaultQuery"], direction); candidates.push({ name: sortedName, ddoc: sortedName, fields: [constants_js_1.CouchDBKeys.TABLE, attr], direction, source: "defaultQuery", }); } } const modelIndexes = decorator_validation_1.Model.indexes(clazz); Object.entries(modelIndexes || {}).forEach(([fieldKey, value]) => { Object.values(value || {}).forEach((metadataValue) => { if (!metadataValue || !isPlainObject(metadataValue)) return; const meta = metadataValue; const compositions = meta.compositions || []; const directions = meta.directions || [core_1.OrderDirection.ASC]; const fields = [constants_js_1.CouchDBKeys.TABLE, fieldKey, ...compositions]; const baseName = (0, generator_js_1.generateModelIndexName)(tableName, fieldKey, compositions); candidates.push({ name: baseName, ddoc: baseName, fields, source: "index", }); const validDirections = Array.from(new Set(directions.map((dir) => String(dir).toLowerCase()))).filter((dir) => dir === core_1.OrderDirection.ASC || dir === core_1.OrderDirection.DSC); for (const direction of validDirections) { const sortedName = (0, generator_js_1.generateModelIndexName)(tableName, fieldKey, compositions, direction); candidates.push({ name: sortedName, ddoc: sortedName, fields, direction, source: "index", }); } }); }); return candidates; } function buildGeneratedIndexCandidates(clazz) { return collectGeneratedIndexEntries(clazz); } function findGeneratedIndexCandidateByName(clazz, expectedName) { return collectGeneratedIndexEntries(clazz).find((candidate) => candidate.name === expectedName || candidate.ddoc === expectedName); } function resolveGeneratedIndexForQuery(clazz, query, options = {}) { if (query.use_index) return undefined; // A single named index cannot be proven valid for selectors combining // branches via $or/$nor, since CouchDB only treats an index as usable when // its leading fields constrain every branch of the selector. Forcing a // use_index hint here causes CouchDB to reject it as "not a valid index for // this query" - so let CouchDB pick (or fall back) on its own instead. if (selectorContainsOperator(query.selector, constants_js_2.CouchDBGroupOperator.OR) || selectorContainsOperator(query.selector, "$nor")) { return undefined; } const preserveDefaultQuery = options.preserveDefaultQuery !== false; const candidates = collectGeneratedIndexEntries(clazz); const equalityFields = getEqualitySelectorFields(query.selector); const rangeFields = getRangeSelectorFields(query.selector); const sortFields = getSortFields(query); const sortDirection = getSortDirection(query); equalityFields.add(constants_js_1.CouchDBKeys.TABLE); const scored = candidates .map((candidate) => { if (candidate.direction && sortDirection && candidate.direction !== sortDirection) { return undefined; } if (!candidate.fields.includes(constants_js_1.CouchDBKeys.TABLE)) return undefined; if (candidate.fields[0] !== constants_js_1.CouchDBKeys.TABLE) return undefined; const candidateFields = candidate.fields.filter((field) => field !== constants_js_1.CouchDBKeys.TABLE); const coversAllSort = sortFields.length === 0 || sortFields.every((field) => candidateFields.includes(field)); const coversSomeSort = sortFields.some((field) => candidateFields.includes(field)); const coversAllRange = rangeFields.size === 0 || Array.from(rangeFields).every((field) => candidateFields.includes(field)); const coversSomeRange = Array.from(rangeFields).some((field) => candidateFields.includes(field)); if (options.requireSortCoverage && sortFields.length && !coversAllSort) { return undefined; } const equalityCoverage = candidateFields.filter((field) => equalityFields.has(field)).length; const rangeCoverage = candidateFields.filter((field) => rangeFields.has(field)).length; const sortCoverage = candidateFields.filter((field) => sortFields.includes(field)).length; if (!equalityCoverage && !rangeCoverage && !sortCoverage) { return undefined; } let score = 0; if (candidate.source === "defaultQuery" && preserveDefaultQuery) { score += 100; } if (candidate.source === "index") score += 50; score += equalityCoverage * 30; score += rangeCoverage * 25; score += sortCoverage * 20; if (coversAllSort && sortFields.length) score += 20; if (coversAllRange && rangeFields.size) score += 20; if (coversSomeSort) score += 5; if (coversSomeRange) score += 5; if (candidate.direction && sortDirection) score += 10; return { candidate, score }; }) .filter(Boolean); if (!scored.length) return undefined; scored.sort((a, b) => { if (b.score !== a.score) return b.score - a.score; return a.candidate.fields.length - b.candidate.fields.length; }); const best = scored[0].candidate; return { candidate: best, use_index: [best.ddoc, best.name], }; } function getRequiredMangoIndexShape(clazz, query) { const tableName = decorator_validation_1.Model.tableName(clazz); const modelName = clazz.name; const equalityFields = Array.from(getEqualitySelectorFields(query.selector)); const rangeFields = Array.from(getRangeSelectorFields(query.selector)); const sortFields = getSortFields(query); const sortDirection = getSortDirection(query); const requiredFields = [constants_js_1.CouchDBKeys.TABLE]; for (const field of equalityFields) { if (field !== constants_js_1.CouchDBKeys.TABLE && !requiredFields.includes(field)) { requiredFields.push(field); } } for (const field of rangeFields) { if (field !== constants_js_1.CouchDBKeys.TABLE && !requiredFields.includes(field)) { requiredFields.push(field); } } for (const field of sortFields) { if (field !== constants_js_1.CouchDBKeys.TABLE && !requiredFields.includes(field)) { requiredFields.push(field); } } return { tableName, modelName, equalityFields, rangeFields, sortFields, sortDirection, requiredFields, }; } function reverseRequiredShapeToIndexDeclaration(clazz, query) { const shape = getRequiredMangoIndexShape(clazz, query); const nonTableFields = shape.requiredFields.filter((field) => field !== constants_js_1.CouchDBKeys.TABLE); if (!nonTableFields.length) { throw new IndexPlanningError_js_1.IndexPlanningError("Cannot infer @index declaration for query with no model fields.", { modelName: shape.modelName, tableName: shape.tableName, attribute: "", compositions: [], directions: [], expectedName: "", expectedDdoc: "", expectedFields: [constants_js_1.CouchDBKeys.TABLE], decorator: "", }, query); } const attribute = nonTableFields[0]; const compositions = nonTableFields.slice(1); const direction = shape.sortDirection || core_1.OrderDirection.ASC; const name = (0, generator_js_1.generateModelIndexName)(shape.tableName, attribute, compositions, direction); const fields = [ { [constants_js_1.CouchDBKeys.TABLE]: direction }, { [attribute]: direction }, ...compositions.map((field) => ({ [field]: direction })), ]; const decorator = [ "@index({", compositions.length ? ` compositions: ${JSON.stringify(compositions)},` : undefined, ` directions: ${JSON.stringify([direction])}`, "})", `${attribute}!: <type>;`, ] .filter(Boolean) .join("\n"); return { attribute, compositions, directions: [direction], name, ddoc: name, fields, decorator, }; } function attachGeneratedUseIndex(clazz, query, log, options = {}) { if (!clazz || query.use_index) return; if (options.forceNamedIndexes === false && !options.requireIndex) { return; } const resolved = resolveGeneratedIndexForQuery(clazz, query, { preserveDefaultQuery: options.preserveDefaultQuery ?? true, requireSortCoverage: options.requireSortCoverage ?? false, }); if (resolved) { query.use_index = resolved.use_index; log?.debug?.(`Attached Mango use_index=${JSON.stringify(resolved.use_index)} from ${resolved.candidate.source} metadata`); return; } if (!options.requireIndex) return; const reversed = reverseRequiredShapeToIndexDeclaration(clazz, query); throw new IndexPlanningError_js_1.IndexPlanningError([ "No generated CouchDB index can satisfy this Mango query.", "", `Model: ${clazz.name}`, `Table: ${shapeTableName(clazz)}`, "", "To allow this query, declare the following index on the model attribute:", "", reversed.decorator, "", "Then regenerate/deploy indexes.", "", "Expected generated index:", ` ddoc: ${reversed.ddoc}`, ` name: ${reversed.name}`, ` fields: ${JSON.stringify(reversed.fields)}`, ].join("\n"), { modelName: clazz.name, tableName: shapeTableName(clazz), attribute: reversed.attribute, compositions: reversed.compositions, directions: reversed.directions, expectedName: reversed.name, expectedDdoc: reversed.ddoc, expectedFields: reversed.fields, decorator: reversed.decorator, }, query); } function shapeTableName(clazz) { return decorator_validation_1.Model.tableName(clazz); } function requireGeneratedUseIndex(clazz, query, log, options = {}) { attachGeneratedUseIndex(clazz, query, log, { preserveDefaultQuery: true, requireSortCoverage: options.requireSortCoverage ?? true, requireIndex: true, forceNamedIndexes: true, }); } function warnScanProneMangoOperators(selector, log, path = "selector") { if (!selector || typeof selector !== "object") return; if (Array.isArray(selector)) { selector.forEach((entry, index) => warnScanProneMangoOperators(entry, log, `${path}[${index}]`)); return; } for (const [key, value] of Object.entries(selector)) { if (SCAN_PRONE_OPERATORS.has(key)) { log?.warn?.(`Mango query contains scan-prone operator ${key} at ${path}. This may bypass or weaken index usage and can produce slow queries.`); } warnScanProneMangoOperators(value, log, `${path}.${key}`); } } function ensureDeterministicSort(query, tieBreaker, direction) { if (!query.sort?.length) return; const sortFields = getSortFields(query); if (sortFields.includes(tieBreaker)) return; const effectiveDirection = direction || getSortDirection(query) || core_1.OrderDirection.ASC; query.sort = [...query.sort, { [tieBreaker]: effectiveDirection }]; } function enableMangoExecutionStats(query) { query.execution_stats = true; } //# sourceMappingURL=planner.js.map //# sourceMappingURL=planner.cjs.map