UNPKG

@aradox/multi-orm

Version:

Type-safe ORM with multi-datasource support, row-level security, and Prisma-like API for PostgreSQL, SQL Server, and HTTP APIs

489 lines 20.2 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.Stitcher = void 0; const logger_1 = require("../utils/logger"); const computed_executor_1 = require("./computed-executor"); const cache_1 = require("./cache"); const path = __importStar(require("path")); class Stitcher { ir; adapters; includeDepth = 0; computedExecutor = null; queryCache; constructor(ir, adapters, schemaPath, cacheOptions) { this.ir = ir; this.adapters = adapters; this.queryCache = new cache_1.QueryCache(cacheOptions); // Initialize computed executor if schema path is provided if (schemaPath) { const basePath = path.dirname(schemaPath); this.computedExecutor = new computed_executor_1.ComputedExecutor({ basePath, timeout: 5000, throwOnTimeout: false, cacheResolvers: true }); } } async findMany(modelName, args) { const model = this.ir.models[modelName]; if (!model) throw new Error(`Model ${modelName} not found`); // Check cache first const cacheKey = cache_1.QueryCache.generateKey(modelName, 'findMany', args); const cached = this.queryCache.get(cacheKey); if (cached) { logger_1.logger.debug('stitcher', `Returning cached result for ${modelName}.findMany`); return cached; } const options = this.mergeOptions(args.$options); const issues = []; const startTime = Date.now(); try { // Validate limits if (options.limits) { this.validateLimits(args, options.limits); } // Fetch root data const adapter = this.getAdapter(model); logger_1.logger.debug('stitcher', 'Calling adapter.findMany for model:', modelName); logger_1.logger.debug('stitcher', 'Args:', JSON.stringify({ where: args.where, select: args.select, orderBy: args.orderBy, skip: args.skip, take: args.take }, null, 2)); let rootData = await adapter.findMany(modelName, { where: args.where, select: args.select, orderBy: args.orderBy, skip: args.skip, take: args.take }); logger_1.logger.debug('stitcher', 'Adapter returned', rootData.length, 'rows'); // Process includes (joins) if (args.include && Object.keys(args.include).length > 0) { await this.processIncludes(model, rootData, args.include, options, issues, 1); } // Execute computed fields if (this.computedExecutor && rootData.length > 0) { try { rootData = await this.computedExecutor.executeComputedFields(rootData, modelName, model.fields, args.select); } catch (error) { logger_1.logger.warn('stitcher', `Error executing computed fields for ${modelName}: ${error.message}`); if (options.strict) { throw error; } issues.push({ type: 'warning', message: `Computed field error: ${error.message}` }); } } const result = { data: rootData, $meta: { issues: issues.length > 0 ? issues : undefined, timings: { total: Date.now() - startTime } } }; // Cache the result this.queryCache.set(cacheKey, result); return result; } catch (error) { if (options.strict) { throw error; } issues.push({ type: 'error', message: error.message }); return { data: [], $meta: { issues } }; } } async findUnique(modelName, args) { const model = this.ir.models[modelName]; if (!model) throw new Error(`Model ${modelName} not found`); // Check cache first const cacheKey = cache_1.QueryCache.generateKey(modelName, 'findUnique', args); const cached = this.queryCache.get(cacheKey); if (cached) { logger_1.logger.debug('stitcher', `Returning cached result for ${modelName}.findUnique`); return cached; } const options = this.mergeOptions(args.$options); const issues = []; try { const adapter = this.getAdapter(model); const data = await adapter.findUnique(modelName, { where: args.where, select: args.select }); if (data && args.include) { await this.processIncludes(model, [data], args.include, options, issues, 1); } // Execute computed fields if (this.computedExecutor && data) { try { const result = await this.computedExecutor.executeComputedFields([data], modelName, model.fields, args.select); const finalResult = { data: result[0], $meta: issues.length > 0 ? { issues } : undefined }; // Cache the result this.queryCache.set(cacheKey, finalResult); return finalResult; } catch (error) { logger_1.logger.warn('stitcher', `Error executing computed fields for ${modelName}: ${error.message}`); if (options.strict) { throw error; } issues.push({ type: 'warning', message: `Computed field error: ${error.message}` }); } } const result = { data: data, $meta: issues.length > 0 ? { issues } : undefined }; // Cache the result this.queryCache.set(cacheKey, result); return result; } catch (error) { if (options.strict) throw error; issues.push({ type: 'error', message: error.message }); return { data: null, $meta: { issues } }; } } async processIncludes(model, parentRows, include, options, issues, depth) { const maxDepth = options.limits?.maxIncludeDepth ?? 2; if (depth > maxDepth) { const msg = `Max include depth ${maxDepth} exceeded`; if (options.strict) { throw new Error(msg); } issues.push({ type: 'warning', message: msg }); return; } for (const [fieldName, includeSpec] of Object.entries(include)) { if (!includeSpec) continue; const field = model.fields[fieldName]; if (!field || !field.relation) { issues.push({ type: 'warning', message: `Field ${fieldName} is not a relation`, path: [model.name, fieldName] }); continue; } const childModel = this.ir.models[field.relation.model]; if (!childModel) { issues.push({ type: 'warning', message: `Related model ${field.relation.model} not found`, path: [model.name, fieldName] }); continue; } await this.fetchAndAttachRelation(model, parentRows, field.relation, childModel, fieldName, includeSpec, options, issues, depth); } } async fetchAndAttachRelation(parentModel, parentRows, relation, childModel, fieldName, includeSpec, options, issues, depth) { const field = parentModel.fields[fieldName]; // Gather foreign keys const foreignKeys = parentRows .map(row => row[relation.fields[0]]) .filter(key => key !== null && key !== undefined); if (foreignKeys.length === 0) { parentRows.forEach(row => (row[fieldName] = null)); return; } // Check fan-out limit const maxFanOut = options.limits?.maxFanOut ?? 2000; if (foreignKeys.length > maxFanOut) { const msg = `Fan-out limit ${maxFanOut} exceeded (${foreignKeys.length} keys)`; if (options.strict) { throw new Error(msg); } issues.push({ type: 'warning', message: msg }); return; } const uniqueKeys = Array.from(new Set(foreignKeys)); const childAdapter = this.getAdapter(childModel); // Build where clause for child fetch const childWhere = { [relation.references[0]]: { in: uniqueKeys } }; logger_1.logger.debug('stitcher', 'Child where clause:', JSON.stringify(childWhere, null, 2)); logger_1.logger.debug('stitcher', 'Unique keys:', uniqueKeys); // Merge with user-provided where from includeSpec if (typeof includeSpec === 'object' && includeSpec.where) { const canPushDown = this.canPushDownFilter(childAdapter, includeSpec.where); if (!canPushDown && options.strict) { throw new Error(`Cannot push down filter on ${childModel.name}`); } if (canPushDown) { Object.assign(childWhere, includeSpec.where); } else { issues.push({ type: 'warning', message: `Filter on ${childModel.name} applied in-memory`, path: [parentModel.name, fieldName] }); } } // Fetch children (with bulk support if available) let childRows; try { logger_1.logger.debug('stitcher', 'Fetching children from model:', childModel.name); childRows = await this.fetchChildren(childModel, childAdapter, childWhere, includeSpec.select, options); logger_1.logger.debug('stitcher', 'Fetched', childRows.length, 'child rows'); } catch (error) { if (options.strict) throw error; issues.push({ type: 'error', message: `Failed to fetch ${childModel.name}: ${error.message}`, path: [parentModel.name, fieldName] }); parentRows.forEach(row => (row[fieldName] = null)); return; } // Post-filter if needed if (typeof includeSpec === 'object' && includeSpec.where && !this.canPushDownFilter(childAdapter, includeSpec.where)) { childRows = this.postFilter(childRows, includeSpec.where, options, issues); } // Build lookup map const childMap = new Map(); for (const child of childRows) { const key = child[relation.references[0]]; if (!childMap.has(key)) { childMap.set(key, []); } childMap.get(key).push(child); } // Attach to parents for (const parent of parentRows) { const key = parent[relation.fields[0]]; const matches = childMap.get(key) || []; parent[fieldName] = field.isList ? matches : (matches[0] || null); } // Process nested includes if (typeof includeSpec === 'object' && includeSpec.include && childRows.length > 0) { await this.processIncludes(childModel, childRows, includeSpec.include, options, issues, depth + 1); } } async fetchChildren(model, adapter, where, select, options) { logger_1.logger.debug('stitcher', 'fetchChildren where:', JSON.stringify(where, null, 2)); logger_1.logger.debug('stitcher', 'fetchChildren adapter capabilities:', adapter.capabilities); // Use bulk endpoint if available and applicable if (adapter.capabilities.bulkByIds && where.id?.in) { return await adapter.findMany(model.name, { where, select }); } // Otherwise, parallel fetches with concurrency control const limit = options.limits?.maxConcurrentRequests ?? 10; const keys = where.id?.in || where[Object.keys(where)[0]]?.in || []; logger_1.logger.debug('stitcher', 'fetchChildren keys:', keys); // If no keys or keys is empty, fetch all with where clause if (keys.length === 0) { logger_1.logger.debug('stitcher', 'fetchChildren: No keys, calling findMany'); return await adapter.findMany(model.name, { where, select }); } // If filtering by 'id' field, use findUnique for each key (for path parameter endpoints like /posts/{id}) const filterField = Object.keys(where)[0]; if (filterField === 'id') { logger_1.logger.debug('stitcher', 'fetchChildren: Filtering by id, using findUnique for each key'); const results = []; for (let i = 0; i < keys.length; i += limit) { const batch = keys.slice(i, i + limit); const promises = batch.map((key) => adapter.findUnique(model.name, { where: { id: key }, select }).catch(() => null)); const batchResults = await Promise.all(promises); results.push(...batchResults.filter(r => r !== null)); } return results; } // For other fields (like userId), call findMany with where clause and let adapter handle it logger_1.logger.debug('stitcher', 'fetchChildren: Filtering by', filterField, '- calling findMany'); return await adapter.findMany(model.name, { where, select }); } canPushDownFilter(adapter, where) { const supportedOps = adapter.capabilities.supportedOperators || []; for (const [field, value] of Object.entries(where)) { if (field === 'AND' || field === 'OR' || field === 'NOT') { const nested = Array.isArray(value) ? value : [value]; if (!nested.every(w => this.canPushDownFilter(adapter, w))) { return false; } } else if (typeof value === 'object' && value !== null) { const ops = Object.keys(value); if (!ops.every(op => supportedOps.includes(op))) { return false; } } } return true; } postFilter(rows, where, options, issues) { const postFilterLimit = options.limits?.postFilterRowLimit ?? 10000; if (rows.length > postFilterLimit) { const msg = `Post-filter row limit ${postFilterLimit} exceeded`; if (options.strict) { throw new Error(msg); } issues.push({ type: 'warning', message: msg }); return rows.slice(0, postFilterLimit); } return rows.filter(row => this.matchesWhere(row, where)); } matchesWhere(row, where) { for (const [field, value] of Object.entries(where)) { if (field === 'AND') { if (!value.every(w => this.matchesWhere(row, w))) return false; } else if (field === 'OR') { if (!value.some(w => this.matchesWhere(row, w))) return false; } else if (field === 'NOT') { if (this.matchesWhere(row, value)) return false; } else if (typeof value === 'object') { if (!this.matchesFieldFilter(row[field], value)) return false; } else { if (row[field] !== value) return false; } } return true; } matchesFieldFilter(fieldValue, filter) { for (const [op, value] of Object.entries(filter)) { switch (op) { case 'eq': if (fieldValue !== value) return false; break; case 'ne': if (fieldValue === value) return false; break; case 'in': if (!value.includes(fieldValue)) return false; break; case 'notIn': if (value.includes(fieldValue)) return false; break; case 'gt': if (fieldValue <= value) return false; break; case 'gte': if (fieldValue < value) return false; break; case 'lt': if (fieldValue >= value) return false; break; case 'lte': if (fieldValue > value) return false; break; case 'contains': if (!String(fieldValue).includes(String(value))) return false; break; case 'startsWith': if (!String(fieldValue).startsWith(String(value))) return false; break; case 'endsWith': if (!String(fieldValue).endsWith(String(value))) return false; break; } } return true; } validateLimits(args, limits) { // Add validation logic as needed } mergeOptions(options) { return { strict: options?.strict ?? this.ir.config.strict, limits: { ...this.ir.config.limits, ...options?.limits } }; } getAdapter(model) { const adapter = this.adapters.get(model.datasource); if (!adapter) { throw new Error(`No adapter found for datasource: ${model.datasource}`); } return adapter; } /** * Invalidate cache for a specific model */ invalidateCache(modelName) { this.queryCache.invalidatePattern(`^${modelName}:`); logger_1.logger.debug('stitcher', `Invalidated cache for model: ${modelName}`); } /** * Clear all cache */ clearCache() { this.queryCache.clear(); } /** * Get cache statistics */ getCacheStats() { return this.queryCache.stats(); } } exports.Stitcher = Stitcher; //# sourceMappingURL=stitcher.js.map