@dreamtree-org/korm-js 1.1.3 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BaseHelperUtility.js +1 -1
- package/ConnectionResilience.js +1 -0
- package/ControllerWrapper.js +1 -1
- package/Emitter.js +1 -1
- package/KormError.js +1 -1
- package/README.md +232 -8
- package/RequestValidator.js +1 -1
- package/ai-skills/korm-js.md +63 -5
- package/bin/korm-mcp.js +1 -1
- package/clients/SyncRunner.js +1 -1
- package/clients/mysql/CurdTable.js +1 -1
- package/clients/mysql/QueryBuilder.js +1 -1
- package/clients/mysql/QueryService.js +1 -1
- package/clients/pg/CurdTable.js +1 -1
- package/clients/pg/QueryBuilder.js +1 -1
- package/clients/pg/QueryService.js +1 -1
- package/clients/sqlite/CurdTable.js +1 -1
- package/clients/sqlite/QueryBuilder.js +1 -1
- package/clients/sqlite/QueryService.js +1 -1
- package/clients/sqlite/SyncTable.js +1 -1
- package/index.d.ts +43 -0
- package/index.js +1 -1
- package/jest.config.js +1 -1
- package/package.json +1 -1
- package/requestSchema.js +1 -1
- package/src/mcp/schemaIntrospect.js +1 -1
package/BaseHelperUtility.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const Files=require("./helpers/files");class BaseHelperUtility{file=new Files;capitalize(e){return e.charAt(0).toUpperCase()+e.slice(1)}snakeCase(e){return e.replace(/([A-Z])/g,"_$1").toLowerCase()}camelCase(e){return e.replace(/([-_][a-z])/gi,e=>e.toUpperCase().replace("-","").replace("_",""))}kebabCase(e){return e.replace(/([A-Z])/g,"-$1").toLowerCase()}pascalCase(e){return e.replace(/([-_][a-z])/gi,e=>e.toUpperCase().replace("-","").replace("_",""))}pluralize(e){if(e.match(/[^aeiou]y$/i))return e.slice(0,-1)+"ies";if(e.match(/(s|x|z|ch|sh)$/i))return e+"es";if(e.match(/(f|fe)$/i)){if(e.endsWith("fe"))return e.slice(0,-2)+"ves";if(e.endsWith("f"))return e.slice(0,-1)+"ves"}return e.match(/(o)$/i)?e.length>1&&"aeiou".includes(e[e.length-2].toLowerCase())?e+"s":e+"es":e.match(/us$/i)?e.slice(0,-2)+"i":e.match(/is$/i)?e.slice(0,-2)+"es":e.match(/on$/i)||e.match(/um$/i)?e.slice(0,-2)+"a":e.match(/a$/i)?e+"s":e.match(/eau$/i)?e+"x":e.match(/ix$/i)||e.match(/ex$/i)?e.slice(0,-2)+"ices":e.match(/s$/i)?e:e+"s"}singularize(e){return String(e||"").trim().replace(/([b-df-hj-np-tv-z])ies$/i,"$1y").replace(/eaux$/i,"eau").replace(/oes$/i,"o").replace(/(xes|zes|ches|shes|sses)$/i,e=>e.slice(0,-2)).replace(/(us)es$/i,"$1").replace(/ves$/i,"f").replace(/([^s])s$/i,"$1")}modelName(e){let r=this.singularize(e);return r=this.camelCase(r),r=this.capitalize(r),r}dotParse(e,r,t=null){return this.dotWalk(e,{source:r,defaultValue:t})}dotWalk(e,r={}){const{source:t={},defaultValue:s=null,resolver:a=({current:e,part:r,source:t})=>e[r]}=r,i=e.split(".");let l=t;for(const e of i){const r=e.match(/^(\w+)\[(.*?)\]$/);if(r){const e=r[1],i=r[2]||0;if(l=l[e]||[],l=a({current:l,part:i,source:t}),void 0===l)return s}else{if(void 0===l[e])return s;if(l=a({current:l,part:e,source:t}),void 0===l)return s}}return l}dotWalkTree(e,r={}){const{resolver:t=({current:e,part:r,source:t})=>{}}=r,s={};for(const r of e){const e=r.split("."),a=e.length;let i=s,l=0;for(const n of e){const e=l===a-1;i[n]=i[n]||t({current:i,part:n,source:s,path:r,isLastPart:e}),i=i[n],l++}}return s}parseValue(e){
|
|
1
|
+
const Files=require("./helpers/files");class BaseHelperUtility{file=new Files;capitalize(e){return e.charAt(0).toUpperCase()+e.slice(1)}snakeCase(e){return e.replace(/([A-Z])/g,"_$1").toLowerCase()}camelCase(e){return e.replace(/([-_][a-z])/gi,e=>e.toUpperCase().replace("-","").replace("_",""))}kebabCase(e){return e.replace(/([A-Z])/g,"-$1").toLowerCase()}pascalCase(e){return e.replace(/([-_][a-z])/gi,e=>e.toUpperCase().replace("-","").replace("_",""))}pluralize(e){if(e.match(/[^aeiou]y$/i))return e.slice(0,-1)+"ies";if(e.match(/(s|x|z|ch|sh)$/i))return e+"es";if(e.match(/(f|fe)$/i)){if(e.endsWith("fe"))return e.slice(0,-2)+"ves";if(e.endsWith("f"))return e.slice(0,-1)+"ves"}return e.match(/(o)$/i)?e.length>1&&"aeiou".includes(e[e.length-2].toLowerCase())?e+"s":e+"es":e.match(/us$/i)?e.slice(0,-2)+"i":e.match(/is$/i)?e.slice(0,-2)+"es":e.match(/on$/i)||e.match(/um$/i)?e.slice(0,-2)+"a":e.match(/a$/i)?e+"s":e.match(/eau$/i)?e+"x":e.match(/ix$/i)||e.match(/ex$/i)?e.slice(0,-2)+"ices":e.match(/s$/i)?e:e+"s"}singularize(e){return String(e||"").trim().replace(/([b-df-hj-np-tv-z])ies$/i,"$1y").replace(/eaux$/i,"eau").replace(/oes$/i,"o").replace(/(xes|zes|ches|shes|sses)$/i,e=>e.slice(0,-2)).replace(/(us)es$/i,"$1").replace(/ves$/i,"f").replace(/([^s])s$/i,"$1")}modelName(e){let r=this.singularize(e);return r=this.camelCase(r),r=this.capitalize(r),r}dotParse(e,r,t=null){return this.dotWalk(e,{source:r,defaultValue:t})}dotWalk(e,r={}){const{source:t={},defaultValue:s=null,resolver:a=({current:e,part:r,source:t})=>e[r]}=r,i=e.split(".");let l=t;for(const e of i){const r=e.match(/^(\w+)\[(.*?)\]$/);if(r){const e=r[1],i=r[2]||0;if(l=l[e]||[],l=a({current:l,part:i,source:t}),void 0===l)return s}else{if(void 0===l[e])return s;if(l=a({current:l,part:e,source:t}),void 0===l)return s}}return l}dotWalkTree(e,r={}){const{resolver:t=({current:e,part:r,source:t})=>{}}=r,s={};for(const r of e){const e=r.split("."),a=e.length;let i=s,l=0;for(const n of e){const e=l===a-1;i[n]=i[n]||t({current:i,part:n,source:s,path:r,isLastPart:e}),i=i[n],l++}}return s}parseValue(e){if(!isNaN(e)&&""!==e){const r=Number(e);if(String(r)===e)return r}return"true"===e.toLowerCase()||"false"!==e.toLowerCase()&&("null"===e.toLowerCase()?null:e)}parseWhereValue(e){if("string"!=typeof e)return{operator:"=",value:e};const r=e.match(/^(!?)\[\]?(.+)$/);if(r){const[,e,t]=r;return{operator:e?"notIn":"in",value:t.split(",").map(e=>this.parseValue(e.trim()))}}if(e.startsWith("!")){const r=e.substring(1);return{operator:"!=",value:this.parseValue(r)}}if(e.includes("%"))return{operator:"like",value:e};const t=e.match(/^(><|<>)(.+)$/);if(t){const[,e,r]=t,s=r.split(",").map(e=>this.parseValue(e.trim()));if("><"===e){if(2===s.length)return{operator:"between",value:s}}else if("<>"===e&&2===s.length)return{operator:"notBetween",value:s}}const s=e.match(/^(>=|<=|<|>)(.+)$/);if(s){const[,e,r]=s;return{operator:e,value:this.parseValue(r)}}return{operator:"=",value:this.parseValue(e)}}parseWhereColumn(e){const r={joinType:"AND",column:e};return e.startsWith("Or:")&&(r.joinType="OR",r.column=e.substring(3)),r}objectFilter(e,r){return Object.keys(e).filter(t=>r(t,e[t])).reduce((r,t)=>(r[t]=e[t],r),{})}pluckDotWalkKey(e,r=1,t="."){const s=e.split(t);return[...s].splice(-1*s.length+r).join(t)}getDotWalkQuery(e,r=""){let t=this.dotWalkTree(Object.keys(e).filter(e=>e.includes(r)),{resolver:({current:r,part:t,source:s,path:a,isLastPart:i})=>i?e[a]:{}});return r&&(t=t[r]),t}setNested(e,r,t){const s={name:r,value:t},a=s.name.split(".");let i=e,l=i,n="";return a.length>1?(a.forEach(e=>{i=l,l[e]=l[e]||{},l=l[e],n=e}),i[n]=s.value):(n=s.name,i=e[n],e[n]=s.value),e}resolveDefaultOrderBy(e){const r=e&&Array.isArray(e.columns)?e.columns:[],t=r.find(e=>e&&e.primary&&e.name);if(t)return{column:t.name,direction:"asc"};return r.some(e=>e&&"id"===e.name)||0===r.length?{column:"id",direction:"asc"}:null}}module.exports=BaseHelperUtility;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const TRANSIENT_CODES=new Set(["PROTOCOL_CONNECTION_LOST","ER_CON_COUNT_ERROR","ECONNRESET","EPIPE","ETIMEDOUT","08000","08003","08006","57P01","57P03","SQLITE_BUSY"]),TRANSIENT_PATTERNS=[/connection lost/i,/connection terminated/i,/server closed the connection/i,/client has encountered a connection error/i,/read econnreset/i,/database is locked/i],READ_ACTIONS=new Set(["list","show","count","sum"]),DEFAULT_RETRY=Object.freeze({attempts:2,backoffMs:100,writes:!1}),DEFAULT_FAN_OUT_CONCURRENCY=5;function isTransientConnectionError(e){if(!e)return!1;const t=e.code||e.original&&e.original.code;if(t&&TRANSIENT_CODES.has(String(t)))return!0;const n=String(e.message||"");return TRANSIENT_PATTERNS.some(e=>e.test(n))}function collectActions(e,t=[],n=0){if(!e||"object"!=typeof e||n>10)return t;t.push(String(e.action||"list").toLowerCase());const o=e.other_requests;if(!o||"object"!=typeof o)return t;for(const e of Object.values(o)){const o=Array.isArray(e)?e:[e];for(const e of o)collectActions(e,t,n+1)}return t}function isReplaySafe(e,t){return!(!e||!e.dryRun)||(!!t||collectActions(e).every(e=>READ_ACTIONS.has(e)))}function normalizeRetryOptions(e){if(!1===e||null===e)return null;if(void 0===e||!0===e)return{...DEFAULT_RETRY};if("object"!=typeof e)return{...DEFAULT_RETRY};const t=Number.isInteger(e.attempts)?e.attempts:DEFAULT_RETRY.attempts;return t<1?null:{attempts:t,backoffMs:Number.isFinite(e.backoffMs)?e.backoffMs:DEFAULT_RETRY.backoffMs,writes:!0===e.writes}}function sleep(e){return!e||e<=0?Promise.resolve():new Promise(t=>setTimeout(t,e))}async function withRetry(e,t={}){const n=Number.isInteger(t.attempts)?Math.max(1,t.attempts):1,o=Number.isFinite(t.backoffMs)?t.backoffMs:0;let r;for(let i=1;i<=n;i++)try{return await e()}catch(e){if(r=e,i>=n||!isTransientConnectionError(e))throw e;"function"==typeof t.onRetry&&t.onRetry(e,i),await sleep(o*i)}throw r}function getPoolConfig(e){try{const t=e&&e.client;if(!t)return null;const n=t.pool,o=t.config&&t.config.pool||{},r=e=>n&&void 0!==n[e]?n[e]:o[e];return{min:r("min"),max:r("max"),idleTimeoutMillis:r("idleTimeoutMillis")}}catch(e){return null}}function getDialect(e){try{const t=e&&e.client;return String(t&&(t.dialect||t.driverName)||"")}catch(e){return""}}const MAX_SAFE_IDLE_MS=6e5,HIGH_POOL_MAX=20;function inspectPoolConfig(e){const t=getDialect(e);if(!t||t.includes("sqlite"))return[];const n=getPoolConfig(e);if(!n)return[];const o=[];return"number"==typeof n.min&&n.min>0&&o.push(`pool.min is ${n.min}: those connections are never reaped and will sit idle until the server closes them, after which the next query gets a dead socket. Set pool.min to 0.`),"number"==typeof n.idleTimeoutMillis&&n.idleTimeoutMillis>6e5&&o.push(`pool.idleTimeoutMillis is ${n.idleTimeoutMillis}ms: keep it well below the server's idle timeout (MySQL wait_timeout) so korm reaps a socket before the server does.`),"number"==typeof n.max&&n.max>20&&o.push(`pool.max is ${n.max}: total connections are (processes x pool.max) and must stay under the server's max_connections.`),o}function recommendedPoolConfig(e=""){return String(e).includes("sqlite")?{pool:{min:0,max:1,idleTimeoutMillis:3e4}}:{pool:{min:0,max:10,idleTimeoutMillis:3e4,reapIntervalMillis:1e3,acquireTimeoutMillis:3e4,createTimeoutMillis:3e4,propagateCreateError:!1}}}function resolveFanOutConcurrency(e,t){const n=Number.isInteger(t)&&t>0?t:5,o=getPoolConfig(e),r=o&&"number"==typeof o.max?o.max:null;return!r||r<1?n:Math.max(1,Math.min(n,r-1))}async function mapWithConcurrency(e,t,n){const o=Array.from(e),r=Math.max(1,Number.isInteger(t)?t:1),i=new Array(o.length);let s=0;const c=Array.from({length:Math.min(r,o.length)},async()=>{for(;s<o.length;){const e=s++;i[e]=await n(o[e],e)}});return await Promise.all(c),i}async function pingConnection(e){if(!e||"function"!=typeof e.raw)return{ok:!1,error:"db not present or not a Knex instance"};try{return await e.raw("SELECT 1"),{ok:!0}}catch(e){return{ok:!1,error:e.message}}}async function destroyConnection(e){if(!e||"function"!=typeof e.destroy)return{ok:!1,error:"db has no destroy()"};try{return await e.destroy(),{ok:!0}}catch(e){return{ok:!1,error:e.message}}}module.exports={isTransientConnectionError:isTransientConnectionError,isReplaySafe:isReplaySafe,collectActions:collectActions,normalizeRetryOptions:normalizeRetryOptions,withRetry:withRetry,getPoolConfig:getPoolConfig,getDialect:getDialect,inspectPoolConfig:inspectPoolConfig,recommendedPoolConfig:recommendedPoolConfig,resolveFanOutConcurrency:resolveFanOutConcurrency,mapWithConcurrency:mapWithConcurrency,pingConnection:pingConnection,destroyConnection:destroyConnection,READ_ACTIONS:READ_ACTIONS,DEFAULT_RETRY:DEFAULT_RETRY,DEFAULT_FAN_OUT_CONCURRENCY:5};
|
package/ControllerWrapper.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const mysqlWrapper=require("./clients/mysql"),sqliteWrapper=require("./clients/sqlite"),pgWrapper=require("./clients/pg"),KormError=require("./KormError"),AuthorizationService=require("./AuthorizationService"),{buildModelRequestSchema:buildModelRequestSchema}=require("./requestSchema"),{buildModelDescription:buildModelDescription,SCHEMA_API_VERSION:SCHEMA_API_VERSION}=require("./schemaDescribe"),InstanceMapper={mysql2:mysqlWrapper,sqlite:sqliteWrapper,pg:pgWrapper},dbClientMapper={mysql2:"mysql2",mysql:"mysql2",pg:"pg",postgresql:"pg",sqlite:"sqlite",sqlite3:"sqlite"};class ControllerWrapper{db=null;dbClient=null;dbClientClass=null;schema=null;resolverPath=null;dbInstance=null;debug=!1;_authz=new AuthorizationService;requestInstance=null;constructor({db:e,dbClient:t,schema:
|
|
1
|
+
const fs=require("fs"),https=require("https"),http=require("http"),path=require("path"),{pathToFileURL:pathToFileURL}=require("url"),mysqlWrapper=require("./clients/mysql"),sqliteWrapper=require("./clients/sqlite"),pgWrapper=require("./clients/pg"),KormError=require("./KormError"),logger=require("./Logger"),AuthorizationService=require("./AuthorizationService"),{normalizeRetryOptions:normalizeRetryOptions,withRetry:withRetry,isReplaySafe:isReplaySafe,inspectPoolConfig:inspectPoolConfig,resolveFanOutConcurrency:resolveFanOutConcurrency,pingConnection:pingConnection,destroyConnection:destroyConnection}=require("./ConnectionResilience"),{buildModelRequestSchema:buildModelRequestSchema}=require("./requestSchema"),{buildModelDescription:buildModelDescription,SCHEMA_API_VERSION:SCHEMA_API_VERSION}=require("./schemaDescribe"),InstanceMapper={mysql2:mysqlWrapper,sqlite:sqliteWrapper,pg:pgWrapper},dbClientMapper={mysql2:"mysql2",mysql:"mysql2",pg:"pg",postgresql:"pg",sqlite:"sqlite",sqlite3:"sqlite"};let auditedPools=new WeakSet,distinctPoolCount=0,poolLeakWarned=!1;const DISTINCT_POOL_WARN_THRESHOLD=5;class ControllerWrapper{db=null;dbClient=null;dbClientClass=null;schema=null;resolverPath=null;dbInstance=null;debug=!1;_authz=new AuthorizationService;requestInstance=null;_retry=null;_destroyed=!1;fanOutConcurrency=null;constructor({db:e,dbClient:t,schema:r,resolverPath:s=null,debug:n=!1,retry:o,fanOutConcurrency:i}={}){this.requestInstance={},this.db=e,this.dbClient=t,this.schema=r,this.resolverPath=s,this.debug=n;const a=dbClientMapper[t];if(!a)throw new Error(`Database client ${t} not found`);const l=InstanceMapper[a];if(!l)throw new Error(`Database client ${t} not found`);this.dbClientClass=l,this._retry=normalizeRetryOptions(o),this.fanOutConcurrency=resolveFanOutConcurrency(e,i),this.dbInstance=new l(this),ControllerWrapper._auditConnectionSetup(e)}static _auditConnectionSetup(e){if(e&&"object"==typeof e&&!auditedPools.has(e)){auditedPools.add(e);for(const t of inspectPoolConfig(e))logger.warn(`connection pool: ${t}`);distinctPoolCount+=1,distinctPoolCount>5&&!poolLeakWarned&&(poolLeakWarned=!0,logger.warn(`${distinctPoolCount} distinct Knex instances have been passed to initializeKORM() in this process — each one is a separate connection pool. Build the Knex instance once at module scope and reuse it; creating it per request leaks a pool per request. See the README section "Connection management & pooling".`))}}static _resetConnectionWarnings(){auditedPools=new WeakSet,distinctPoolCount=0,poolLeakWarned=!1}static _fetchSchemaFromUrl(e){return new Promise((t,r)=>{const s=e.startsWith("https")?https:http,n=t=>r(new KormError(t,{code:KormError.CODES.INTERNAL,context:{schemaSource:e}}));s.get(e,s=>{if(s.statusCode<200||s.statusCode>=300)return void r(new KormError(`Schema URL returned ${s.statusCode}: ${e}`,{code:KormError.CODES.INTERNAL,context:{schemaSource:e,statusCode:s.statusCode}}));const o=[];s.on("data",e=>o.push(e)),s.on("end",()=>{try{t(JSON.parse(Buffer.concat(o).toString("utf8")))}catch(t){n(`Schema URL returned invalid JSON: ${e}`)}})}).on("error",t=>n(`Schema URL fetch failed (${t.message}): ${e}`))})}static async _loadSchema(e){if("string"!=typeof e)return e;if(/^https?:\/\//i.test(e))return ControllerWrapper._fetchSchemaFromUrl(e);const t=path.extname(e).toLowerCase();if(".js"===t)return require(path.resolve(e));if(".mjs"===t)return import(pathToFileURL(path.resolve(e)).href);const r=fs.readFileSync(path.resolve(e),"utf8");return JSON.parse(r)}static async initializeKORM(e){const t={...e};return"string"==typeof e.schema&&(t.schema=await ControllerWrapper._loadSchema(e.schema)),new ControllerWrapper(t)}setSchema(e){this.schema=e;const t=this.dbClientClass;if(!t)throw new Error(`Database client ${this.dbClient} not found`);return this.dbInstance=new t(this),this}_resolveModelName(e){const t=this.schema||{};if(t[e])return e;return Object.keys(t).find(r=>t[r]&&t[r].table===e)||e}authorize(e,t,r){return this._authz.registerAuthorize(this._resolveModelName(e),t,r),this}scope(e,t){return this._authz.registerScope(this._resolveModelName(e),t),this}resetAuthorization(){return this._authz.reset(),this}async processRequest(e,t=null,r=null){if(this._destroyed)throw new KormError("KORM instance has been destroyed; its connection pool is closed.",{code:KormError.CODES.INTERNAL});let s=e;if(this._authz.hasRules()&&t){const n=this._resolveModelName(t),o=e&&e.action||"list";this._authz.enforce(n,o,e,r),s=this._authz.applyScope(n,o,e||{},r)}const n=()=>this.dbInstance.processRequest(s,t,r);return this._retry&&isReplaySafe(s,this._retry.writes)?await withRetry(n,{attempts:this._retry.attempts,backoffMs:this._retry.backoffMs,onRetry:(e,t)=>logger.warn(`transient connection error on attempt ${t} (${e.message}) — retrying`)}):await n()}async processRequestWithOthers(e,t=null,r=null){return await this.processRequest(e,t,r)}async ping(){return await pingConnection(this.db)}async destroy(){return this._destroyed?{ok:!0}:(this._destroyed=!0,await destroyConnection(this.db))}get isDestroyed(){return this._destroyed}async syncDatabase(e={}){return await this.dbInstance.syncDatabase(e)}async generateSchema(){return await this.dbInstance.generateSchema()}loadModelClass(e){return this.dbInstance.hookService.loadModelClass(e)}getModelInstance(e){return this.dbInstance.hookService.getModelInstance(e)}getRequestJsonSchema(e){const t=this.schema||{},r=t[e]||Object.values(t).find(t=>t&&t.table===e);if(!r)throw KormError.unknownModel({model:e,available:Object.keys(t)});return buildModelRequestSchema(r,{title:`KormRequest<${e}>`})}_modelHasSoftDelete(e){try{const t=this.dbInstance?.hookService?.getModelInstance?.(e);return!(!t||!0!==t.hasSoftDelete)}catch{return!1}}describeModel(e,t=null){const r=this.schema||{},s=Object.entries(r).find(([t,r])=>t===e||r&&r.table===e);if(!s)throw KormError.unknownModel({model:e,available:Object.keys(r)});const[n,o]=s,i=buildModelDescription(n,o,{softDelete:this._modelHasSoftDelete(n)});return null!=t&&this._authz.hasRules()&&(i.actions=this._authz.availableActions(n,i.actions,t)),i}describeSchema(){const e=this.schema||{},t=Object.entries(e).map(([e,t])=>buildModelDescription(e,t,{softDelete:this._modelHasSoftDelete(e)}));return{schemaApiVersion:SCHEMA_API_VERSION,models:t}}}module.exports=ControllerWrapper;
|
package/Emitter.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
class Emitter{constructor(){this.events={}}on(e,t){this.events[e]||(this.events[e]=[]),this.events[e].push({listener:t,once:!1})}onOnce(e,t){this.events[e]||(this.events[e]=[]),this.events[e].push({listener:t,once:!0})}offAll(e){this.events[e]&&(this.events[e]=[])}onSchedule(e,t,s){this.events[e]||(this.events[e]=[]),this.events[e].push({listener:t,schedule:s})}emit(e,...t){return new Promise((s,n)=>{this.executeEvent(e,{resolve:s,reject:n},...t)})}executeEvent(e,t={},...s){try{if(!this.events[e])return;this.events[e].forEach(n=>{const{listener:i,once:
|
|
1
|
+
class Emitter{constructor(){this.events={}}on(e,t){this.events[e]||(this.events[e]=[]),this.events[e].push({listener:t,once:!1})}onOnce(e,t){this.events[e]||(this.events[e]=[]),this.events[e].push({listener:t,once:!0})}offAll(e){this.events[e]&&(this.events[e]=[])}onSchedule(e,t,s){this.events[e]||(this.events[e]=[]),this.events[e].push({listener:t,schedule:s})}emit(e,...t){return new Promise((s,n)=>{this.executeEvent(e,{resolve:s,reject:n},...t)})}executeEvent(e,t={},...s){try{if(!this.events[e])return;this.events[e].forEach(n=>{const{listener:i,once:r,schedule:h}=n,o=()=>{const e=i(...s);t.resolve(e)};if(h){const e=new Date;let t,s=-1;h instanceof Date?(t=h,s=t-e):"number"==typeof h||h instanceof Number?s=h:("string"==typeof h||h instanceof String)&&(t=new Date(h),s=t-e),s>0?setTimeout(()=>{o()},s):o()}else o();r&&this.off(e,i)})}catch(e){t.reject(e)}}off(e,t){this.events[e]&&(this.events[e]=this.events[e].filter(e=>{const{listener:s}=e;return s!==t}))}}module.exports=Emitter;
|
package/KormError.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const CODES=Object.freeze({NO_MATCHING_ROW:"NO_MATCHING_ROW",UNKNOWN_ACTION:"UNKNOWN_ACTION",VALIDATION_FAILED:"VALIDATION_FAILED",UNKNOWN_MODEL:"UNKNOWN_MODEL",NO_CUSTOM_ACTION_HOOK:"NO_CUSTOM_ACTION_HOOK",FORBIDDEN:"FORBIDDEN",SYNC_FK_ORPHAN:"SYNC_FK_ORPHAN",INTERNAL:"INTERNAL"}),ACTIONS=Object.freeze(["count","sum","list","show","create","update","replace","upsert","sync","delete"]);function levenshtein(e,t){const o=e.length,r=t.length;if(0===o)return r;if(0===r)return o;let n=Array.from({length:r+1},(e,t)=>t),i=new Array(r+1);for(let s=1;s<=o;s++){i[0]=s;for(let o=1;o<=r;o++){const r=e[s-1]===t[o-1]?0:1;i[o]=Math.min(n[o]+1,i[o-1]+1,n[o-1]+r)}[n,i]=[i,n]}return n[r]}function closestAction(e,t=ACTIONS){if(!e)return null;const o=String(e).toLowerCase();let r=null,n=1/0;for(const e of t){const t=levenshtein(o,e);t<n&&(n=t,r=e)}return n<=Math.max(2,Math.ceil(o.length/2))?r:null}class KormError extends Error{constructor(e,{code:t=CODES.INTERNAL,hint:o=null,context:r={},suggestedFixes:n=null}={}){super(e),this.name="KormError",this.code=t,this.hint=o,this.context=r,this.suggestedFixes=n,Error.captureStackTrace&&Error.captureStackTrace(this,KormError)}toJSON(){return{name:this.name,code:this.code,message:this.message,hint:this.hint,context:this.context,suggestedFixes:this.suggestedFixes}}}KormError.noMatchingRow=({action:e,model:t})=>{const o="update"===e?"No row matched the where clause. Use `upsert` to insert-or-update, or `sync` to reconcile.":"No row matched the where clause for this action.";return new KormError(`No matching row for action "${e}" on model "${t}".`,{code:CODES.NO_MATCHING_ROW,hint:o,context:{action:e,model:t}})},KormError.unknownAction=({action:e,model:t,hasCustomHook:o=!1})=>{const r=closestAction(e),n=o?CODES.NO_CUSTOM_ACTION_HOOK:CODES.UNKNOWN_ACTION,i=o?`No custom action hook found for "${t}.${e}".`:`Unknown action "${e}".`,s=r?`Did you mean "${r}"? Valid actions: ${ACTIONS.join(", ")}.`:`Valid actions: ${ACTIONS.join(", ")}. Custom actions require an on<Action>Action hook on the model.`;return new KormError(i,{code:n,hint:s,context:{action:e,model:t,validActions:ACTIONS,closest:r}})},KormError.unknownModel=({model:e,available:t=[]})=>{const o=t.length?`Available models: ${t.join(", ")}.`:"No models are registered in the schema.";return new KormError(`Model "${e}" not found.`,{code:CODES.UNKNOWN_MODEL,hint:o,context:{model:e,available:t}})},KormError.forbidden=({model:e,action:t,hint:o=null,context:r={}}={})=>new KormError(`Action "${t}" on model "${e}" is not permitted in this context.`,{code:CODES.FORBIDDEN,hint:o||"A registered authorize() predicate denied this request for the current context.",context:{action:t,model:e,...r}}),KormError.validationFailed=({errors:e=[],source:t=null})=>{const o=e.map(e=>({field:e.field,message:e.message,value:e.value,rule:e.rule})),r=o.map(e=>e.field).filter(Boolean),n=new KormError(`Validation failed${t?` for ${t}`:""}${r.length?`: ${r.join(", ")}`:""}.`,{code:CODES.VALIDATION_FAILED,hint:"Fix the listed fields and resubmit. See context.fields for per-field detail.",context:{source:t,fields:o}});return n.errors=e,n},KormError.foreignKeyOrphans=({table:e,offenders:t=[]})=>{const o=t.map(t=>{const o=`${t.rowsAffected} existing row${1===t.rowsAffected?"":"s"}`,r=JSON.stringify(t.backfillValue);return`"${e}.${t.column}" → "${t.parentTable}.${t.parentColumn}": ${o} would be back-filled with ${r}, which does not exist in "${t.parentTable}"`}).join("; ");return new KormError(`Cannot add foreign key on "${e}": existing rows would be orphaned (${o}).`,{code:CODES.SYNC_FK_ORPHAN,hint:"Adding this foreign-key column to a populated table would leave child rows pointing at a parent that does not exist, so the database would reject the constraint. Resolve the data first: make the column nullable (existing rows back-fill NULL, which is FK-safe), give it a default that exists in the parent, populate the column for existing rows before syncing, insert the missing parent rows, or clear the child table. No data was changed — korm aborted before altering the table.",context:{table:e,offenders:t}})},KormError.CODES=CODES,KormError.ACTIONS=ACTIONS,KormError.closestAction=closestAction,module.exports=KormError;
|
|
1
|
+
const CODES=Object.freeze({NO_MATCHING_ROW:"NO_MATCHING_ROW",UNKNOWN_ACTION:"UNKNOWN_ACTION",VALIDATION_FAILED:"VALIDATION_FAILED",UNKNOWN_MODEL:"UNKNOWN_MODEL",NO_CUSTOM_ACTION_HOOK:"NO_CUSTOM_ACTION_HOOK",FORBIDDEN:"FORBIDDEN",SYNC_FK_ORPHAN:"SYNC_FK_ORPHAN",INTERNAL:"INTERNAL"}),ACTIONS=Object.freeze(["count","sum","list","show","create","update","replace","upsert","sync","delete","restore"]);function levenshtein(e,t){const o=e.length,r=t.length;if(0===o)return r;if(0===r)return o;let n=Array.from({length:r+1},(e,t)=>t),i=new Array(r+1);for(let s=1;s<=o;s++){i[0]=s;for(let o=1;o<=r;o++){const r=e[s-1]===t[o-1]?0:1;i[o]=Math.min(n[o]+1,i[o-1]+1,n[o-1]+r)}[n,i]=[i,n]}return n[r]}function closestAction(e,t=ACTIONS){if(!e)return null;const o=String(e).toLowerCase();let r=null,n=1/0;for(const e of t){const t=levenshtein(o,e);t<n&&(n=t,r=e)}return n<=Math.max(2,Math.ceil(o.length/2))?r:null}class KormError extends Error{constructor(e,{code:t=CODES.INTERNAL,hint:o=null,context:r={},suggestedFixes:n=null}={}){super(e),this.name="KormError",this.code=t,this.hint=o,this.context=r,this.suggestedFixes=n,Error.captureStackTrace&&Error.captureStackTrace(this,KormError)}toJSON(){return{name:this.name,code:this.code,message:this.message,hint:this.hint,context:this.context,suggestedFixes:this.suggestedFixes}}}KormError.noMatchingRow=({action:e,model:t})=>{const o="update"===e?"No row matched the where clause. Use `upsert` to insert-or-update, or `sync` to reconcile.":"No row matched the where clause for this action.";return new KormError(`No matching row for action "${e}" on model "${t}".`,{code:CODES.NO_MATCHING_ROW,hint:o,context:{action:e,model:t}})},KormError.unknownAction=({action:e,model:t,hasCustomHook:o=!1})=>{const r=closestAction(e),n=o?CODES.NO_CUSTOM_ACTION_HOOK:CODES.UNKNOWN_ACTION,i=o?`No custom action hook found for "${t}.${e}".`:`Unknown action "${e}".`,s=r?`Did you mean "${r}"? Valid actions: ${ACTIONS.join(", ")}.`:`Valid actions: ${ACTIONS.join(", ")}. Custom actions require an on<Action>Action hook on the model.`;return new KormError(i,{code:n,hint:s,context:{action:e,model:t,validActions:ACTIONS,closest:r}})},KormError.unknownModel=({model:e,available:t=[]})=>{const o=t.length?`Available models: ${t.join(", ")}.`:"No models are registered in the schema.";return new KormError(`Model "${e}" not found.`,{code:CODES.UNKNOWN_MODEL,hint:o,context:{model:e,available:t}})},KormError.forbidden=({model:e,action:t,hint:o=null,context:r={}}={})=>new KormError(`Action "${t}" on model "${e}" is not permitted in this context.`,{code:CODES.FORBIDDEN,hint:o||"A registered authorize() predicate denied this request for the current context.",context:{action:t,model:e,...r}}),KormError.validationFailed=({errors:e=[],source:t=null})=>{const o=e.map(e=>({field:e.field,message:e.message,value:e.value,rule:e.rule})),r=o.map(e=>e.field).filter(Boolean),n=new KormError(`Validation failed${t?` for ${t}`:""}${r.length?`: ${r.join(", ")}`:""}.`,{code:CODES.VALIDATION_FAILED,hint:"Fix the listed fields and resubmit. See context.fields for per-field detail.",context:{source:t,fields:o}});return n.errors=e,n},KormError.foreignKeyOrphans=({table:e,offenders:t=[]})=>{const o=t.map(t=>{const o=`${t.rowsAffected} existing row${1===t.rowsAffected?"":"s"}`,r=JSON.stringify(t.backfillValue);return`"${e}.${t.column}" → "${t.parentTable}.${t.parentColumn}": ${o} would be back-filled with ${r}, which does not exist in "${t.parentTable}"`}).join("; ");return new KormError(`Cannot add foreign key on "${e}": existing rows would be orphaned (${o}).`,{code:CODES.SYNC_FK_ORPHAN,hint:"Adding this foreign-key column to a populated table would leave child rows pointing at a parent that does not exist, so the database would reject the constraint. Resolve the data first: make the column nullable (existing rows back-fill NULL, which is FK-safe), give it a default that exists in the parent, populate the column for existing rows before syncing, insert the missing parent rows, or clear the child table. No data was changed — korm aborted before altering the table.",context:{table:e,offenders:t}})},KormError.CODES=CODES,KormError.ACTIONS=ACTIONS,KormError.closestAction=closestAction,module.exports=KormError;
|
package/README.md
CHANGED
|
@@ -108,13 +108,20 @@ Block-insert targets (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.github/copilot-in
|
|
|
108
108
|
```javascript
|
|
109
109
|
require('dotenv').config();
|
|
110
110
|
const express = require('express');
|
|
111
|
-
const {
|
|
111
|
+
const {
|
|
112
|
+
initializeKORM,
|
|
113
|
+
validate,
|
|
114
|
+
helperUtility,
|
|
115
|
+
recommendedPoolConfig,
|
|
116
|
+
} = require('@dreamtree-org/korm-js');
|
|
112
117
|
const knex = require('knex');
|
|
113
118
|
|
|
114
119
|
const app = express();
|
|
115
120
|
app.use(express.json());
|
|
116
121
|
|
|
117
|
-
// MySQL database configuration
|
|
122
|
+
// MySQL database configuration.
|
|
123
|
+
// Build this ONCE at module scope — never inside a request handler.
|
|
124
|
+
// See "Connection management & pooling" for why the pool block matters.
|
|
118
125
|
const db = knex({
|
|
119
126
|
client: 'mysql2',
|
|
120
127
|
connection: {
|
|
@@ -123,7 +130,10 @@ const db = knex({
|
|
|
123
130
|
password: process.env.DB_PASS || 'password',
|
|
124
131
|
database: process.env.DB_NAME || 'my_database',
|
|
125
132
|
port: process.env.DB_PORT || 3306,
|
|
133
|
+
enableKeepAlive: true,
|
|
134
|
+
keepAliveInitialDelay: 10000,
|
|
126
135
|
},
|
|
136
|
+
...recommendedPoolConfig(),
|
|
127
137
|
});
|
|
128
138
|
|
|
129
139
|
// Initialize KORM with MySQL
|
|
@@ -710,6 +720,7 @@ POST /api/Users/crud
|
|
|
710
720
|
| `!=` | Not equal | `!value` | `"status": "!deleted"` |
|
|
711
721
|
| `like` | Pattern matching (auto) | `%value%` | `"name": "%john%"` |
|
|
712
722
|
| `in` | Value in list | `[]val1,val2` | `"status": "[]active,pending"` |
|
|
723
|
+
| `in` (bare) | Bare array → IN | `[val1,val2]` | `"id": [1,2,3]` |
|
|
713
724
|
| `notIn` | Value not in list | `![]val1,val2` | `"role": "![]banned,suspended"` |
|
|
714
725
|
| `between` | Range (inclusive) | `><min,max` | `"age": "><18,65"` |
|
|
715
726
|
| `notBetween` | Outside range | `<>min,max` | `"score": "<>0,50"` |
|
|
@@ -735,6 +746,11 @@ POST /api/Users/crud
|
|
|
735
746
|
// AND role IN ('admin', 'moderator', 'editor') AND score BETWEEN 50 AND 100
|
|
736
747
|
```
|
|
737
748
|
|
|
749
|
+
All where operators work identically on `list`, `show`, `count`, `sum`,
|
|
750
|
+
`update`, `delete`, `restore`, and `sync` — same grammar everywhere.
|
|
751
|
+
`update` / `delete` / `restore` require a non-empty `where` clause
|
|
752
|
+
(preventing accidental full-table mutations).
|
|
753
|
+
|
|
738
754
|
### Sorting (orderBy)
|
|
739
755
|
|
|
740
756
|
```javascript
|
|
@@ -1332,7 +1348,8 @@ POST /api/Users/crud
|
|
|
1332
1348
|
"where": { "id": 1 }
|
|
1333
1349
|
}
|
|
1334
1350
|
|
|
1335
|
-
// List
|
|
1351
|
+
// List, show, count, and sum operations automatically filter out soft-deleted
|
|
1352
|
+
// records (deleted_at IS NULL)
|
|
1336
1353
|
POST /api/Users/crud
|
|
1337
1354
|
{
|
|
1338
1355
|
"action": "list",
|
|
@@ -1340,8 +1357,16 @@ POST /api/Users/crud
|
|
|
1340
1357
|
// Automatically adds: deleted_at IS NULL
|
|
1341
1358
|
}
|
|
1342
1359
|
|
|
1360
|
+
// Restore a soft-deleted row back to active state
|
|
1361
|
+
POST /api/Users/crud
|
|
1362
|
+
{
|
|
1363
|
+
"action": "restore",
|
|
1364
|
+
"where": { "id": 1 }
|
|
1365
|
+
// Sets deleted_at to NULL and returns the restored row(s)
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1343
1368
|
// To see soft-deleted records, you need to query directly
|
|
1344
|
-
// (soft delete
|
|
1369
|
+
// (soft delete affects list, show, count, sum, and delete operations)
|
|
1345
1370
|
```
|
|
1346
1371
|
|
|
1347
1372
|
## Model Hooks
|
|
@@ -1605,6 +1630,11 @@ POST /api/Users/crud
|
|
|
1605
1630
|
}
|
|
1606
1631
|
```
|
|
1607
1632
|
|
|
1633
|
+
**Limits & safety:** `other_requests` enforces a maximum nesting depth of 10
|
|
1634
|
+
and a fan-out cap of 50 child requests to prevent request amplification.
|
|
1635
|
+
Nested requests also respect `authorize()` / `scope()` rules registered
|
|
1636
|
+
on the parent KORM instance.
|
|
1637
|
+
|
|
1608
1638
|
## Schema Structure
|
|
1609
1639
|
|
|
1610
1640
|
### Example Schema (Auto-generated from MySQL)
|
|
@@ -1753,14 +1783,26 @@ Seed data is automatically inserted when `syncDatabase()` is called and the tabl
|
|
|
1753
1783
|
```javascript
|
|
1754
1784
|
const { initializeKORM } = require('@dreamtree-org/korm-js');
|
|
1755
1785
|
|
|
1756
|
-
|
|
1786
|
+
// Schema as an inline object (existing behaviour):
|
|
1787
|
+
const korm = await initializeKORM({
|
|
1757
1788
|
db: db, // Knex database instance
|
|
1758
1789
|
dbClient: 'mysql', // 'mysql', 'mysql2', 'pg', 'postgresql', 'sqlite', 'sqlite3'
|
|
1759
|
-
schema: null, // Optional:
|
|
1790
|
+
schema: null, // Optional: schema object, or a string (file path / URL — see below)
|
|
1760
1791
|
resolverPath: null, // Optional: path to models directory (default: process.cwd())
|
|
1761
1792
|
debug: false, // Optional: enable SQL debugging (default: false)
|
|
1762
1793
|
});
|
|
1763
1794
|
|
|
1795
|
+
// Schema auto-resolved from a string (path or URL):
|
|
1796
|
+
// "./schema.json" → read & JSON.parse
|
|
1797
|
+
// "./schema.js" → require() (CJS: module.exports = {…})
|
|
1798
|
+
// "./schema.mjs" → dynamic import() (ESM: export default {…})
|
|
1799
|
+
// "https://api.example.com/schema" → fetch & JSON.parse
|
|
1800
|
+
const korm2 = await initializeKORM({
|
|
1801
|
+
db, dbClient: 'sqlite',
|
|
1802
|
+
schema: './schema.json',
|
|
1803
|
+
});
|
|
1804
|
+
```
|
|
1805
|
+
|
|
1764
1806
|
// Process any CRUD request (automatically handles other_requests if present)
|
|
1765
1807
|
const result = await korm.processRequest(requestBody, modelName, context);
|
|
1766
1808
|
|
|
@@ -1789,7 +1831,7 @@ const modelInstance = korm.getModelInstance(modelDef);
|
|
|
1789
1831
|
|
|
1790
1832
|
| Parameter | Type | Description |
|
|
1791
1833
|
| ---------------- | -------------------- | ------------------------------------------------------------------------------------ |
|
|
1792
|
-
| `action` | string | Action to perform (list, show, create, update, delete, count, replace, upsert, sync) |
|
|
1834
|
+
| `action` | string | Action to perform (list, show, create, update, delete, count, replace, upsert, sync, restore) |
|
|
1793
1835
|
| `where` | object/array | Filter conditions |
|
|
1794
1836
|
| `data` | object/array | Data for create/update operations |
|
|
1795
1837
|
| `select` | array/string | Columns to select |
|
|
@@ -1822,7 +1864,8 @@ const modelInstance = korm.getModelInstance(modelDef);
|
|
|
1822
1864
|
| `sum` | Sum column or expression | `data.sumColumn` or `data.sumFormula` (optional: `where`) |
|
|
1823
1865
|
| `replace` | Replace full row by PK (all engines; pg = merge, see §8) | `data` (optional: `conflict`) |
|
|
1824
1866
|
| `upsert` | Insert or update | `data`, `conflict` |
|
|
1825
|
-
| `sync`
|
|
1867
|
+
| `sync` | Upsert + delete | `data`, `conflict`, `where` |
|
|
1868
|
+
| `restore` | Restore soft-deleted row(s) | `where` (model must have soft-delete enabled) |
|
|
1826
1869
|
|
|
1827
1870
|
### Validation Rules
|
|
1828
1871
|
|
|
@@ -2106,6 +2149,187 @@ const korm = initializeKORM({
|
|
|
2106
2149
|
|
|
2107
2150
|
**Note:** The `sqlDebug` field is only included in responses when `debug: true`. In production, set `debug: false` to exclude SQL statements from responses.
|
|
2108
2151
|
|
|
2152
|
+
## Connection management & pooling
|
|
2153
|
+
|
|
2154
|
+
KORM does not create or own database connections. You build one Knex instance
|
|
2155
|
+
and hand it to `initializeKORM({ db })`; KORM borrows a connection from that
|
|
2156
|
+
pool for each query and returns it. Everything below is about keeping that pool
|
|
2157
|
+
healthy — misconfiguring it is the most common production problem in KORM apps.
|
|
2158
|
+
|
|
2159
|
+
### The three failure modes
|
|
2160
|
+
|
|
2161
|
+
| Symptom | Cause |
|
|
2162
|
+
| --- | --- |
|
|
2163
|
+
| `PROTOCOL_CONNECTION_LOST`, `ECONNRESET`, `Connection lost: The server closed the connection` | A pooled socket sat idle past the server's `wait_timeout`, the server closed it, and the pool handed the dead socket to the next query. |
|
|
2164
|
+
| `Knex: Timeout acquiring a connection. The pool is probably full.` | Every connection in the pool is checked out — usually a fan-out or a slow query holding connections. |
|
|
2165
|
+
| `Too many connections` (`ER_CON_COUNT_ERROR`) | `processes × pool.max` exceeded the server's `max_connections`. |
|
|
2166
|
+
|
|
2167
|
+
They compound: stale sockets cause failed requests, failed requests get retried,
|
|
2168
|
+
retries fill the pool, and a full pool across many processes exhausts the server.
|
|
2169
|
+
|
|
2170
|
+
### Recommended configuration
|
|
2171
|
+
|
|
2172
|
+
```js
|
|
2173
|
+
const knex = require('knex');
|
|
2174
|
+
const { initializeKORM, recommendedPoolConfig } = require('@dreamtree-org/korm-js');
|
|
2175
|
+
|
|
2176
|
+
const db = knex({
|
|
2177
|
+
client: 'mysql2',
|
|
2178
|
+
connection: {
|
|
2179
|
+
host: process.env.DB_HOST,
|
|
2180
|
+
user: process.env.DB_USER,
|
|
2181
|
+
password: process.env.DB_PASS,
|
|
2182
|
+
database: process.env.DB_NAME,
|
|
2183
|
+
port: Number(process.env.DB_PORT) || 3306,
|
|
2184
|
+
enableKeepAlive: true,
|
|
2185
|
+
keepAliveInitialDelay: 10000,
|
|
2186
|
+
connectTimeout: 10000,
|
|
2187
|
+
},
|
|
2188
|
+
...recommendedPoolConfig(),
|
|
2189
|
+
});
|
|
2190
|
+
```
|
|
2191
|
+
|
|
2192
|
+
`recommendedPoolConfig()` expands to:
|
|
2193
|
+
|
|
2194
|
+
```js
|
|
2195
|
+
{
|
|
2196
|
+
pool: {
|
|
2197
|
+
min: 0,
|
|
2198
|
+
max: 10,
|
|
2199
|
+
idleTimeoutMillis: 30000,
|
|
2200
|
+
reapIntervalMillis: 1000,
|
|
2201
|
+
acquireTimeoutMillis: 30000,
|
|
2202
|
+
createTimeoutMillis: 30000,
|
|
2203
|
+
propagateCreateError: false,
|
|
2204
|
+
},
|
|
2205
|
+
}
|
|
2206
|
+
```
|
|
2207
|
+
|
|
2208
|
+
**`min: 0` is the important one.** Knex's pool only reaps idle connections
|
|
2209
|
+
_down to_ `min`, so any non-zero minimum guarantees connections that sit idle
|
|
2210
|
+
forever — exactly the long-`Sleep` rows you see in `SHOW PROCESSLIST`, and
|
|
2211
|
+
exactly the ones the server eventually kills out from under you. With `min: 0`
|
|
2212
|
+
and `idleTimeoutMillis` well below the server's `wait_timeout`, KORM always
|
|
2213
|
+
closes a connection before the server does.
|
|
2214
|
+
|
|
2215
|
+
Size `max` against your server: **`processes × pool.max` must stay under
|
|
2216
|
+
`max_connections`**. Count every replica, container, and PM2 cluster worker.
|
|
2217
|
+
|
|
2218
|
+
`recommendedPoolConfig('sqlite3')` returns a single-connection pool instead,
|
|
2219
|
+
since SQLite is a file handle rather than a network service.
|
|
2220
|
+
|
|
2221
|
+
### One pool per process
|
|
2222
|
+
|
|
2223
|
+
```js
|
|
2224
|
+
// db.js — module scope, evaluated once
|
|
2225
|
+
const db = knex({ ... });
|
|
2226
|
+
const kormPromise = initializeKORM({ db, dbClient: 'mysql', schema });
|
|
2227
|
+
|
|
2228
|
+
module.exports = { db, getKorm: () => kormPromise };
|
|
2229
|
+
```
|
|
2230
|
+
|
|
2231
|
+
Never call `knex()` or `initializeKORM()` inside a request handler — each call
|
|
2232
|
+
opens a whole new pool that is never closed. On Next.js, guard the singleton
|
|
2233
|
+
against hot reload:
|
|
2234
|
+
|
|
2235
|
+
```js
|
|
2236
|
+
const globalForKnex = globalThis;
|
|
2237
|
+
const db = globalForKnex.__kormKnex || (globalForKnex.__kormKnex = knex({ ... }));
|
|
2238
|
+
```
|
|
2239
|
+
|
|
2240
|
+
KORM warns at startup (through its logger, at `warn` level) when it is handed a
|
|
2241
|
+
pool shape known to go stale, and once when it has been handed more than five
|
|
2242
|
+
distinct Knex instances in one process — the signature of a per-request leak.
|
|
2243
|
+
|
|
2244
|
+
### Health checks and shutdown
|
|
2245
|
+
|
|
2246
|
+
```js
|
|
2247
|
+
const korm = await initializeKORM({ db, dbClient: 'mysql', schema });
|
|
2248
|
+
|
|
2249
|
+
// Liveness probe. Never throws — resolves { ok: false, error } on failure.
|
|
2250
|
+
app.get('/healthz', async (req, res) => {
|
|
2251
|
+
const { ok, error } = await korm.ping();
|
|
2252
|
+
res.status(ok ? 200 : 503).json({ db: ok, error });
|
|
2253
|
+
});
|
|
2254
|
+
|
|
2255
|
+
// Drain the pool on shutdown instead of leaving sockets for the server to
|
|
2256
|
+
// time out. Idempotent and non-throwing.
|
|
2257
|
+
for (const signal of ['SIGTERM', 'SIGINT']) {
|
|
2258
|
+
process.on(signal, async () => {
|
|
2259
|
+
await korm.destroy();
|
|
2260
|
+
process.exit(0);
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
```
|
|
2264
|
+
|
|
2265
|
+
After `destroy()`, `korm.isDestroyed` is `true` and further `processRequest`
|
|
2266
|
+
calls throw a `KormError` instead of crashing inside the closed pool.
|
|
2267
|
+
|
|
2268
|
+
### Automatic retry on dropped connections
|
|
2269
|
+
|
|
2270
|
+
When a query fails because the connection was dropped rather than because the
|
|
2271
|
+
query was wrong, KORM replays it on a fresh connection.
|
|
2272
|
+
|
|
2273
|
+
```js
|
|
2274
|
+
const korm = await initializeKORM({
|
|
2275
|
+
db,
|
|
2276
|
+
dbClient: 'mysql',
|
|
2277
|
+
schema,
|
|
2278
|
+
retry: { attempts: 2, backoffMs: 100, writes: false }, // this is the default
|
|
2279
|
+
});
|
|
2280
|
+
```
|
|
2281
|
+
|
|
2282
|
+
| Option | Default | Meaning |
|
|
2283
|
+
| --- | --- | --- |
|
|
2284
|
+
| `attempts` | `2` | Total attempts, including the first. |
|
|
2285
|
+
| `backoffMs` | `100` | Linear backoff — attempt _N_ waits _N_ × `backoffMs`. |
|
|
2286
|
+
| `writes` | `false` | Whether write actions may be replayed. |
|
|
2287
|
+
|
|
2288
|
+
Pass `retry: false` to disable it entirely.
|
|
2289
|
+
|
|
2290
|
+
**Reads are replayed; writes are not.** A `create` that actually reached the
|
|
2291
|
+
database before the socket died would be inserted twice by a blind replay, so
|
|
2292
|
+
writes are retried only when you explicitly set `writes: true`. `dryRun`
|
|
2293
|
+
requests are always safe to replay because they execute nothing. A request whose
|
|
2294
|
+
nested `other_requests` contain a write is treated as a write.
|
|
2295
|
+
|
|
2296
|
+
Only genuine connection failures are retried — `ECONNRESET`, `EPIPE`,
|
|
2297
|
+
`ETIMEDOUT`, `PROTOCOL_CONNECTION_LOST`, PostgreSQL `57P01`/`08006`, SQLite
|
|
2298
|
+
`SQLITE_BUSY`. A real query error (bad column, constraint violation) is thrown
|
|
2299
|
+
immediately and never retried. Pool-acquire timeouts are deliberately _not_
|
|
2300
|
+
retried: replaying against an exhausted pool makes the exhaustion worse.
|
|
2301
|
+
|
|
2302
|
+
### Fan-out and the pool
|
|
2303
|
+
|
|
2304
|
+
Each entry in [`other_requests`](#nested-requests) runs as its own query and
|
|
2305
|
+
checks out its own connection. KORM caps how many run at once at
|
|
2306
|
+
`min(5, pool.max - 1)`, leaving a connection spare for the parent query, which
|
|
2307
|
+
may still be holding a transaction. Override it with `fanOutConcurrency`:
|
|
2308
|
+
|
|
2309
|
+
```js
|
|
2310
|
+
initializeKORM({ db, dbClient: 'mysql', schema, fanOutConcurrency: 3 });
|
|
2311
|
+
```
|
|
2312
|
+
|
|
2313
|
+
Raising this above `pool.max - 1` is how a nested request turns into
|
|
2314
|
+
`Timeout acquiring a connection`.
|
|
2315
|
+
|
|
2316
|
+
### Diagnosing a live server
|
|
2317
|
+
|
|
2318
|
+
```sql
|
|
2319
|
+
-- MySQL: who is holding connections, and for how long?
|
|
2320
|
+
SELECT host, db, COUNT(*) AS conns, MAX(time) AS idle_seconds
|
|
2321
|
+
FROM information_schema.processlist
|
|
2322
|
+
WHERE command = 'Sleep'
|
|
2323
|
+
GROUP BY host, db
|
|
2324
|
+
ORDER BY conns DESC;
|
|
2325
|
+
|
|
2326
|
+
SELECT @@max_connections, @@wait_timeout, @@interactive_timeout;
|
|
2327
|
+
```
|
|
2328
|
+
|
|
2329
|
+
Many same-age idle connections from one host means `pool.min > 0`, or several
|
|
2330
|
+
pools inside one deployment. `idle_seconds` climbing toward `wait_timeout` is
|
|
2331
|
+
the staleness window you are about to fall into.
|
|
2332
|
+
|
|
2109
2333
|
## Database Support
|
|
2110
2334
|
|
|
2111
2335
|
### MySQL (Current Guide)
|
package/RequestValidator.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const{processRequest:processRequest}=require("./ControllerWrapper"),HelperUtility=require("./BaseHelperUtility"),KormError=require("./KormError");class ValidationError extends Error{constructor(e,t,a,r){super(e),this.name="ValidationError",this.field=t,this.value=a,this.rule=r,this.timestamp=(new Date).toISOString()}}class RequestValidator{constructor(){this.rules=new Map,this.customMessages=new Map,this.transformers=new Map,this.customRegex=new Map,this.customCallbacks=new Map,this.helperUtility=new HelperUtility}rule(e,t,a=null){return this.rules.has(e)||this.rules.set(e,[]),this.rules.get(e).push(t),a&&this.customMessages.set(`${e}.${t.type}`,a),this}string(e,t=null){return this.rule(e,{type:"string",validator:e=>"string"==typeof e},t)}number(e,t=null){return this.rule(e,{type:"number",validator:e=>"number"==typeof e&&!isNaN(e)},t)}boolean(e,t=null){return this.rule(e,{type:"boolean",validator:e=>"boolean"==typeof e},t)}required(e,t=null){return this.rule(e,{type:"required",validator:e=>null!=e&&""!==e},t)}email(e,t=null){const a=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;return this.rule(e,{type:"email",validator:e=>a.test(e)},t)}url(e,t=null){return this.rule(e,{type:"url",validator:e=>{try{return new URL(e),!0}catch{return!1}}},t)}minLength(e,t,a=null){return this.rule(e,{type:"minLength",validator:e=>String(e).length>=t,params:{min:t}},a)}maxLength(e,t,a=null){return this.rule(e,{type:"maxLength",validator:e=>String(e).length<=t,params:{max:t}},a)}min(e,t,a=null){return this.rule(e,{type:"min",validator:e=>Number(e)>=t,params:{min:t}},a)}max(e,t,a=null){return this.rule(e,{type:"max",validator:e=>Number(e)<=t,params:{max:t}},a)}enum(e,t,a=null){return this.rule(e,{type:"enum",validator:e=>t.includes(e),params:{allowedValues:t}},a)}regex(e,t,a=null){return this.rule(e,{type:"regex",validator:e=>t.test(e),params:{pattern:t}},a)}uuid(e,t=null){const a=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;return this.rule(e,{type:"uuid",validator:e=>a.test(e)},t)}date(e,t=null){return this.rule(e,{type:"date",validator:e=>!isNaN(Date.parse(e))},t)}array(e,t=null){return this.rule(e,{type:"array",validator:e=>Array.isArray(e)},t)}object(e,t=null){return this.rule(e,{type:"object",validator:e=>"object"==typeof e&&null!==e&&!Array.isArray(e)},t)}custom(e,t,a=null){return this.rule(e,{type:"custom",validator:t},a)}transform(e,t){return this.transformers.set(e,t),this}message(e,t,a){return this.customMessages.set(`${e}.${t}`,a),this}getDefaultMessage(e,t,a,r={}){return{required:`${e} is required`,string:`${e} must be a string`,number:`${e} must be a number`,boolean:`${e} must be a boolean`,email:`${e} must be a valid email address`,url:`${e} must be a valid URL`,minLength:`${e} must be at least ${r.min} characters long`,maxLength:`${e} must be at most ${r.max} characters long`,min:`${e} must be at least ${r.min}`,max:`${e} must be at most ${r.max}`,enum:`${e} must be one of: ${r.allowedValues?.join(", ")}`,regex:`${e} format is invalid`,uuid:`${e} must be a valid UUID`,date:`${e} must be a valid date`,array:`${e} must be an array`,object:`${e} must be an object`,custom:`${e} validation failed`}[t]||`${e} validation failed`}validateField(e,t){const a=this.rules.get(e)||[],r=[];let s=t;this.transformers.has(e)&&(s=this.transformers.get(e)(t));for(const t of a)try{if(!t.validator(s)){const a=this.customMessages.get(`${e}.${t.type}`)||this.getDefaultMessage(e,t.type,s,t.params);r.push(new ValidationError(a,e,s,t.type))}}catch(a){const i=this.customMessages.get(`${e}.${t.type}`)||this.getDefaultMessage(e,t.type,s,t.params);r.push(new ValidationError(i,e,s,t.type))}return{value:s,errors:r}}validate(e,t={}){const{source:a="body"}=t,r=[],s={};for(const[t,a]of this.rules){const a=e[t],{value:i,errors:n}=this.validateField(t,a);n.length>0?r.push(...n):s[t]=i}if(r.length>0)throw KormError.validationFailed({errors:r,source:a});return s}validateParams(e){return this.validate(e,{source:"params"})}validateBody(e){return this.validate(e,{source:"body"})}validateQuery(e){return this.validate(e,{source:"query"})}validateRequest(e){const t={params:{},body:{},query:{}};try{e.params&&(t.params=this.validateParams(e.params))}catch(e){t.params={error:e}}try{e.body&&(t.body=this.validateBody(e.body))}catch(e){t.body={error:e}}try{e.query&&(t.query=this.validateQuery(e.query))}catch(e){t.query={error:e}}return t}static create(){return new RequestValidator}static schema(e){const t=new RequestValidator;for(const[a,r]of Object.entries(e))Array.isArray(r)?r.forEach(e=>{"string"==typeof e?t[e](a):"object"==typeof e&&t.rule(a,e)}):"string"==typeof r?t[r](a):"object"==typeof r&&t.rule(a,r);return t}parseRuleString(e){const t=[],a=e.split("|");for(const e of a){const a=e.trim();if(a)if("required"===a)t.push({type:"required"});else if(a.startsWith("type:")){const e=a.substring(5).replace(/[()]/g,"").split(",");t.push({type:"type",params:e})}else if(a.startsWith("maxLen:")){const e=parseInt(a.substring(7));isNaN(e)||t.push({type:"maxLength",params:{max:e}})}else if(a.startsWith("minLen:")){const e=parseInt(a.substring(7));isNaN(e)||t.push({type:"minLength",params:{min:e}})}else if(a.startsWith("max:")){const e=parseInt(a.substring(4));isNaN(e)||t.push({type:"max",params:{max:e}})}else if(a.startsWith("min:")){const e=parseInt(a.substring(4));isNaN(e)||t.push({type:"min",params:{min:e}})}else if(a.startsWith("in:")){const e=a.substring(3).split(",");t.push({type:"in",params:{values:e}})}else if(a.startsWith("exists:")){const[e,r]=a.substring(7).split(",");e&&r&&t.push({type:"exists",params:{table:e,field:r}})}else if(a.startsWith("regex:")){const e=a.substring(6).replace(/[{}]/g,"");e&&t.push({type:"regex",params:{regexName:e}})}else if(a.startsWith("default:")){const e=a.substring(8);void 0!==e&&t.push({type:"default",params:{value:e}})}else if(a.startsWith("call:")){const e=a.substring(5).replace(/[{}]/g,"");e&&t.push({type:"call",params:{callbackName:e}})}}return t}addRegex(e,t){return this.customRegex.set(e,new RegExp(t)),this}addCallback(e,t){return this.customCallbacks.set(e,t),this}async validateWithRules(e,t,a={}){const{customRegex:r={},customCallbacks:s={}}=a;for(const[e,t]of Object.entries(r))this.addRegex(e,t);for(const[e,t]of Object.entries(s))this.addCallback(e,t);const i=[],n={};for(const[a,r]of Object.entries(t)){const t=this.parseRuleString(r),s=t.find(e=>"default"===e.type);let l=e[a];(a.includes(".")||a.includes("[]"))&&(l=this.helperUtility.dotParse(a,e));let u=l;!s||null!=l&&""!==l||(u=s.params.value);let o=!0;if(t.some(e=>"required"===e.type)||null!=u&&""!==u){for(const e of t){if("default"===e.type)continue;const t=await this.validateRule(a,u,e);if(!t.isValid){i.push(t.error),o=!1;break}}o&&(n[a]=u)}}if(i.length>0)throw KormError.validationFailed({errors:i});return n}async validateRule(e,t,a){try{let r=!0,s="";switch(a.type){case"required":r=null!=t&&""!==t,s=r?"":`${e} is required`;break;case"type":const i=a.params;r=i.some(e=>{switch(e){case"string":return"string"==typeof t;case"number":return"number"==typeof t&&!isNaN(t);case"boolean":return"boolean"==typeof t;case"array":return Array.isArray(t);case"object":return"object"==typeof t&&null!==t&&!Array.isArray(t);case"longText":return"string"==typeof t&&t.length>255;default:return!1}}),s=r?"":`${e} must be one of: ${i.join(", ")}`;break;case"maxLength":"string"==typeof t?(r=String(t).length<=a.params.max,s=r?"":`${e} must be at most ${a.params.max} characters long`):Array.isArray(t)&&(r=t.length<=a.params.max,s=r?"":`${e} must be at most ${a.params.max} items`);break;case"minLength":"string"==typeof t?(r=String(t).length>=a.params.min,s=r?"":`${e} must be at least ${a.params.min} characters long`):Array.isArray(t)&&(r=t.length>=a.params.min,s=r?"":`${e} must be at least ${a.params.min} items`);break;case"max":r=Number(t)<=a.params.max,s=r?"":`${e} must be at most ${a.params.max}`;break;case"min":r=Number(t)>=a.params.min,s=r?"":`${e} must be at least ${a.params.min}`;break;case"in":try{if(a.params.values){const i=a.params.values;r=i.includes(t),s=r?"":`${e} must be one of: ${i.join(", ")}`}else{const{table:i,field:n}=a.params,l=await processRequest({model:i,action:"get",request:{where:{[n]:t},limit:1}});r=l&&l.data&&l.data.length>0,s=r?"":`${e} must be a valid value from ${i}`}}catch(t){r=!1,s=`${e} database validation error: ${t.message}`}break;case"exists":try{const{table:i,field:n}=a.params,l=await processRequest({model:i,action:"get",request:{where:{[n]:t},limit:1}});r=l&&l.data&&l.data.length>0,s=r?"":`${e} must exist in ${i}`}catch(t){r=!1,s=`${e} database validation error: ${t.message}`}break;case"regex":const n=a.params.regexName,l=this.customRegex.get(n);l?(r=l.test(t),s=r?"":`${e} format is invalid`):(r=!1,s=`${e} regex pattern '${n}' not found`);break;case"call":const u=a.params.callbackName,o=this.customCallbacks.get(u);if(o&&"function"==typeof o)try{r=o(t),s=r?"":`${e} validation failed`}catch(t){r=!1,s=`${e} validation error: ${t.message}`}else r=!1,s=`${e} callback function '${u}' not found`;break;default:r=!1,s=`${e} unknown validation rule: ${a.type}`}return{isValid:r,error:r?null:new ValidationError(s,e,t,a.type)}}catch(r){return{isValid:!1,error:new ValidationError(`${e} validation failed`,e,t,a.type)}}}}async function validate(e,t,a={}){const r=new RequestValidator;let s={};return!e||"object"!=typeof e||e.body||e.params||e.query?(e.body&&(s={...s,...e.body}),e.params&&(s={...s,...e.params}),e.query&&(s={...s,...e.query})):s=e,await r.validateWithRules(s,t,a)}function validateEmail(e){return/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)}function validatePassword(e){return/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/.test(e)}function validatePhone(e){return/^\+?[\d\s-()]{10,15}$/.test(e)}function validatePAN(e){return/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(e)}function validateAadhaar(e){return/^\d{12}$/.test(e)&&!/^0{12}$/.test(e)}function createValidationMiddleware(e,t={}){return async(a,r,s)=>{try{const i=await validate(a,e,t);if(!1===i.success)return r.status(400).json(i);a.validated=i,s()}catch(e){return r.status(500).json({success:!1,reason:"Validation middleware error",error:e.message})}}}module.exports={RequestValidator:RequestValidator,validate:validate,createValidationMiddleware:createValidationMiddleware,validateEmail:validateEmail,validatePassword:validatePassword,validatePhone:validatePhone,validatePAN:validatePAN,validateAadhaar:validateAadhaar};
|
|
1
|
+
const HelperUtility=require("./BaseHelperUtility"),KormError=require("./KormError");class ValidationError extends Error{constructor(t,e,a,r){super(t),this.name="ValidationError",this.field=e,this.value=a,this.rule=r,this.timestamp=(new Date).toISOString()}}class RequestValidator{constructor(){this.rules=new Map,this.customMessages=new Map,this.transformers=new Map,this.customRegex=new Map,this.customCallbacks=new Map,this.helperUtility=new HelperUtility}rule(t,e,a=null){return this.rules.has(t)||this.rules.set(t,[]),this.rules.get(t).push(e),a&&this.customMessages.set(`${t}.${e.type}`,a),this}string(t,e=null){return this.rule(t,{type:"string",validator:t=>"string"==typeof t},e)}number(t,e=null){return this.rule(t,{type:"number",validator:t=>"number"==typeof t&&!isNaN(t)},e)}boolean(t,e=null){return this.rule(t,{type:"boolean",validator:t=>"boolean"==typeof t},e)}required(t,e=null){return this.rule(t,{type:"required",validator:t=>null!=t&&""!==t},e)}email(t,e=null){const a=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;return this.rule(t,{type:"email",validator:t=>a.test(t)},e)}url(t,e=null){return this.rule(t,{type:"url",validator:t=>{try{return new URL(t),!0}catch{return!1}}},e)}minLength(t,e,a=null){return this.rule(t,{type:"minLength",validator:t=>String(t).length>=e,params:{min:e}},a)}maxLength(t,e,a=null){return this.rule(t,{type:"maxLength",validator:t=>String(t).length<=e,params:{max:e}},a)}min(t,e,a=null){return this.rule(t,{type:"min",validator:t=>Number(t)>=e,params:{min:e}},a)}max(t,e,a=null){return this.rule(t,{type:"max",validator:t=>Number(t)<=e,params:{max:e}},a)}enum(t,e,a=null){return this.rule(t,{type:"enum",validator:t=>e.includes(t),params:{allowedValues:e}},a)}regex(t,e,a=null){return this.rule(t,{type:"regex",validator:t=>e.test(t),params:{pattern:e}},a)}uuid(t,e=null){const a=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;return this.rule(t,{type:"uuid",validator:t=>a.test(t)},e)}date(t,e=null){return this.rule(t,{type:"date",validator:t=>!isNaN(Date.parse(t))},e)}array(t,e=null){return this.rule(t,{type:"array",validator:t=>Array.isArray(t)},e)}object(t,e=null){return this.rule(t,{type:"object",validator:t=>"object"==typeof t&&null!==t&&!Array.isArray(t)},e)}custom(t,e,a=null){return this.rule(t,{type:"custom",validator:e},a)}transform(t,e){return this.transformers.set(t,e),this}message(t,e,a){return this.customMessages.set(`${t}.${e}`,a),this}getDefaultMessage(t,e,a,r={}){return{required:`${t} is required`,string:`${t} must be a string`,number:`${t} must be a number`,boolean:`${t} must be a boolean`,email:`${t} must be a valid email address`,url:`${t} must be a valid URL`,minLength:`${t} must be at least ${r.min} characters long`,maxLength:`${t} must be at most ${r.max} characters long`,min:`${t} must be at least ${r.min}`,max:`${t} must be at most ${r.max}`,enum:`${t} must be one of: ${r.allowedValues?.join(", ")}`,regex:`${t} format is invalid`,uuid:`${t} must be a valid UUID`,date:`${t} must be a valid date`,array:`${t} must be an array`,object:`${t} must be an object`,custom:`${t} validation failed`}[e]||`${t} validation failed`}validateField(t,e){const a=this.rules.get(t)||[],r=[];let s=e;this.transformers.has(t)&&(s=this.transformers.get(t)(e));for(const e of a)try{if(!e.validator(s)){const a=this.customMessages.get(`${t}.${e.type}`)||this.getDefaultMessage(t,e.type,s,e.params);r.push(new ValidationError(a,t,s,e.type))}}catch(a){const i=this.customMessages.get(`${t}.${e.type}`)||this.getDefaultMessage(t,e.type,s,e.params);r.push(new ValidationError(i,t,s,e.type))}return{value:s,errors:r}}validate(t,e={}){const{source:a="body"}=e,r=[],s={};for(const[e,a]of this.rules){const a=t[e],{value:i,errors:n}=this.validateField(e,a);n.length>0?r.push(...n):s[e]=i}if(r.length>0)throw KormError.validationFailed({errors:r,source:a});return s}validateParams(t){return this.validate(t,{source:"params"})}validateBody(t){return this.validate(t,{source:"body"})}validateQuery(t){return this.validate(t,{source:"query"})}validateRequest(t){const e={params:{},body:{},query:{}};try{t.params&&(e.params=this.validateParams(t.params))}catch(t){e.params={error:t}}try{t.body&&(e.body=this.validateBody(t.body))}catch(t){e.body={error:t}}try{t.query&&(e.query=this.validateQuery(t.query))}catch(t){e.query={error:t}}return e}static create(){return new RequestValidator}static schema(t){const e=new RequestValidator;for(const[a,r]of Object.entries(t))Array.isArray(r)?r.forEach(t=>{"string"==typeof t?e[t](a):"object"==typeof t&&e.rule(a,t)}):"string"==typeof r?e[r](a):"object"==typeof r&&e.rule(a,r);return e}parseRuleString(t){const e=[],a=t.split("|");for(const t of a){const a=t.trim();if(a)if("required"===a)e.push({type:"required"});else if(a.startsWith("type:")){const t=a.substring(5).replace(/[()]/g,"").split(",");e.push({type:"type",params:t})}else if(a.startsWith("maxLen:")){const t=parseInt(a.substring(7));isNaN(t)||e.push({type:"maxLength",params:{max:t}})}else if(a.startsWith("minLen:")){const t=parseInt(a.substring(7));isNaN(t)||e.push({type:"minLength",params:{min:t}})}else if(a.startsWith("max:")){const t=parseInt(a.substring(4));isNaN(t)||e.push({type:"max",params:{max:t}})}else if(a.startsWith("min:")){const t=parseInt(a.substring(4));isNaN(t)||e.push({type:"min",params:{min:t}})}else if(a.startsWith("in:")){const t=a.substring(3).split(",");e.push({type:"in",params:{values:t}})}else if(a.startsWith("exists:")){const[t,r]=a.substring(7).split(",");t&&r&&e.push({type:"exists",params:{table:t,field:r}})}else if(a.startsWith("regex:")){const t=a.substring(6).replace(/[{}]/g,"");t&&e.push({type:"regex",params:{regexName:t}})}else if(a.startsWith("default:")){const t=a.substring(8);void 0!==t&&e.push({type:"default",params:{value:t}})}else if(a.startsWith("call:")){const t=a.substring(5).replace(/[{}]/g,"");t&&e.push({type:"call",params:{callbackName:t}})}}return e}addRegex(t,e){return this.customRegex.set(t,new RegExp(e)),this}addCallback(t,e){return this.customCallbacks.set(t,e),this}async validateWithRules(t,e,a={}){const{customRegex:r={},customCallbacks:s={}}=a;this._options=a;for(const[t,e]of Object.entries(r))this.addRegex(t,e);for(const[t,e]of Object.entries(s))this.addCallback(t,e);const i=[],n={};for(const[a,r]of Object.entries(e)){const e=this.parseRuleString(r),s=e.find(t=>"default"===t.type);let l=t[a];(a.includes(".")||a.includes("[]"))&&(l=this.helperUtility.dotParse(a,t));let o=l;!s||null!=l&&""!==l||(o=s.params.value);let u=!0;if(e.some(t=>"required"===t.type)||null!=o&&""!==o){for(const t of e){if("default"===t.type)continue;const e=await this.validateRule(a,o,t);if(!e.isValid){i.push(e.error),u=!1;break}}u&&(n[a]=o)}}if(i.length>0)throw KormError.validationFailed({errors:i});return n}async validateRule(t,e,a){try{let r=!0,s="";switch(a.type){case"required":r=null!=e&&""!==e,s=r?"":`${t} is required`;break;case"type":const i=a.params;r=i.some(t=>{switch(t){case"string":return"string"==typeof e;case"number":return"number"==typeof e&&!isNaN(e);case"boolean":return"boolean"==typeof e;case"array":return Array.isArray(e);case"object":return"object"==typeof e&&null!==e&&!Array.isArray(e);case"longText":return"string"==typeof e&&e.length>255;default:return!1}}),s=r?"":`${t} must be one of: ${i.join(", ")}`;break;case"maxLength":"string"==typeof e?(r=String(e).length<=a.params.max,s=r?"":`${t} must be at most ${a.params.max} characters long`):Array.isArray(e)&&(r=e.length<=a.params.max,s=r?"":`${t} must be at most ${a.params.max} items`);break;case"minLength":"string"==typeof e?(r=String(e).length>=a.params.min,s=r?"":`${t} must be at least ${a.params.min} characters long`):Array.isArray(e)&&(r=e.length>=a.params.min,s=r?"":`${t} must be at least ${a.params.min} items`);break;case"max":r=Number(e)<=a.params.max,s=r?"":`${t} must be at most ${a.params.max}`;break;case"min":r=Number(e)>=a.params.min,s=r?"":`${t} must be at least ${a.params.min}`;break;case"in":try{if(a.params.values){const i=a.params.values;r=i.includes(e),s=r?"":`${t} must be one of: ${i.join(", ")}`}else if(this._options?.dbQuery){const{table:i,field:n}=a.params,l=await this._options.dbQuery({action:"list",where:{[n]:e},limit:1},i);r=l&&l.data&&l.data.length>0,s=r?"":`${t} must be a valid value from ${i}`}}catch(e){if(e&&"VALIDATION_FAILED"===e.code)throw e;r=!1,s=`${t} database validation error: ${e.message}`}break;case"exists":try{if(this._options?.dbQuery){const{table:i,field:n}=a.params,l=await this._options.dbQuery({action:"list",where:{[n]:e},limit:1},i);r=l&&l.data&&l.data.length>0,s=r?"":`${t} must exist in ${i}`}}catch(e){if(e&&"VALIDATION_FAILED"===e.code)throw e;r=!1,s=`${t} database validation error: ${e.message}`}break;case"regex":const n=a.params.regexName,l=this.customRegex.get(n);l?(r=l.test(e),s=r?"":`${t} format is invalid`):(r=!1,s=`${t} regex pattern '${n}' not found`);break;case"call":const o=a.params.callbackName,u=this.customCallbacks.get(o);if(u&&"function"==typeof u)try{r=u(e),s=r?"":`${t} validation failed`}catch(e){r=!1,s=`${t} validation error: ${e.message}`}else r=!1,s=`${t} callback function '${o}' not found`;break;default:r=!1,s=`${t} unknown validation rule: ${a.type}`}return{isValid:r,error:r?null:new ValidationError(s,t,e,a.type)}}catch(r){return{isValid:!1,error:new ValidationError(`${t} validation failed`,t,e,a.type)}}}}async function validate(t,e,a={}){const r=new RequestValidator;let s={};return!t||"object"!=typeof t||t.body||t.params||t.query?(t.body&&(s={...s,...t.body}),t.params&&(s={...s,...t.params}),t.query&&(s={...s,...t.query})):s=t,await r.validateWithRules(s,e,a)}function validateEmail(t){return/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)}function validatePassword(t){return/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/.test(t)}function validatePhone(t){return/^\+?[\d\s-()]{10,15}$/.test(t)}function validatePAN(t){return/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(t)}function validateAadhaar(t){return/^\d{12}$/.test(t)&&!/^0{12}$/.test(t)}function createValidationMiddleware(t,e={}){return async(a,r,s)=>{try{const i=await validate(a,t,e);if(!1===i.success)return r.status(400).json(i);a.validated=i,s()}catch(t){return t&&"VALIDATION_FAILED"===t.code?r.status(400).json({success:!1,reason:"Validation failed",error:t.message,context:t.context}):r.status(500).json({success:!1,reason:"Validation middleware error",error:t.message})}}}module.exports={RequestValidator:RequestValidator,validate:validate,createValidationMiddleware:createValidationMiddleware,validateEmail:validateEmail,validatePassword:validatePassword,validatePhone:validatePhone,validatePAN:validatePAN,validateAadhaar:validateAadhaar};
|
package/ai-skills/korm-js.md
CHANGED
|
@@ -13,22 +13,39 @@ When helping the user, **always express data access as a KORM request object**,
|
|
|
13
13
|
## Wiring (do not invent alternatives)
|
|
14
14
|
|
|
15
15
|
```js
|
|
16
|
-
const { initializeKORM } = require('@dreamtree-org/korm-js');
|
|
16
|
+
const { initializeKORM, recommendedPoolConfig } = require('@dreamtree-org/korm-js');
|
|
17
17
|
const knex = require('knex');
|
|
18
18
|
|
|
19
|
+
// Build this ONCE at module scope. Never inside a request handler —
|
|
20
|
+
// each call opens a connection pool that is never closed.
|
|
19
21
|
const db = knex({
|
|
20
22
|
client: 'mysql2', // 'mysql2' | 'pg' | 'sqlite3'
|
|
21
23
|
connection: {
|
|
22
24
|
/* ... */
|
|
25
|
+
enableKeepAlive: true, // mysql2
|
|
26
|
+
keepAliveInitialDelay: 10000,
|
|
23
27
|
},
|
|
28
|
+
...recommendedPoolConfig(), // pool.min 0 + a 30s idle timeout — see below
|
|
24
29
|
});
|
|
25
30
|
|
|
26
|
-
const korm = initializeKORM({
|
|
31
|
+
const korm = await initializeKORM({
|
|
27
32
|
db,
|
|
28
33
|
dbClient: 'mysql', // 'mysql' | 'pg' | 'sqlite'
|
|
29
34
|
debug: false,
|
|
35
|
+
schema: null, // optional: schema object, file path, or URL
|
|
36
|
+
resolverPath: null, // optional: path to models directory
|
|
37
|
+
retry: undefined, // optional: { attempts, backoffMs, writes } | false
|
|
38
|
+
fanOutConcurrency: undefined, // optional: cap on parallel other_requests
|
|
30
39
|
});
|
|
31
40
|
|
|
41
|
+
// `schema` accepts four forms — auto-detected:
|
|
42
|
+
// object → used as-is
|
|
43
|
+
// ".json" → readFileSync + JSON.parse
|
|
44
|
+
// ".js" → require() (CJS: module.exports = {…})
|
|
45
|
+
// ".mjs" → dynamic import() (ESM: export default {…})
|
|
46
|
+
// "http(s)"→ fetch + JSON.parse
|
|
47
|
+
// Invalid sources throw KormError.
|
|
48
|
+
|
|
32
49
|
const result = await korm.processRequest(requestObject, 'ModelName');
|
|
33
50
|
```
|
|
34
51
|
|
|
@@ -40,7 +57,7 @@ In Express/Next/Fastify the consumer just forwards `req.body` and the model name
|
|
|
40
57
|
|
|
41
58
|
| Field | Type | Purpose |
|
|
42
59
|
| ----------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
43
|
-
| `action` | string (required) | The operation: `list`, `show`, `create`, `update`, `delete`, `count`, `sum`, `replace`, `upsert`, `sync`
|
|
60
|
+
| `action` | string (required) | The operation: `list`, `show`, `create`, `update`, `delete`, `count`, `sum`, `replace`, `upsert`, `sync`, `restore` |
|
|
44
61
|
| `where` | object \| array | Filter conditions for `list`/`show`/`update`/`delete`/`count`/`sum` |
|
|
45
62
|
| `data` | object \| array | Payload for `create`/`update`/`upsert`/`replace`/`sync` |
|
|
46
63
|
| `select` | array \| string | Columns to return (default: all) |
|
|
@@ -71,6 +88,7 @@ In Express/Next/Fastify the consumer just forwards `req.body` and the model name
|
|
|
71
88
|
| `replace` | Full-row replace by PK, all engines (MySQL/SQLite = delete+insert; pg = ON CONFLICT merge — omitted cols retained). Optional `conflict` |
|
|
72
89
|
| `upsert` | Insert-or-update keyed by `conflict` columns |
|
|
73
90
|
| `sync` | Upsert matching `data` + delete non-matching within `where` scope |
|
|
91
|
+
| `restore` | Re-activate soft-deleted rows (sets `deleted_at = NULL`); requires the model to have soft-delete enabled. Throws `UNKNOWN_ACTION` otherwise. |
|
|
74
92
|
|
|
75
93
|
### `where` operator cheat-sheet
|
|
76
94
|
|
|
@@ -86,6 +104,7 @@ Operators are **encoded as string prefixes on the value** (not separate keys):
|
|
|
86
104
|
| `!=` | `"!V"` | `{status: "!deleted"}` | `!= ?` |
|
|
87
105
|
| LIKE | `"%V%"` (or `"V%"`, `"%V"`) | `{name: "%john%"}` | `LIKE ?` |
|
|
88
106
|
| IN | `"[]a,b,c"` | `{role: "[]admin,user"}` | `IN (?, ?, ?)` |
|
|
107
|
+
| IN (bare array) | `[1,2,3]` (JS array) | `{id: [1,2,3]}` | `IN (?, ?, ?)` |
|
|
89
108
|
| NOT IN | `"![]a,b"` | `{role: "![]banned"}` | `NOT IN (...)` |
|
|
90
109
|
| BETWEEN | `"><min,max"` | `{age: "><18,65"}` | `BETWEEN ? AND ?` |
|
|
91
110
|
| NOT BETWEEN | `"<>min,max"` | `{score: "<>0,50"}` | `NOT BETWEEN ? AND ?` |
|
|
@@ -99,6 +118,8 @@ Rules:
|
|
|
99
118
|
- Array form `where: [ {a: 1}, {b: 2} ]` is equivalent to object form for ANDs but lets you repeat the same column.
|
|
100
119
|
- `sumFormula` uses `{columnName}` placeholders and accepts only `+ - * / ( )` and decimal literals — **never interpolate user input**.
|
|
101
120
|
- All values flow through Knex bindings. **Do not hand-build SQL strings.**
|
|
121
|
+
- All operators work on read AND write actions (`update`, `delete`, `restore`, `sync`) — same grammar everywhere.
|
|
122
|
+
- `update` / `delete` / `restore` require a non-empty `where` clause — empty `where` throws `VALIDATION_FAILED`.
|
|
102
123
|
|
|
103
124
|
### Relations (`with`)
|
|
104
125
|
|
|
@@ -256,6 +277,42 @@ await korm.processRequest(
|
|
|
256
277
|
);
|
|
257
278
|
```
|
|
258
279
|
|
|
280
|
+
## Connection management
|
|
281
|
+
|
|
282
|
+
KORM does not create connections — the consumer builds one Knex instance and
|
|
283
|
+
hands it over. Getting that wrong is the most common production failure, so
|
|
284
|
+
treat these as hard rules.
|
|
285
|
+
|
|
286
|
+
**Pool config.** Always include `...recommendedPoolConfig()`, which sets
|
|
287
|
+
`pool: { min: 0, max: 10, idleTimeoutMillis: 30000, ... }`. `min: 0` is the
|
|
288
|
+
critical value: Knex only reaps idle connections _down to_ `min`, so any
|
|
289
|
+
non-zero minimum leaves sockets idle until the database server closes them —
|
|
290
|
+
after which the next query gets `PROTOCOL_CONNECTION_LOST` / `ECONNRESET`.
|
|
291
|
+
`processes × pool.max` must stay under the server's `max_connections`.
|
|
292
|
+
|
|
293
|
+
**One pool per process.** `knex()` and `initializeKORM()` go at module scope,
|
|
294
|
+
never in a request handler. On Next.js, guard the singleton on `globalThis` so
|
|
295
|
+
hot reload does not rebuild it.
|
|
296
|
+
|
|
297
|
+
**Lifecycle methods on the instance:**
|
|
298
|
+
|
|
299
|
+
| Method | Returns | Use |
|
|
300
|
+
| --- | --- | --- |
|
|
301
|
+
| `korm.ping()` | `{ ok, error? }` — never throws | `/healthz` probe, idle keepalive |
|
|
302
|
+
| `korm.destroy()` | `{ ok, error? }` — idempotent | SIGTERM/SIGINT handler |
|
|
303
|
+
| `korm.isDestroyed` | `boolean` | after `destroy()`, `processRequest` throws |
|
|
304
|
+
|
|
305
|
+
**Retry.** KORM replays a request once on a dropped connection
|
|
306
|
+
(`ECONNRESET`, `PROTOCOL_CONNECTION_LOST`, pg `57P01`/`08006`, `SQLITE_BUSY`).
|
|
307
|
+
Reads and `dryRun` are replayed; **writes are not**, because a `create` that
|
|
308
|
+
landed before the socket died would be duplicated. Opt in with
|
|
309
|
+
`retry: { writes: true }`; disable with `retry: false`. Real query errors are
|
|
310
|
+
never retried.
|
|
311
|
+
|
|
312
|
+
**Fan-out.** Sibling `other_requests` run at most `min(5, pool.max - 1)` at a
|
|
313
|
+
time so a nested request cannot exhaust the pool. Override with
|
|
314
|
+
`fanOutConcurrency`.
|
|
315
|
+
|
|
259
316
|
## Rules for AI assistants helping consumers
|
|
260
317
|
|
|
261
318
|
1. **Use the JSON contract.** When the user asks for a query, return a KORM request object plus the `processRequest` call — not raw Knex chains.
|
|
@@ -263,7 +320,8 @@ await korm.processRequest(
|
|
|
263
320
|
3. **Multi-DB.** Assume the same request runs on MySQL, Postgres, and SQLite. If a feature's _semantics_ differ by engine, call it out — e.g. `replace` is a true delete+insert on MySQL/SQLite but a merge on Postgres (omitted columns are retained); prefer `upsert` for portable insert-or-update.
|
|
264
321
|
4. **Don't invent operators.** If the user needs something not in the operator table, use `where` with relation traversal, `having`, or `groupBy` — or tell the user the contract doesn't support it.
|
|
265
322
|
5. **Don't invent fields.** The top-level keys above are the entire contract surface. No `filter`, no `query`, no `params`.
|
|
266
|
-
6. **Soft delete is per-model.** `delete` becomes a soft-delete only if the model declares it; don't assume.
|
|
323
|
+
6. **Soft delete is per-model.** `delete` becomes a soft-delete only if the model declares it; don't assume. Soft-deleted rows are automatically excluded from `list`, `show`, `count`, and `sum`. Use `restore` to re-activate a soft-deleted row (sets `deleted_at = NULL`).
|
|
267
324
|
7. **Preview before mutating.** For a risky write, add `dryRun: true` first to inspect the SQL, then re-issue without it.
|
|
268
325
|
8. **Handle errors by `code`.** Catch `KormError` and branch on `e.code` (table above) rather than string-matching `e.message`.
|
|
269
|
-
9. **
|
|
326
|
+
9. **Never open a pool per request.** `knex()` / `initializeKORM()` belong at module scope. If the user reports `Too many connections`, `Timeout acquiring a connection`, or `ECONNRESET`, check for a per-request pool and for `pool.min > 0` before anything else.
|
|
327
|
+
10. **Refresh this doc** by re-running `npx @dreamtree-org/korm-js init --ai <provider>` when the library is upgraded.
|
package/bin/korm-mcp.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
"use strict";const path=require("path"),{initializeKORM:initializeKORM}=require("../index"),{createServer:createServer}=require("../src/mcp/server"),{McpConfigError:McpConfigError}=require("../src/mcp/errors");function parseArgv(e){const r={config:null,help:!1};for(let o=2;o<e.length;o++){const t=e[o];"--help"===t||"-h"===t?r.help=!0:"--config"===t||"-c"===t?r.config=e[++o]:t.startsWith("--config=")?r.config=t.slice(9):(process.stderr.write(`korm-mcp: unknown argument "${t}"\n`),r.help=!0)}return r}function printHelp(){process.stderr.write("korm-mcp — Model Context Protocol server for @dreamtree-org/korm-js\n\nUsage:\n korm-mcp --config <path-to-config.js>\n\nOptions:\n -c, --config <path> Path to a Node CJS module exporting { db, dbClient, schema, mcp }.\n -h, --help Show this help.\n\nSee docs/agents/11-mcp-server.md for the config shape.\n")}function requireConfigModule(e){try{const r=require(e);return r&&r.default?r.default:r}catch(r){throw new McpConfigError(`Failed to load config at ${e}: ${r.message}`)}}function assertConfigFields(e,r){if(!e||"object"!=typeof e)throw new McpConfigError(`Config at ${r} must export an object (got ${typeof e}).`);const o=["db","dbClient","schema"];for(const r of o)if(!e[r])throw new McpConfigError(`Config: \`${r}\` is required.`);if(!e.mcp||"object"!=typeof e.mcp)throw new McpConfigError("Config: `mcp` object is required (see spec §6).")}function loadConfig(e){if(!e)throw new McpConfigError("--config is required. See `korm-mcp --help`.");const r=path.resolve(process.cwd(),e),o=requireConfigModule(r);return assertConfigFields(o,r),o}const stderrLogger={info:(...e)=>process.stderr.write(`[korm-mcp] ${e.join(" ")}\n`),error:(...e)=>process.stderr.write(`[korm-mcp:error] ${e.join(" ")}\n`)};function installShutdownHandlers(e){const
|
|
2
|
+
"use strict";const path=require("path"),{initializeKORM:initializeKORM}=require("../index"),{createServer:createServer}=require("../src/mcp/server"),{McpConfigError:McpConfigError}=require("../src/mcp/errors");function parseArgv(e){const r={config:null,help:!1};for(let o=2;o<e.length;o++){const t=e[o];"--help"===t||"-h"===t?r.help=!0:"--config"===t||"-c"===t?r.config=e[++o]:t.startsWith("--config=")?r.config=t.slice(9):(process.stderr.write(`korm-mcp: unknown argument "${t}"\n`),r.help=!0)}return r}function printHelp(){process.stderr.write("korm-mcp — Model Context Protocol server for @dreamtree-org/korm-js\n\nUsage:\n korm-mcp --config <path-to-config.js>\n\nOptions:\n -c, --config <path> Path to a Node CJS module exporting { db, dbClient, schema, mcp }.\n -h, --help Show this help.\n\nSee docs/agents/11-mcp-server.md for the config shape.\n")}function requireConfigModule(e){try{const r=require(e);return r&&r.default?r.default:r}catch(r){throw new McpConfigError(`Failed to load config at ${e}: ${r.message}`)}}function assertConfigFields(e,r){if(!e||"object"!=typeof e)throw new McpConfigError(`Config at ${r} must export an object (got ${typeof e}).`);const o=["db","dbClient","schema"];for(const r of o)if(!e[r])throw new McpConfigError(`Config: \`${r}\` is required.`);if(!e.mcp||"object"!=typeof e.mcp)throw new McpConfigError("Config: `mcp` object is required (see spec §6).")}function loadConfig(e){if(!e)throw new McpConfigError("--config is required. See `korm-mcp --help`.");const r=path.resolve(process.cwd(),e),o=requireConfigModule(r);return assertConfigFields(o,r),o}const stderrLogger={info:(...e)=>process.stderr.write(`[korm-mcp] ${e.join(" ")}\n`),error:(...e)=>process.stderr.write(`[korm-mcp:error] ${e.join(" ")}\n`)};function installShutdownHandlers(e,r){const o=async o=>{stderrLogger.info(`received ${o}, shutting down`);try{await e.stop()}catch(e){stderrLogger.error(`stop error: ${e.message}`)}if(r&&"function"==typeof r.destroy){const e=await r.destroy();e.ok||stderrLogger.error(`db close error: ${e.error}`)}process.exit(0)};process.on("SIGINT",()=>o("SIGINT")),process.on("SIGTERM",()=>o("SIGTERM"))}async function main(e=process.argv){const r=parseArgv(e);let o;r.help&&(printHelp(),process.exit(0));try{o=loadConfig(r.config)}catch(e){process.stderr.write(`korm-mcp: ${e.message}\n`),process.exit(2)}const t=await initializeKORM({db:o.db,dbClient:o.dbClient,schema:o.schema,resolverPath:o.resolverPath||null,debug:o.debug||!1}),n=require("../package.json"),s=createServer({controller:t,schema:o.schema,mcpConfig:o.mcp,packageInfo:{name:n.name,version:n.version}});installShutdownHandlers(s,t);try{await s.start({logger:stderrLogger}),stderrLogger.info(`started; ${s.tools.length} tools exposed (mode=${o.mcp.mode||"ro"})`)}catch(e){stderrLogger.error(`failed to start: ${e.message}`),process.exit(1)}}require.main===module&&main().catch(e=>{process.stderr.write(`korm-mcp: fatal: ${e.message}\n`),process.exit(1)}),module.exports={parseArgv:parseArgv,loadConfig:loadConfig,main:main};
|
package/clients/SyncRunner.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const logger=require("../Logger");function normalizeOptions(t={}){return{dropColumns:!!(null!=t.dropColumns?t.dropColumns:t.prune),continueOnError:!!t.continueOnError,dryRun:!!t.dryRun}}class SyncRunner{constructor(t,o={}){this.sync=t,this.opts=normalizeOptions(o),this.report={dryRun:this.opts.dryRun,applied:[],plan:[],skippedDrops:[],errors:[]}}result(){return this.opts.dryRun||this.opts.continueOnError?this.report:void 0}async runDatabase(t){for(const o of this.sync._orderTablesByDependency(t))await this._syncTable(t[o
|
|
1
|
+
const logger=require("../Logger");function normalizeOptions(t={}){return{dropColumns:!!(null!=t.dropColumns?t.dropColumns:t.prune),continueOnError:!!t.continueOnError,dryRun:!!t.dryRun}}class SyncRunner{constructor(t,o={}){this.sync=t,this.opts=normalizeOptions(o),this.report={dryRun:this.opts.dryRun,applied:[],plan:[],skippedDrops:[],errors:[]}}result(){return this.opts.dryRun||this.opts.continueOnError?this.report:void 0}async runDatabase(t){const o={};for(const e of Object.keys(t)){const r=t[e];if(r&&r.table){o[r.table]||(o[r.table]=new Set);for(const t of Object.keys(r.columns||{}))o[r.table].add(t)}}for(const e of this.sync._orderTablesByDependency(t))await this._syncTable(t[e],o);return this.opts.dryRun||logger.info("Database synced by SyncTable..."),this.result()}async runTable(t){return await this._syncTable(t,{}),this.result()}async _syncTable(t,o){const e=t.table;(await this.sync.existsTable(e)?await this._alter(t,e,o):await this._create(t,e))&&!this.opts.dryRun&&await this._extrasAndSeed(t,e)}async _create(t,o){if(this.opts.dryRun)return this.report.plan.push({table:o,operation:"createTable",column:null,destructive:!1}),!0;const e=await this._attempt({table:o,operation:"createTable",column:null},()=>this.sync.createTable(t));return e&&this.report.applied.push({table:o,operation:"createTable",column:null}),e}async _alter(t,o,e={}){const r=await this.sync.getAlterations(t),n=e[o]||new Set;if(n.size>0&&r.drop&&(r.drop=r.drop.filter(t=>!n.has(t.name))),this._recordDrops(o,r),this.opts.dryRun)return this._planAlter(o,r),!0;const s={add:await this._screenOrphans(o,r.add||[]),drop:this.opts.dropColumns&&r.drop||[],modify:r.modify||[]};return await this._applyWork(o,s),!0}_recordDrops(t,o){if(!this.opts.dropColumns)for(const e of o.drop||[])this.report.skippedDrops.push({table:t,column:e.name}),logger.warn(`syncDatabase: column "${t}.${e.name}" exists in the database but not in the schema; left in place (additive-only). Pass { dropColumns: true } to drop it.`)}_planAlter(t,o){for(const e of o.add||[])this.report.plan.push({table:t,operation:"add",column:e.name,destructive:!1});for(const e of o.modify||[])this.report.plan.push({table:t,operation:"modify",column:e.name,destructive:!1});if(this.opts.dropColumns)for(const e of o.drop||[])this.report.plan.push({table:t,operation:"drop",column:e.name,destructive:!0})}async _screenOrphans(t,o){if(!this.opts.continueOnError)return await this.sync._assertNoOrphanForeignKeys(t,{add:o}),o;const e=[];for(const r of o){const o=await this.sync._detectOrphanForNewFkColumn(t,r);o?this.report.errors.push({table:t,operation:"add",column:r.name,message:`adding FK column would orphan ${o.rowsAffected} row(s) with no matching ${o.parentTable}.${o.parentColumn}`}):e.push(r)}return e}async _applyWork(t,o){const e=this._ops(o);if(0!==e.length)if(this.opts.continueOnError)for(const o of e){await this._attempt({table:t,operation:o.operation,column:o.column},()=>this.sync.alterTable(t,this._single(o)))&&this.report.applied.push({table:t,operation:o.operation,column:o.column})}else{await this.sync.alterTable(t,o);for(const o of e)this.report.applied.push({table:t,operation:o.operation,column:o.column})}}_ops(t){const o=[];for(const e of t.add||[])o.push({operation:"add",column:e.name,frm:e});for(const e of t.drop||[])o.push({operation:"drop",column:e.name,name:e.name});for(const e of t.modify||[])o.push({operation:"modify",column:e.name,frm:e});return o}_single(t){return"add"===t.operation?{add:[t.frm],drop:[],modify:[]}:"drop"===t.operation?{add:[],drop:[{name:t.name}],modify:[]}:{add:[],drop:[],modify:[t.frm]}}async _extrasAndSeed(t,o){await this._attempt({table:o,operation:"extras",column:null},()=>this.sync._applyExtras(t)),await this._attempt({table:o,operation:"seed",column:null},()=>this.sync.syncSeedData(t,o))}async _attempt(t,o){try{return await o(),!0}catch(o){if(!this.opts.continueOnError)throw o;return this.report.errors.push({...t,message:o.message}),!1}}}module.exports=SyncRunner;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
const QueryService=require("./QueryService"),HookService=require("./HookService"),KormError=require("../../KormError");class CurdTable{constructor(e,r,t=null){if(this.db=e,this.utils=r,this.controllerWrapper=t,this.hookService=new HookService(e,r,t),this.queryService=new QueryService(e,r,t),!this.queryService)throw new KormError("CurdTable requires queryService (execute*Query / getQuery).",{code:KormError.CODES.INTERNAL})}async processRequest(e,r=null,t={}){const
|
|
1
|
+
const QueryService=require("./QueryService"),HookService=require("./HookService"),KormError=require("../../KormError"),{mapWithConcurrency:mapWithConcurrency,resolveFanOutConcurrency:resolveFanOutConcurrency}=require("../../ConnectionResilience");class CurdTable{constructor(e,r,t=null){if(this.db=e,this.utils=r,this.controllerWrapper=t,this.hookService=new HookService(e,r,t),this.queryService=new QueryService(e,r,t),!this.queryService)throw new KormError("CurdTable requires queryService (execute*Query / getQuery).",{code:KormError.CODES.INTERNAL})}async _injectSoftDeleteWhere(e,r,t="deleted_at"){return this.hookService?.executeHasSoftDeleteHook&&await this.hookService.executeHasSoftDeleteHook(e)&&(r={...r,where:{...r.where||{},[t]:null}}),r}async processRequest(e,r=null,t={},o=0){if(o>10)throw new KormError("other_requests nesting depth exceeded maximum (10)",{code:KormError.CODES.INTERNAL,context:{depth:o}});const c=this.controllerWrapper,s=this.utils.getModel(this.controllerWrapper,r),i=e?.action||"list";let a=null;if(new Set(["update","delete","softDelete"]).has(i)){const r=e?.where;if(!r||"object"==typeof r&&0===Object.keys(r).length)throw KormError.validationFailed({errors:[{field:"where",message:`where is required for action "${i}"`}],source:i})}const u={model:s,action:i,request:e,ctx:t,controller:c};if(this.hookService?.executeValidatorHook&&await this.hookService.executeValidatorHook({...u}),e?.dryRun)return this.buildDryRunResult(s,e,i);switch(this.hookService?.executeBeforeHook&&(e.beforeActionData=await this.hookService.executeBeforeHook({...u})),i){case"count":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeCountQuery(s,e);break;case"sum":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeSumQuery(s,e);break;case"list":a=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.getSoftDeleteQuery(s,e):await this.queryService.getQuery(s,e);break;case"show":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeShowQuery(s,e);break;case"create":a=await this.queryService.executeCreateQuery(s,e);break;case"update":{const r=await this.queryService.executeUpdateQuery(s,e);if(!(r&&r.length>0))throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record updated successfully",data:r,success:!0};break}case"replace":if(a=await this.queryService.executeReplaceQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record replaced successfully",data:a,success:!0};break;case"upsert":if(a=await this.queryService.executeUpsertQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record upserted successfully",data:a,success:!0};break;case"sync":if(a=await this.queryService.executeSyncQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record synced successfully",data:a,success:!0};break;case"delete":{let r=null;if(r=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.executeSoftDeleteQuery(s,e):await this.queryService.executeDeleteQuery(s,e),!r)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record deleted successfully",data:r,success:!0};break}case"restore":{if(!await(this.hookService?.executeHasSoftDeleteHook?.(s)))throw KormError.unknownAction({action:i,model:s.name});const r=await this.queryService.executeRestoreQuery(s,e);if(!(r&&r.length>0))throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record restored successfully",data:r,success:!0};break}default:if(!this.hookService?.executeCustomAction)throw KormError.unknownAction({action:i,model:s?.name});a=await this.hookService.executeCustomAction({...u})}if(this.hookService?.executeAfterHook&&(a=await this.hookService.executeAfterHook({...u,data:a})),"object"==typeof a&&null!==a&&e?.other_requests&&"object"==typeof e.other_requests){const r={},c=Object.entries(e.other_requests),s=50;let i=0;for(const[,e]of c)i+=Array.isArray(e)?e.length:1;if(i>s)throw new KormError(`other_requests fan-out exceeded maximum (${s})`,{code:KormError.CODES.INTERNAL,context:{fanOut:i}});for(const[e,s]of c)if(this.controllerWrapper?._authz?.hasRules()&&this.controllerWrapper._authz.enforce(e,s?.action||"list",s,t),Array.isArray(s)){const c=resolveFanOutConcurrency(this.db,this.controllerWrapper?.fanOutConcurrency);r[e]=await mapWithConcurrency(s,c,r=>this.processRequest(r,e,t,o+1))}else r[e]=await this.processRequest(s,e,t,o+1);a.other_responses=r}return a}async buildDryRunResult(e,r,t){if(!["list","show","count","sum","create","update","replace","upsert","sync","delete","restore"].includes(t))throw KormError.unknownAction({action:t,model:e?.name});let o=t,c=r;const s=await this.hookService.executeHasSoftDeleteHook(e);if(s&&"delete"===t)o="softDelete";else if(s&&"list"===t)c={...r,where:{...r.where||{},deleted_at:null}};else if("restore"===t&&!s)throw KormError.unknownAction({action:t,model:e?.name});return this.queryService.buildDryRun(e,c,o)}}module.exports=CurdTable;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
const HelperUtility=require("./HelperUtility"),logger=require("../../Logger"),KormError=require("../../KormError");class QueryBuilder{constructor(e,t,r=null){this.db=e,this.utils=t,this.controllerWrapper=r,this.helperUtility=new HelperUtility}getHookService(){return this.controllerWrapper.hookService}getQueryBuilder(e,t=null){let r=this.db(e.table);return t&&(r=t),r._getMyModel=()=>e,r}parseValue(e){return this.helperUtility.parseValue(e)}parseWhereValue(e){return this.helperUtility.parseWhereValue(e)}parseWhereColumn(e){return this.helperUtility.parseWhereColumn(e)}_applyOrWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.orWhereBetween(t,o);break;case"notBetween":e.orWhereNotBetween(t,o);break;case"in":e.orWhereIn(t,o);break;case"notIn":e.orWhereNotIn(t,o);break;case"like":e.orWhereRaw("?? LIKE ?",[t,o]);break;default:Array.isArray(o)?e.orWhereIn(t,o):e.orWhere(t,r,o)}else e.orWhereNull(t)}_applyAndWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.whereBetween(t,o);break;case"notBetween":e.whereNotBetween(t,o);break;case"in":e.whereIn(t,o);break;case"notIn":e.whereNotIn(t,o);break;case"like":e.whereRaw("?? LIKE ?",[t,o]);break;default:Array.isArray(o)?e.whereIn(t,o):e.where(t,r,o)}else e.whereNull(t)}buildWithTree(e,t=null){return this.helperUtility.dotWalkTree(e,{resolver:({current:e,part:r,source:o})=>t?t({current:e,part:r,source:o}):{}})}_getWithWhereForRelation(e,t){if(!e||"object"!=typeof e)return{direct:{},nested:{}};const r=`${t}.`,o={},i={};for(const[t,s]of Object.entries(e))if(t.startsWith(r)){const e=t.slice(r.length);e.includes(".")?i[e]=s:o[e]=s}return{direct:o,nested:i}}_applyWithWhereConditions(e,t){for(const[r,o]of Object.entries(t)){const{joinType:t="AND",column:i}=this.parseWhereColumn(r),{operator:s,value:n}=this.parseWhereValue(o);"AND"===t?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}async fetchRelatedRows(e,t,r={}){if(e.through){const o=await this.db(e.through).whereIn(e.throughLocalKey,t),i=this.db(e.table).whereIn(e.foreignKey,o.map(t=>t[e.throughForeignKey]));return Object.keys(r).length>0&&this._applyWithWhereConditions(i,r),i}{const o=this.db(e.table).whereIn(e.foreignKey,t);return Object.keys(r).length>0&&this._applyWithWhereConditions(o,r),o}}async fetchAndAttachRelated(e){const{parentRows:t,relName:r,model:o,withTree:i,relation:s,withWhere:n={}}=e;if(!s){const e=this.getHookService().getModelInstance(o),s=`get${r.charAt(0).toUpperCase()+r.slice(1)}Relation`;if("function"==typeof e[s]){const{direct:l,nested:a}=this._getWithWhereForRelation(n,r),h={rows:t,relName:r,model:o,withTree:i,controller:this.controllerWrapper,relation:o.hasRelations[r],qb:this,db:this.db,withWhere:l,nestedWithWhere:a};await e[s](h)}else logger.warn(`Method ${s} not found in model ${o.name}`);return t}const l=t.map(e=>e[s.localKey]);let a=[];const h="one"===s?.type,{direct:c,nested:p}=this._getWithWhereForRelation(n,r);let u={...c};try{const e=this.utils.getModel(this.controllerWrapper,r);if(this.controllerWrapper?.hookService){await this.controllerWrapper.hookService.executeHasSoftDeleteHook(e)&&(u={...u,deleted_at:null})}}catch(e){}a=await this.fetchRelatedRows(s,l,u);const y=new Map;for(const e of a){const t=e[s.foreignKey];y.has(t)||y.set(t,[]),y.get(t).push(e)}for(const e of t){const t=e[s.localKey];h&&1==y.get(t)?.length?e[r]=y.get(t)[0]:e[r]=y.get(t)||[]}const d=Object.keys(i);for(const e of d){const o=i[e],s=t.filter(e=>h?e[r]:e[r].length>0).map(e=>e[r]).reduce((e,t)=>e.concat(t),[]),n=this.utils.getModel(this.controllerWrapper,r);await this.fetchAndAttachRelated({parentRows:s,relName:e,model:n,withTree:o,relation:n.hasRelations[e],withWhere:p})}return t}filterWhere(e,t=""){return t?Object.keys(e).filter(e=>e.includes(t)).reduce((r,o)=>(r[o.replace(t,"")]=e[o],r),{}):Object.keys(e).filter(e=>!e.includes(".")).reduce((t,r)=>(t[r]=e[r],t),{})}buildSelectQuery(e,t){const{where:r={},with:o,select:i,orderBy:s,limit:n=10,offset:l=0,page:a,groupBy:h,having:c,distinct:p,join:u,leftJoin:y,rightJoin:d,innerJoin:f}=t,g=this.getQueryBuilder(e);i&&(Array.isArray(i)||"string"==typeof i)?g.select(i):g.select("*"),p&&(Array.isArray(p)||"string"==typeof p?g.distinct(p):g.distinct()),u&&this._applyJoins(g,u,"join"),y&&this._applyJoins(g,y,"leftJoin"),d&&this._applyJoins(g,d,"rightJoin"),f&&this._applyJoins(g,f,"innerJoin"),this._applyWhereClause(g,r,o),h&&g.groupBy(h),c&&this._applyHavingClause(g,c);const W=void 0===s?this.helperUtility.resolveDefaultOrderBy(e):s;W&&this._applyOrderBy(g,W);let w=l;return a&&n>0&&(w=(Math.max(1,parseInt(a))-1)*n),n>0&&(g.limit(n),w>0&&g.offset(w)),g}async getQuery(e,t){try{const{where:r={},with:o,withWhere:i,limit:s=10,offset:n=0,page:l,join:a,leftJoin:h,rightJoin:c,innerJoin:p}=t,u=this.buildSelectQuery(e,t);let y=!1;const d=s;let f=n,g=1,W=0;l&&s>0&&(g=Math.max(1,parseInt(l)),f=(g-1)*s);const w=[],m=u.toSQL();w.push(m.sql);const b=await u;if(o&&o.length>0){const t=this.buildWithTree(o);for(const r of Object.keys(t))await this.fetchAndAttachRelated({parentRows:b,relName:r,model:e,withTree:t[r],relation:e.hasRelations[r],withWhere:i||{}})}let _=null;if(s>0)try{const t=this.getQueryBuilder(e);r&&Object.keys(r).length>0&&this._applyWhereClause(t,r),a&&this._applyJoins(t,a,"join"),h&&this._applyJoins(t,h,"leftJoin"),c&&this._applyJoins(t,c,"rightJoin"),p&&this._applyJoins(t,p,"innerJoin");const o=t.count("* as cnt");w.push(o.toSQL().sql);_=(await o.first()).cnt}catch(e){logger.warn("Failed to get total count:",e.message),_=b.length}s>0&&null!==_&&(W=Math.ceil(_/s),y=g<W);const j=y?g+1:null,A=g>1?g-1:null;return{data:b,totalCount:_,...this.controllerWrapper.debug?{sqlDebug:w}:{},...s>0?{pagination:{page:g,limit:d,offset:f,totalPages:W,hasNext:y,hasPrev:g>1,nextPage:j,prevPage:A}}:{}}}catch(t){throw logger.error("QueryService.getQuery error:",t),new Error(`Failed to execute query: ${t.message} on model ${e.name}`)}}getSumQuery(e,t){const{where:r={},join:o,leftJoin:i,rightJoin:s,innerJoin:n}=t,l=t.data||t,a=l.sumColumn,h=l.sumFormula;if(!a&&!h)throw new Error("Sum action requires either data.sumColumn or data.sumFormula");const c=e=>"`"+String(e).replace(/[`\\]/g,"")+"`";let p;if(a){const e=String(a).trim().replace(/[^a-zA-Z0-9_]/g,"");if(!e)throw new Error("data.sumColumn must be a valid column name");p="SUM("+c(e)+")"}else{const e=String(h).trim();if(!/\{[a-zA-Z0-9_]+\}/.test(e))throw new Error("data.sumFormula must contain at least one {columnName}");let t=0;for(const r of e)if("("===r)t++;else if(")"===r&&(t--,t<0))break;if(0!==t)throw new Error("data.sumFormula has unbalanced or misordered parentheses ( and )");const r=e.replace(/\{[a-zA-Z0-9_]+\}/g,"@");if(!/^[\s0-9.+*\/\-()@]+$/.test(r))throw new Error("data.sumFormula may only use BODMAS: numbers, + - * /, parentheses, and {columnName}");p="SUM("+e.replace(/\{([a-zA-Z0-9_]+)\}/g,(e,t)=>c(t))+")"}const u=this.getQueryBuilder(e);return o&&this._applyJoins(u,o,"join"),i&&this._applyJoins(u,i,"leftJoin"),s&&this._applyJoins(u,s,"rightJoin"),n&&this._applyJoins(u,n,"innerJoin"),this._applyWhereClause(u,r,[]),u.select(this.db.raw(p+" as sum")),u}_applyWhereWithArray(e,t,r){for(const o of t)this._applyWhereClause(e,o,r)}_applyWhereClause(e,t,r=[]){if(Array.isArray(t))this._applyWhereWithArray(e,t,r);else{if(t&&Object.keys(t).length>0){let r=this.helperUtility.getDotWalkQuery(t);logger.debug({filteredWhere:r}),r=this.helperUtility.objectFilter(r,(e,t)=>!e.startsWith("!")&&("__exists__"!==e&&("object"!=typeof t||null===t)));for(const[t,o]of Object.entries(r)){const{joinType:r="AND",column:i}=this.parseWhereColumn(t),{operator:s,value:n}=this.parseWhereValue(o);"AND"===r?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}this._applyNestedWhere(e,t,r)}}_getNestedWhereConditions(e,t){if(!e||"object"!=typeof e)return{};const r=`${t}.`,o={};for(const[i,s]of Object.entries(e))if(i.startsWith(r)){o[i.slice(r.length)]=s}else i===t&&!0===s&&(o.__exists__=!0);return o}_getTopLevelRelationsFromWhere(e){if(!e||"object"!=typeof e)return[];const t=new Set;for(const r of Object.keys(e))if(r.includes(".")){const e=r.split(".")[0];t.add(e)}else r.startsWith("!")&&t.add(r);return[...t]}_applyNestedWhere(e,t,r){const o=this,i=e._getMyModel(),s=this._getTopLevelRelationsFromWhere(t);if(0!==s.length)for(const r of s){const s=r.startsWith("!"),n=s?r.slice(1):r,l=this._getNestedWhereConditions(t,r);if(0===Object.keys(l).length)continue;const a=i.hasRelations?.[n];if(!a){logger.warn(`Relation ${n} not found in model ${i.modelName}`);continue}let h;try{h=this.utils.getModel(this.controllerWrapper,a.table||n)}catch(e){try{h=this.utils.getModel(this.controllerWrapper,n)}catch(e){logger.warn(`Model for relation ${n} (table: ${a.table}) not found`);continue}}e[s?"whereNotExists":"whereExists"](function(){const e=o.getQueryBuilder(h,this.select("*").from(a.table));e.whereRaw("??.?? = ??.??",[a.table,a.foreignKey,i.table,a.localKey]);if(!(!0===l.__exists__&&1===Object.keys(l).length)){const t={...l};delete t.__exists__,o._applyWhereClause(e,t,[])}})}}_applyJoins(e,t,r){const o=Array.isArray(t)?t:[t];for(const t of o)"string"==typeof t?e[r](t):"object"==typeof t&&(t.table&&t.on?e[r](t.table,t.on):t.table&&t.first&&t.operator&&t.second&&e[r](t.table,t.first,t.operator,t.second))}_applyWithWhere(e,t){try{if(Array.isArray(t))for(const r of t)"string"==typeof r?e.withWhere(r):"object"==typeof r&&e.withWhere(r.column,r.operator,r.value);else if("object"==typeof t)for(const[r,o]of Object.entries(t))e.withWhere(r,o)}catch(e){logger.warn("Failed to apply withWhere:",e.message)}}_applyHavingClause(e,t){for(const[r,o]of Object.entries(t))"object"==typeof o&&o.operator?e.having(r,o.operator,o.value):e.having(r,o)}_applyOrderBy(e,t){if(Array.isArray(t))for(const r of t)this._applyOrderByEntry(e,r);else this._applyOrderByEntry(e,t)}_applyOrderByEntry(e,t){if("string"!=typeof t){if(t&&"object"==typeof t){if(!t.column)throw KormError.validationFailed({errors:[{field:"orderBy",message:`orderBy object must include a "column" key (got keys: ${Object.keys(t).join(", ")||"none"})`,value:t,rule:"required"}],source:"orderBy"});e.orderBy(t.column,t.direction||"asc")}}else e.orderBy(t)}}module.exports=QueryBuilder;
|
|
1
|
+
const HelperUtility=require("./HelperUtility"),logger=require("../../Logger"),KormError=require("../../KormError");class QueryBuilder{constructor(e,t,r=null){this.db=e,this.utils=t,this.controllerWrapper=r,this.helperUtility=new HelperUtility}getHookService(){return this.controllerWrapper.hookService}getQueryBuilder(e,t=null){let r=this.db(e.table);return t&&(r=t),r._getMyModel=()=>e,r}parseValue(e){return this.helperUtility.parseValue(e)}parseWhereValue(e){return this.helperUtility.parseWhereValue(e)}parseWhereColumn(e){return this.helperUtility.parseWhereColumn(e)}_applyOrWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.orWhereBetween(t,o);break;case"notBetween":e.orWhereNotBetween(t,o);break;case"in":e.orWhereIn(t,o);break;case"notIn":e.orWhereNotIn(t,o);break;case"like":e.orWhereRaw("?? LIKE ?",[t,o]);break;default:Array.isArray(o)?e.orWhereIn(t,o):e.orWhere(t,r,o)}else e.orWhereNull(t)}_applyAndWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.whereBetween(t,o);break;case"notBetween":e.whereNotBetween(t,o);break;case"in":e.whereIn(t,o);break;case"notIn":e.whereNotIn(t,o);break;case"like":e.whereRaw("?? LIKE ?",[t,o]);break;default:Array.isArray(o)?e.whereIn(t,o):e.where(t,r,o)}else e.whereNull(t)}buildWithTree(e,t=null){return this.helperUtility.dotWalkTree(e,{resolver:({current:e,part:r,source:o})=>t?t({current:e,part:r,source:o}):{}})}_getWithWhereForRelation(e,t){if(!e||"object"!=typeof e)return{direct:{},nested:{}};const r=`${t}.`,o={},i={};for(const[t,s]of Object.entries(e))if(t.startsWith(r)){const e=t.slice(r.length);e.includes(".")?i[e]=s:o[e]=s}return{direct:o,nested:i}}_applyWithWhereConditions(e,t){for(const[r,o]of Object.entries(t)){const{joinType:t="AND",column:i}=this.parseWhereColumn(r),{operator:s,value:n}=this.parseWhereValue(o);"AND"===t?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}async fetchRelatedRows(e,t,r={}){if(e.through){const o=await this.db(e.through).whereIn(e.throughLocalKey,t),i=this.db(e.table).whereIn(e.foreignKey,o.map(t=>t[e.throughForeignKey]));return Object.keys(r).length>0&&this._applyWithWhereConditions(i,r),i}{const o=this.db(e.table).whereIn(e.foreignKey,t);return Object.keys(r).length>0&&this._applyWithWhereConditions(o,r),o}}async fetchAndAttachRelated(e){const{parentRows:t,relName:r,model:o,withTree:i,relation:s,withWhere:n={}}=e;if(!s){const e=this.getHookService().getModelInstance(o),s=`get${r.charAt(0).toUpperCase()+r.slice(1)}Relation`;if("function"==typeof e[s]){const{direct:a,nested:l}=this._getWithWhereForRelation(n,r),h={rows:t,relName:r,model:o,withTree:i,controller:this.controllerWrapper,relation:o.hasRelations[r],qb:this,db:this.db,withWhere:a,nestedWithWhere:l};await e[s](h)}else logger.warn(`Method ${s} not found in model ${o.name}`);return t}const a=t.map(e=>e[s.localKey]);let l=[];const h="one"===s?.type,{direct:c,nested:u}=this._getWithWhereForRelation(n,r);let p={...c};try{const e=this.utils.getModel(this.controllerWrapper,r);if(this.controllerWrapper?.hookService){await this.controllerWrapper.hookService.executeHasSoftDeleteHook(e)&&(p={...p,deleted_at:null})}}catch(e){}l=await this.fetchRelatedRows(s,a,p);const d=new Map;for(const e of l){const t=e[s.foreignKey];d.has(t)||d.set(t,[]),d.get(t).push(e)}for(const e of t){const t=e[s.localKey];h&&1==d.get(t)?.length?e[r]=d.get(t)[0]:e[r]=d.get(t)||[]}const y=Object.keys(i);for(const e of y){const o=i[e],s=t.filter(e=>h?e[r]:e[r].length>0).map(e=>e[r]).reduce((e,t)=>e.concat(t),[]),n=this.utils.getModel(this.controllerWrapper,r);await this.fetchAndAttachRelated({parentRows:s,relName:e,model:n,withTree:o,relation:n.hasRelations[e],withWhere:u})}return t}filterWhere(e,t=""){return t?Object.keys(e).filter(e=>e.includes(t)).reduce((r,o)=>(r[o.replace(t,"")]=e[o],r),{}):Object.keys(e).filter(e=>!e.includes(".")).reduce((t,r)=>(t[r]=e[r],t),{})}buildSelectQuery(e,t){const{where:r={},with:o,select:i,orderBy:s,limit:n=10,offset:a=0,page:l,groupBy:h,having:c,distinct:u,join:p,leftJoin:d,rightJoin:y,innerJoin:f}=t,g=this.getQueryBuilder(e);i&&(Array.isArray(i)||"string"==typeof i)?g.select(i):g.select("*"),u&&(Array.isArray(u)||"string"==typeof u?g.distinct(u):g.distinct()),p&&this._applyJoins(g,p,"join"),d&&this._applyJoins(g,d,"leftJoin"),y&&this._applyJoins(g,y,"rightJoin"),f&&this._applyJoins(g,f,"innerJoin"),this._applyWhereClause(g,r,o),h&&g.groupBy(h),c&&this._applyHavingClause(g,c);const m=void 0===s?this.helperUtility.resolveDefaultOrderBy(e):s;m&&this._applyOrderBy(g,m);let W=a;return l&&n>0&&(W=(Math.max(1,parseInt(l))-1)*n),n>0&&(g.limit(n),W>0&&g.offset(W)),g}async getQuery(e,t){try{const{where:r={},with:o,withWhere:i,limit:s=10,offset:n=0,page:a,join:l,leftJoin:h,rightJoin:c,innerJoin:u,groupBy:p,having:d,distinct:y}=t,f=this.buildSelectQuery(e,t);let g=!1;const m=s;let W=n,b=1,_=0;a&&s>0?(b=Math.max(1,parseInt(a)),W=(b-1)*s):W>0&&s>0&&(b=Math.floor(W/s)+1);const w=[],A=f.toSQL();w.push(A.sql);const j=await f;if(o&&o.length>0){const t=this.buildWithTree(o);for(const r of Object.keys(t))await this.fetchAndAttachRelated({parentRows:j,relName:r,model:e,withTree:t[r],relation:e.hasRelations[r],withWhere:i||{}})}let k=null;if(s>0)try{const t=this.getQueryBuilder(e);let o;r&&Object.keys(r).length>0&&this._applyWhereClause(t,r),l&&this._applyJoins(t,l,"join"),h&&this._applyJoins(t,h,"leftJoin"),c&&this._applyJoins(t,c,"rightJoin"),u&&this._applyJoins(t,u,"innerJoin"),y&&(Array.isArray(y)||"string"==typeof y?t.distinct(y):t.distinct()),p&&t.groupBy(p),d&&this._applyHavingClause(t,d),o=p||y||d?this.db.count("* as cnt").from(t.as("__count_sub")):t.count("* as cnt"),w.push(o.toSQL().sql);const i=await o.first();k=Number(i.cnt)||0}catch(e){logger.warn("Failed to get total count:",e.message),k=j.length}s>0&&null!==k&&(_=Math.ceil(k/s),g=b<_);const v=g?b+1:null,J=b>1?b-1:null;return{data:j,totalCount:k,...this.controllerWrapper.debug?{sqlDebug:w}:{},...s>0?{pagination:{page:b,limit:m,offset:W,totalPages:_,hasNext:g,hasPrev:b>1,nextPage:v,prevPage:J}}:{}}}catch(t){if(t instanceof KormError)throw t;throw logger.error("QueryService.getQuery error:",t),new Error(`Failed to execute query: ${t.message} on model ${e.name}`)}}getSumQuery(e,t){const{where:r={},join:o,leftJoin:i,rightJoin:s,innerJoin:n}=t,a=t.data||t,l=a.sumColumn,h=a.sumFormula;if(!l&&!h)throw KormError.validationFailed({errors:[{field:"data",message:"Sum action requires either data.sumColumn or data.sumFormula"}],source:"sum"});const c=e=>"`"+String(e).replace(/[`\\]/g,"")+"`";let u;if(l){const e=String(l).trim().replace(/[^a-zA-Z0-9_]/g,"");if(!e)throw KormError.validationFailed({errors:[{field:"data.sumColumn",message:"data.sumColumn must be a valid column name"}],source:"sum"});u="SUM("+c(e)+")"}else{const e=String(h).trim();if(!/\{[a-zA-Z0-9_]+\}/.test(e))throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula must contain at least one {columnName}"}],source:"sum"});let t=0;for(const r of e)if("("===r)t++;else if(")"===r&&(t--,t<0))break;if(0!==t)throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula has unbalanced or misordered parentheses ( and )"}],source:"sum"});const r=e.replace(/\{[a-zA-Z0-9_]+\}/g,"@");if(!/^[\s0-9.+*\/\-()@]+$/.test(r))throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula may only use BODMAS: numbers, + - * /, parentheses, and {columnName}"}],source:"sum"});u="SUM("+e.replace(/\{([a-zA-Z0-9_]+)\}/g,(e,t)=>c(t))+")"}const p=this.getQueryBuilder(e);return o&&this._applyJoins(p,o,"join"),i&&this._applyJoins(p,i,"leftJoin"),s&&this._applyJoins(p,s,"rightJoin"),n&&this._applyJoins(p,n,"innerJoin"),this._applyWhereClause(p,r,[]),p.select(this.db.raw(u+" as sum")),p}_applyWhereWithArray(e,t,r){for(const o of t)this._applyWhereClause(e,o,r)}_applyWhereClause(e,t,r=[]){if(Array.isArray(t))this._applyWhereWithArray(e,t,r);else{if(t&&Object.keys(t).length>0){let r=this.helperUtility.getDotWalkQuery(t);logger.debug({filteredWhere:r}),r=this.helperUtility.objectFilter(r,(e,t)=>!e.startsWith("!")&&("__exists__"!==e&&("object"!=typeof t||null===t||Array.isArray(t))));for(const[t,o]of Object.entries(r)){const{joinType:r="AND",column:i}=this.parseWhereColumn(t),{operator:s,value:n}=this.parseWhereValue(o);"AND"===r?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}this._applyNestedWhere(e,t,r)}}_getNestedWhereConditions(e,t){if(!e||"object"!=typeof e)return{};const r=`${t}.`,o={};for(const[i,s]of Object.entries(e))if(i.startsWith(r)){o[i.slice(r.length)]=s}else i===t&&!0===s&&(o.__exists__=!0);return o}_getTopLevelRelationsFromWhere(e){if(!e||"object"!=typeof e)return[];const t=new Set;for(const r of Object.keys(e))if(r.includes(".")){const e=r.split(".")[0];t.add(e)}else r.startsWith("!")&&t.add(r);return[...t]}_applyNestedWhere(e,t,r){const o=this,i=e._getMyModel(),s=this._getTopLevelRelationsFromWhere(t);if(0!==s.length)for(const r of s){const s=r.startsWith("!"),n=s?r.slice(1):r,a=this._getNestedWhereConditions(t,r);if(0===Object.keys(a).length)continue;const l=i.hasRelations?.[n];if(!l){logger.warn(`Relation ${n} not found in model ${i.modelName}`);continue}let h;try{h=this.utils.getModel(this.controllerWrapper,l.table||n)}catch(e){try{h=this.utils.getModel(this.controllerWrapper,n)}catch(e){logger.warn(`Model for relation ${n} (table: ${l.table}) not found`);continue}}e[s?"whereNotExists":"whereExists"](function(){const e=o.getQueryBuilder(h,this.select("*").from(l.table));e.whereRaw("??.?? = ??.??",[l.table,l.foreignKey,i.table,l.localKey]);if(!(!0===a.__exists__&&1===Object.keys(a).length)){const t={...a};delete t.__exists__,o._applyWhereClause(e,t,[])}})}}_applyJoins(e,t,r){const o=Array.isArray(t)?t:[t];for(const t of o)"string"==typeof t?e[r](t):"object"==typeof t&&(t.table&&t.on?e[r](t.table,t.on):t.table&&t.first&&t.operator&&t.second&&e[r](t.table,t.first,t.operator,t.second))}_applyHavingClause(e,t){for(const[r,o]of Object.entries(t))"object"==typeof o&&o.operator?e.having(r,o.operator,o.value):e.having(r,"=",o)}_applyOrderBy(e,t){if(Array.isArray(t))for(const r of t)this._applyOrderByEntry(e,r);else this._applyOrderByEntry(e,t)}_applyOrderByEntry(e,t){if("string"!=typeof t){if(t&&"object"==typeof t){if(!t.column)throw KormError.validationFailed({errors:[{field:"orderBy",message:`orderBy object must include a "column" key (got keys: ${Object.keys(t).join(", ")||"none"})`,value:t,rule:"required"}],source:"orderBy"});e.orderBy(t.column,t.direction||"asc")}}else e.orderBy(t)}}module.exports=QueryBuilder;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
const QueryBuilder=require("./QueryBuilder");class QueryService{constructor(e,t
|
|
1
|
+
const QueryBuilder=require("./QueryBuilder");class QueryService{constructor(e,r,t=null){this.db=e,this.utils=r,this.controllerWrapper=t,this.queryBuilder=new QueryBuilder(e,r,t)}async getQuery(e,r){return await this.queryBuilder.getQuery(e,r)}async getSoftDeleteQuery(e,r){return await this.queryBuilder.getQuery(e,{...r,where:{...r.where||{},deleted_at:null}})}async executeShowQuery(e,r){const t=await this.getQuery(e,{...r,limit:1,offset:0});return t.data.length>0?t.data[0]:null}_buildCountQuery(e,r={}){const t=this.queryBuilder.getQueryBuilder(e),{join:u,leftJoin:i,rightJoin:s,innerJoin:a,where:l={}}=r;return u&&this.queryBuilder._applyJoins(t,u,"join"),i&&this.queryBuilder._applyJoins(t,i,"leftJoin"),s&&this.queryBuilder._applyJoins(t,s,"rightJoin"),a&&this.queryBuilder._applyJoins(t,a,"innerJoin"),this.queryBuilder._applyWhereClause(t,l,[]),t.count()}async executeCountQuery(e,r){const t=await this._buildCountQuery(e,r||{}),u=t&&t[0]?Object.values(t[0])[0]:0,i=Number(u);return Number.isNaN(i)?0:i}async executeSumQuery(e,r){const t=await this.queryBuilder.getSumQuery(e,r).first(),u=t&&null!=t.sum?t.sum:0;return Number(u)}async executeCreateQuery(e,r){const t=r.data,u=Array.isArray(t)?t:[t],i=e.columns&&e.columns.find(e=>e.primary),s=i&&i.name?i.name:"id";return await this.db.transaction(async r=>{const i=await r(e.table).insert(t),a=Array.isArray(i)?i[0]:i;if(null!=a&&0!==a){const t=await r(e.table).where(s,">=",a).limit(u.length).orderBy(s).select("*");return Array.isArray(t)?t:[t]}const l=u.map(e=>e[s]).filter(e=>null!=e);if(l.length>0){const t=await r(e.table).whereIn(s,l).select("*");return Array.isArray(t)?t:[t]}return u})}async executeUpdateQuery(e,r){return await this.db.transaction(async t=>{const u=this.queryBuilder.getQueryBuilder(e,t(e.table));this.queryBuilder._applyWhereClause(u,r.where||{},[]),await u.update(r.data);const i=this.queryBuilder.getQueryBuilder(e,t(e.table));return this.queryBuilder._applyWhereClause(i,r.where||{},[]),await i.select("*")})}async executeDeleteQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.delete()}async executeSoftDeleteQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update({deleted_at:new Date});const u=this.queryBuilder.getQueryBuilder(e);this.queryBuilder._applyWhereClause(u,r.where||{},[]);const i=await u.select("*");return Array.isArray(i)?i:[i]}async executeRestoreQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update({deleted_at:null});const u=this.queryBuilder.getQueryBuilder(e);this.queryBuilder._applyWhereClause(u,r.where||{},[]);const i=await u.select("*");return Array.isArray(i)?i:[i]}async executeUpsertQuery(e,r){return await this.db(e.table).insert(r.data).onConflict(r.conflict).merge(r.data)}_buildReplaceQuery(e,r){const{sql:t,bindings:u}=this.db(e.table).insert(r.data).toSQL();return this.db.raw(t.replace(/^\s*insert/i,"REPLACE"),u)}async executeReplaceQuery(e,r){return await this._buildReplaceQuery(e,r),r.data}buildDryRun(e,r,t){const u=this._dryRunBuilders(e,r,t).map(e=>{const r=e.toSQL();return{sql:r.sql,bindings:r.bindings}});return{success:!0,dryRun:!0,action:t,model:e.name,sql:u[0]?u[0].sql:null,bindings:u[0]?u[0].bindings:[],statements:u}}_dryRunBuilders(e,r,t){const u=e.table,i=r.where||{};switch(t){case"list":return[this.queryBuilder.buildSelectQuery(e,r)];case"show":return[this.queryBuilder.buildSelectQuery(e,{...r,limit:1,offset:0})];case"count":return[this._buildCountQuery(e,r)];case"sum":return[this.queryBuilder.getSumQuery(e,r)];case"create":return[this.db(u).insert(r.data)];case"update":{const t=this.queryBuilder.getQueryBuilder(e);this.queryBuilder._applyWhereClause(t,i,[]);const s=this.queryBuilder.getQueryBuilder(e,this.db(u));return this.queryBuilder._applyWhereClause(s,i,[]),[t.update(r.data),s.select("*")]}case"softDelete":{const r=this.queryBuilder.getQueryBuilder(e,this.db(u));this.queryBuilder._applyWhereClause(r,i,[]);const t=this.queryBuilder.getQueryBuilder(e,this.db(u));return this.queryBuilder._applyWhereClause(t,i,[]),[r.update({deleted_at:new Date}),t.select("*")]}case"delete":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.delete()]}case"replace":return[this._buildReplaceQuery(e,r)];case"upsert":return[this.db(u).insert(r.data).onConflict(r.conflict).merge(r.data)];case"sync":{const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,i,[]),[this.db(u).insert(r.data).onConflict(r.conflict).merge(r.data),t.delete()]}case"restore":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.update({deleted_at:null}).returning("*")]}default:return[]}}async executeSyncQuery(e,r){return this.db.transaction(async t=>{const u=await t(e.table).insert(r.data).onConflict(r.conflict).merge(r.data),i=this.queryBuilder.getQueryBuilder(e,t(e.table));this.queryBuilder._applyWhereClause(i,r.where||{},[]);return{insertOrUpdateQuery:u,deleteQuery:await i.delete()}})}}module.exports=QueryService;
|
package/clients/pg/CurdTable.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const QueryService=require("./QueryService"),HookService=require("./HookService"),KormError=require("../../KormError");class CurdTable{constructor(e,r,t=null){if(this.db=e,this.utils=r,this.controllerWrapper=t,this.hookService=new HookService(e,r,t),this.queryService=new QueryService(e,r,t),!this.queryService)throw new KormError("CurdTable requires queryService (execute*Query / getQuery).",{code:KormError.CODES.INTERNAL})}async processRequest(e,r=null,t=
|
|
1
|
+
const QueryService=require("./QueryService"),HookService=require("./HookService"),KormError=require("../../KormError"),{mapWithConcurrency:mapWithConcurrency,resolveFanOutConcurrency:resolveFanOutConcurrency}=require("../../ConnectionResilience");class CurdTable{constructor(e,r,t=null){if(this.db=e,this.utils=r,this.controllerWrapper=t,this.hookService=new HookService(e,r,t),this.queryService=new QueryService(e,r,t),!this.queryService)throw new KormError("CurdTable requires queryService (execute*Query / getQuery).",{code:KormError.CODES.INTERNAL})}async _injectSoftDeleteWhere(e,r,t="deleted_at"){return this.hookService?.executeHasSoftDeleteHook&&await this.hookService.executeHasSoftDeleteHook(e)&&(r={...r,where:{...r.where||{},[t]:null}}),r}async processRequest(e,r=null,t={},o=0){if(o>10)throw new KormError("other_requests nesting depth exceeded maximum (10)",{code:KormError.CODES.INTERNAL,context:{depth:o}});const c=this.controllerWrapper,s=this.utils.getModel(this.controllerWrapper,r),i=e?.action||"list";let a=null;if(new Set(["update","delete","softDelete"]).has(i)){const r=e?.where;if(!r||"object"==typeof r&&0===Object.keys(r).length)throw KormError.validationFailed({errors:[{field:"where",message:`where is required for action "${i}"`}],source:i})}const u={model:s,action:i,request:e,ctx:t,controller:c};if(this.hookService?.executeValidatorHook&&await this.hookService.executeValidatorHook({...u}),e?.dryRun)return this.buildDryRunResult(s,e,i);switch(this.hookService?.executeBeforeHook&&(e.beforeActionData=await this.hookService.executeBeforeHook({...u})),i){case"count":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeCountQuery(s,e);break;case"sum":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeSumQuery(s,e);break;case"list":a=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.getSoftDeleteQuery(s,e):await this.queryService.getQuery(s,e);break;case"show":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeShowQuery(s,e);break;case"create":a=await this.queryService.executeCreateQuery(s,e);break;case"update":{const r=await this.queryService.executeUpdateQuery(s,e);if(!(r&&r.length>0))throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record updated successfully",data:r,success:!0};break}case"replace":if(a=await this.queryService.executeReplaceQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record replaced successfully",data:a,success:!0};break;case"upsert":if(a=await this.queryService.executeUpsertQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record upserted successfully",data:a,success:!0};break;case"sync":if(a=await this.queryService.executeSyncQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record synced successfully",data:a,success:!0};break;case"delete":{let r=null;if(r=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.executeSoftDeleteQuery(s,e):await this.queryService.executeDeleteQuery(s,e),!r)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record deleted successfully",data:r,success:!0};break}case"restore":{if(!await(this.hookService?.executeHasSoftDeleteHook?.(s)))throw KormError.unknownAction({action:i,model:s.name});const r=await this.queryService.executeRestoreQuery(s,e);if(!(r&&r.length>0))throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record restored successfully",data:r,success:!0};break}default:if(!this.hookService?.executeCustomAction)throw KormError.unknownAction({action:i,model:s?.name});a=await this.hookService.executeCustomAction({...u})}if(this.hookService?.executeAfterHook&&(a=await this.hookService.executeAfterHook({...u,data:a})),"object"==typeof a&&null!==a&&e?.other_requests&&"object"==typeof e.other_requests){const r={},c=Object.entries(e.other_requests),s=50;let i=0;for(const[,e]of c)i+=Array.isArray(e)?e.length:1;if(i>s)throw new KormError(`other_requests fan-out exceeded maximum (${s})`,{code:KormError.CODES.INTERNAL,context:{fanOut:i}});for(const[e,s]of c)if(this.controllerWrapper?._authz?.hasRules()&&this.controllerWrapper._authz.enforce(e,s?.action||"list",s,t),Array.isArray(s)){const c=resolveFanOutConcurrency(this.db,this.controllerWrapper?.fanOutConcurrency);r[e]=await mapWithConcurrency(s,c,r=>this.processRequest(r,e,t,o+1))}else r[e]=await this.processRequest(s,e,t,o+1);a.other_responses=r}return a}async buildDryRunResult(e,r,t){if(!["list","show","count","sum","create","update","replace","upsert","sync","delete","restore"].includes(t))throw KormError.unknownAction({action:t,model:e?.name});let o=t,c=r;const s=await this.hookService.executeHasSoftDeleteHook(e);if(s&&"delete"===t)o="softDelete";else if(s&&"list"===t)c={...r,where:{...r.where||{},deleted_at:null}};else if("restore"===t&&!s)throw KormError.unknownAction({action:t,model:e?.name});return this.queryService.buildDryRun(e,c,o)}}module.exports=CurdTable;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
const HelperUtility=require("./HelperUtility"),logger=require("../../Logger"),KormError=require("../../KormError");class QueryBuilder{constructor(e,t,r=null){this.db=e,this.utils=t,this.controllerWrapper=r,this.helperUtility=new HelperUtility}getHookService(){return this.controllerWrapper.hookService}getQueryBuilder(e,t=null){let r=this.db(e.table);return t&&(r=t),r._getMyModel=()=>e,r}parseValue(e){return this.helperUtility.parseValue(e)}parseWhereValue(e){return this.helperUtility.parseWhereValue(e)}parseWhereColumn(e){return this.helperUtility.parseWhereColumn(e)}_applyOrWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.orWhereBetween(t,o);break;case"notBetween":e.orWhereNotBetween(t,o);break;case"in":e.orWhereIn(t,o);break;case"notIn":e.orWhereNotIn(t,o);break;case"like":e.orWhere(t,"like",o);break;default:Array.isArray(o)?e.orWhereIn(t,o):e.orWhere(t,r,o)}else e.orWhereNull(t)}_applyAndWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.whereBetween(t,o);break;case"notBetween":e.whereNotBetween(t,o);break;case"in":e.whereIn(t,o);break;case"notIn":e.whereNotIn(t,o);break;case"like":e.where(t,"like",o);break;default:Array.isArray(o)?e.whereIn(t,o):e.where(t,r,o)}else e.whereNull(t)}buildWithTree(e,t=null){return this.helperUtility.dotWalkTree(e,{resolver:({current:e,part:r,source:o})=>t?t({current:e,part:r,source:o}):{}})}_getWithWhereForRelation(e,t){if(!e||"object"!=typeof e)return{direct:{},nested:{}};const r=`${t}.`,o={},i={};for(const[t,s]of Object.entries(e))if(t.startsWith(r)){const e=t.slice(r.length);e.includes(".")?i[e]=s:o[e]=s}return{direct:o,nested:i}}_applyWithWhereConditions(e,t){for(const[r,o]of Object.entries(t)){const{joinType:t="AND",column:i}=this.parseWhereColumn(r),{operator:s,value:n}=this.parseWhereValue(o);"AND"===t?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}async fetchRelatedRows(e,t,r={}){if(e.through){const o=await this.db(e.through).whereIn(e.throughLocalKey,t),i=this.db(e.table).whereIn(e.foreignKey,o.map(t=>t[e.throughForeignKey]));return Object.keys(r).length>0&&this._applyWithWhereConditions(i,r),i}{const o=this.db(e.table).whereIn(e.foreignKey,t);return Object.keys(r).length>0&&this._applyWithWhereConditions(o,r),o}}async fetchAndAttachRelated(e){const{parentRows:t,relName:r,model:o,withTree:i,relation:s,withWhere:n={}}=e;if(!s){const e=this.getHookService().getModelInstance(o),s=`get${r.charAt(0).toUpperCase()+r.slice(1)}Relation`;if("function"==typeof e[s]){const{direct:l,nested:a}=this._getWithWhereForRelation(n,r),h={rows:t,relName:r,model:o,withTree:i,controller:this.controllerWrapper,relation:o.hasRelations[r],qb:this,db:this.db,withWhere:l,nestedWithWhere:a};await e[s](h)}else logger.warn(`Method ${s} not found in model ${o.name}`);return t}const l=t.map(e=>e[s.localKey]),a="one"===s?.type;let h=[];const{direct:c,nested:p}=this._getWithWhereForRelation(n,r);let u={...c};try{const e=this.utils.getModel(this.controllerWrapper,r);if(this.controllerWrapper?.hookService){await this.controllerWrapper.hookService.executeHasSoftDeleteHook(e)&&(u={...u,deleted_at:null})}}catch(e){}h=await this.fetchRelatedRows(s,l,u);const y=new Map;for(const e of h){const t=e[s.foreignKey];y.has(t)||y.set(t,[]),y.get(t).push(e)}for(const e of t){const t=e[s.localKey];a&&1==y.get(t)?.length?e[r]=y.get(t)[0]:e[r]=y.get(t)||[]}const d=Object.keys(i);for(const e of d){const o=i[e],s=t.filter(e=>a?e[r]:e[r].length>0).map(e=>e[r]).reduce((e,t)=>e.concat(t),[]),n=this.utils.getModel(this.controllerWrapper,r);await this.fetchAndAttachRelated({parentRows:s,relName:e,model:n,withTree:o,relation:n.hasRelations[e],withWhere:p})}return t}filterWhere(e,t=""){return t?Object.keys(e).filter(e=>e.includes(t)).reduce((r,o)=>(r[o.replace(t,"")]=e[o],r),{}):Object.keys(e).filter(e=>!e.includes(".")).reduce((t,r)=>(t[r]=e[r],t),{})}buildSelectQuery(e,t){const{where:r={},with:o,select:i,orderBy:s,limit:n=10,offset:l=0,page:a,groupBy:h,having:c,distinct:p,join:u,leftJoin:y,rightJoin:d,innerJoin:f}=t,g=this.getQueryBuilder(e);i&&(Array.isArray(i)||"string"==typeof i)?g.select(i):g.select("*"),p&&(Array.isArray(p)||"string"==typeof p?g.distinct(p):g.distinct()),u&&this._applyJoins(g,u,"join"),y&&this._applyJoins(g,y,"leftJoin"),d&&this._applyJoins(g,d,"rightJoin"),f&&this._applyJoins(g,f,"innerJoin"),this._applyWhereClause(g,r,o),h&&g.groupBy(h),c&&this._applyHavingClause(g,c);const W=void 0===s?this.helperUtility.resolveDefaultOrderBy(e):s;W&&this._applyOrderBy(g,W);let w=l;return a&&n>0&&(w=(Math.max(1,parseInt(a))-1)*n),n>0&&(g.limit(n),w>0&&g.offset(w)),g}async getQuery(e,t){try{const{where:r={},with:o,withWhere:i,limit:s=10,offset:n=0,page:l,join:a,leftJoin:h,rightJoin:c,innerJoin:p}=t,u=this.buildSelectQuery(e,t);let y=!1;const d=s;let f=n,g=1,W=0;l&&s>0&&(g=Math.max(1,parseInt(l)),f=(g-1)*s);const w=[],m=u.toSQL();w.push(m.sql);const b=await u;if(o&&o.length>0){const t=this.buildWithTree(o);for(const r of Object.keys(t))await this.fetchAndAttachRelated({parentRows:b,relName:r,model:e,withTree:t[r],relation:e.hasRelations[r],withWhere:i||{}})}let _=null;if(s>0)try{const t=this.getQueryBuilder(e);r&&Object.keys(r).length>0&&this._applyWhereClause(t,r),a&&this._applyJoins(t,a,"join"),h&&this._applyJoins(t,h,"leftJoin"),c&&this._applyJoins(t,c,"rightJoin"),p&&this._applyJoins(t,p,"innerJoin");const o=t.count("* as cnt");w.push(o.toSQL().sql);_=(await o.first()).cnt}catch(e){logger.warn("Failed to get total count:",e.message),_=b.length}s>0&&null!==_&&(W=Math.ceil(_/s),y=g<W);const j=y?g+1:null,k=g>1?g-1:null;return{data:b,totalCount:_,...this.controllerWrapper.debug?{sqlDebug:w}:{},...s>0?{pagination:{page:g,limit:d,offset:f,totalPages:W,hasNext:y,hasPrev:g>1,nextPage:j,prevPage:k}}:{}}}catch(t){throw logger.error("QueryService.getQuery error:",t),new Error(`Failed to execute query: ${t.message} on model ${e.name}`)}}getSumQuery(e,t){const{where:r={},join:o,leftJoin:i,rightJoin:s,innerJoin:n}=t,l=t.data||t,a=l.sumColumn,h=l.sumFormula;if(!a&&!h)throw new Error("Sum action requires either data.sumColumn or data.sumFormula");const c=e=>'"'+String(e).replace(/["\\]/g,"")+'"';let p;if(a){const e=String(a).trim().replace(/[^a-zA-Z0-9_]/g,"");if(!e)throw new Error("data.sumColumn must be a valid column name");p="SUM("+c(e)+")"}else{const e=String(h).trim();if(!/\{[a-zA-Z0-9_]+\}/.test(e))throw new Error("data.sumFormula must contain at least one {columnName}");let t=0;for(const r of e)if("("===r)t++;else if(")"===r&&(t--,t<0))break;if(0!==t)throw new Error("data.sumFormula has unbalanced or misordered parentheses ( and )");const r=e.replace(/\{[a-zA-Z0-9_]+\}/g,"@");if(!/^[\s0-9.+*\/\-()@]+$/.test(r))throw new Error("data.sumFormula may only use BODMAS: numbers, + - * /, parentheses, and {columnName}");p="SUM("+e.replace(/\{([a-zA-Z0-9_]+)\}/g,(e,t)=>c(t))+")"}const u=this.getQueryBuilder(e);return o&&this._applyJoins(u,o,"join"),i&&this._applyJoins(u,i,"leftJoin"),s&&this._applyJoins(u,s,"rightJoin"),n&&this._applyJoins(u,n,"innerJoin"),this._applyWhereClause(u,r,[]),u.select(this.db.raw(p+" as sum")),u}_applyWhereWithArray(e,t,r){for(const o of t)this._applyWhereClause(e,o,r)}_applyWhereClause(e,t,r=[]){if(Array.isArray(t))this._applyWhereWithArray(e,t,r);else{if(t&&Object.keys(t).length>0){let r=this.helperUtility.getDotWalkQuery(t);logger.debug({filteredWhere:r}),r=this.helperUtility.objectFilter(r,(e,t)=>!e.startsWith("!")&&("__exists__"!==e&&("object"!=typeof t||null===t)));for(const[t,o]of Object.entries(r)){const{joinType:r="AND",column:i}=this.parseWhereColumn(t),{operator:s,value:n}=this.parseWhereValue(o);"AND"===r?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}this._applyNestedWhere(e,t,r)}}_getNestedWhereConditions(e,t){if(!e||"object"!=typeof e)return{};const r=`${t}.`,o={};for(const[i,s]of Object.entries(e))if(i.startsWith(r)){o[i.slice(r.length)]=s}else i===t&&!0===s&&(o.__exists__=!0);return o}_getTopLevelRelationsFromWhere(e){if(!e||"object"!=typeof e)return[];const t=new Set;for(const r of Object.keys(e))if(r.includes(".")){const e=r.split(".")[0];t.add(e)}else r.startsWith("!")&&t.add(r);return[...t]}_applyNestedWhere(e,t,r){const o=this,i=e._getMyModel(),s=this._getTopLevelRelationsFromWhere(t);if(0!==s.length)for(const r of s){const s=r.startsWith("!"),n=s?r.slice(1):r,l=this._getNestedWhereConditions(t,r);if(0===Object.keys(l).length)continue;const a=i.hasRelations?.[n];if(!a){logger.warn(`Relation ${n} not found in model ${i.modelName}`);continue}let h;try{h=this.utils.getModel(this.controllerWrapper,a.table||n)}catch(e){try{h=this.utils.getModel(this.controllerWrapper,n)}catch(e){logger.warn(`Model for relation ${n} (table: ${a.table}) not found`);continue}}e[s?"whereNotExists":"whereExists"](function(){const e=o.getQueryBuilder(h,this.select("*").from(a.table));e.whereRaw("??.?? = ??.??",[a.table,a.foreignKey,i.table,a.localKey]);if(!(!0===l.__exists__&&1===Object.keys(l).length)){const t={...l};delete t.__exists__,o._applyWhereClause(e,t,[])}})}}_applyJoins(e,t,r){const o=Array.isArray(t)?t:[t];for(const t of o)"string"==typeof t?e[r](t):"object"==typeof t&&(t.table&&t.on?e[r](t.table,t.on):t.table&&t.first&&t.operator&&t.second&&e[r](t.table,t.first,t.operator,t.second))}_applyWithWhere(e,t){try{if(Array.isArray(t))for(const r of t)"string"==typeof r?e.withWhere(r):"object"==typeof r&&e.withWhere(r.column,r.operator,r.value);else if("object"==typeof t)for(const[r,o]of Object.entries(t))e.withWhere(r,o)}catch(e){logger.warn("Failed to apply withWhere:",e.message)}}_applyHavingClause(e,t){for(const[r,o]of Object.entries(t))"object"==typeof o&&o.operator?e.having(r,o.operator,o.value):e.having(r,o)}_applyOrderBy(e,t){if(Array.isArray(t))for(const r of t)this._applyOrderByEntry(e,r);else this._applyOrderByEntry(e,t)}_applyOrderByEntry(e,t){if("string"!=typeof t){if(t&&"object"==typeof t){if(!t.column)throw KormError.validationFailed({errors:[{field:"orderBy",message:`orderBy object must include a "column" key (got keys: ${Object.keys(t).join(", ")||"none"})`,value:t,rule:"required"}],source:"orderBy"});e.orderBy(t.column,t.direction||"asc")}}else e.orderBy(t)}}module.exports=QueryBuilder;
|
|
1
|
+
const HelperUtility=require("./HelperUtility"),logger=require("../../Logger"),KormError=require("../../KormError");class QueryBuilder{constructor(e,t,r=null){this.db=e,this.utils=t,this.controllerWrapper=r,this.helperUtility=new HelperUtility}getHookService(){return this.controllerWrapper.hookService}getQueryBuilder(e,t=null){let r=this.db(e.table);return t&&(r=t),r._getMyModel=()=>e,r}parseValue(e){return this.helperUtility.parseValue(e)}parseWhereValue(e){return this.helperUtility.parseWhereValue(e)}parseWhereColumn(e){return this.helperUtility.parseWhereColumn(e)}_applyOrWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.orWhereBetween(t,o);break;case"notBetween":e.orWhereNotBetween(t,o);break;case"in":e.orWhereIn(t,o);break;case"notIn":e.orWhereNotIn(t,o);break;case"like":e.orWhere(t,"ilike",o);break;default:Array.isArray(o)?e.orWhereIn(t,o):e.orWhere(t,r,o)}else e.orWhereNull(t)}_applyAndWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.whereBetween(t,o);break;case"notBetween":e.whereNotBetween(t,o);break;case"in":e.whereIn(t,o);break;case"notIn":e.whereNotIn(t,o);break;case"like":e.where(t,"ilike",o);break;default:Array.isArray(o)?e.whereIn(t,o):e.where(t,r,o)}else e.whereNull(t)}buildWithTree(e,t=null){return this.helperUtility.dotWalkTree(e,{resolver:({current:e,part:r,source:o})=>t?t({current:e,part:r,source:o}):{}})}_getWithWhereForRelation(e,t){if(!e||"object"!=typeof e)return{direct:{},nested:{}};const r=`${t}.`,o={},i={};for(const[t,s]of Object.entries(e))if(t.startsWith(r)){const e=t.slice(r.length);e.includes(".")?i[e]=s:o[e]=s}return{direct:o,nested:i}}_applyWithWhereConditions(e,t){for(const[r,o]of Object.entries(t)){const{joinType:t="AND",column:i}=this.parseWhereColumn(r),{operator:s,value:n}=this.parseWhereValue(o);"AND"===t?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}async fetchRelatedRows(e,t,r={}){if(e.through){const o=await this.db(e.through).whereIn(e.throughLocalKey,t),i=this.db(e.table).whereIn(e.foreignKey,o.map(t=>t[e.throughForeignKey]));return Object.keys(r).length>0&&this._applyWithWhereConditions(i,r),i}{const o=this.db(e.table).whereIn(e.foreignKey,t);return Object.keys(r).length>0&&this._applyWithWhereConditions(o,r),o}}async fetchAndAttachRelated(e){const{parentRows:t,relName:r,model:o,withTree:i,relation:s,withWhere:n={}}=e;if(!s){const e=this.getHookService().getModelInstance(o),s=`get${r.charAt(0).toUpperCase()+r.slice(1)}Relation`;if("function"==typeof e[s]){const{direct:a,nested:l}=this._getWithWhereForRelation(n,r),h={rows:t,relName:r,model:o,withTree:i,controller:this.controllerWrapper,relation:o.hasRelations[r],qb:this,db:this.db,withWhere:a,nestedWithWhere:l};await e[s](h)}else logger.warn(`Method ${s} not found in model ${o.name}`);return t}const a=t.map(e=>e[s.localKey]),l="one"===s?.type;let h=[];const{direct:c,nested:u}=this._getWithWhereForRelation(n,r);let p={...c};try{const e=this.utils.getModel(this.controllerWrapper,r);if(this.controllerWrapper?.hookService){await this.controllerWrapper.hookService.executeHasSoftDeleteHook(e)&&(p={...p,deleted_at:null})}}catch(e){}h=await this.fetchRelatedRows(s,a,p);const d=new Map;for(const e of h){const t=e[s.foreignKey];d.has(t)||d.set(t,[]),d.get(t).push(e)}for(const e of t){const t=e[s.localKey];l&&1==d.get(t)?.length?e[r]=d.get(t)[0]:e[r]=d.get(t)||[]}const y=Object.keys(i);for(const e of y){const o=i[e],s=t.filter(e=>l?e[r]:e[r].length>0).map(e=>e[r]).reduce((e,t)=>e.concat(t),[]),n=this.utils.getModel(this.controllerWrapper,r);await this.fetchAndAttachRelated({parentRows:s,relName:e,model:n,withTree:o,relation:n.hasRelations[e],withWhere:u})}return t}filterWhere(e,t=""){return t?Object.keys(e).filter(e=>e.includes(t)).reduce((r,o)=>(r[o.replace(t,"")]=e[o],r),{}):Object.keys(e).filter(e=>!e.includes(".")).reduce((t,r)=>(t[r]=e[r],t),{})}buildSelectQuery(e,t){const{where:r={},with:o,select:i,orderBy:s,limit:n=10,offset:a=0,page:l,groupBy:h,having:c,distinct:u,join:p,leftJoin:d,rightJoin:y,innerJoin:f}=t,g=this.getQueryBuilder(e);i&&(Array.isArray(i)||"string"==typeof i)?g.select(i):g.select("*"),u&&(Array.isArray(u)||"string"==typeof u?g.distinct(u):g.distinct()),p&&this._applyJoins(g,p,"join"),d&&this._applyJoins(g,d,"leftJoin"),y&&this._applyJoins(g,y,"rightJoin"),f&&this._applyJoins(g,f,"innerJoin"),this._applyWhereClause(g,r,o),h&&g.groupBy(h),c&&this._applyHavingClause(g,c);const m=void 0===s?this.helperUtility.resolveDefaultOrderBy(e):s;m&&this._applyOrderBy(g,m);let W=a;return l&&n>0&&(W=(Math.max(1,parseInt(l))-1)*n),n>0&&(g.limit(n),W>0&&g.offset(W)),g}async getQuery(e,t){try{const{where:r={},with:o,withWhere:i,limit:s=10,offset:n=0,page:a,join:l,leftJoin:h,rightJoin:c,innerJoin:u,groupBy:p,having:d,distinct:y}=t,f=this.buildSelectQuery(e,t);let g=!1;const m=s;let W=n,b=1,_=0;a&&s>0?(b=Math.max(1,parseInt(a)),W=(b-1)*s):W>0&&s>0&&(b=Math.floor(W/s)+1);const w=[],A=f.toSQL();w.push(A.sql);const k=await f;if(o&&o.length>0){const t=this.buildWithTree(o);for(const r of Object.keys(t))await this.fetchAndAttachRelated({parentRows:k,relName:r,model:e,withTree:t[r],relation:e.hasRelations[r],withWhere:i||{}})}let j=null;if(s>0)try{const t=this.getQueryBuilder(e);let o;r&&Object.keys(r).length>0&&this._applyWhereClause(t,r),l&&this._applyJoins(t,l,"join"),h&&this._applyJoins(t,h,"leftJoin"),c&&this._applyJoins(t,c,"rightJoin"),u&&this._applyJoins(t,u,"innerJoin"),y&&(Array.isArray(y)||"string"==typeof y?t.distinct(y):t.distinct()),p&&t.groupBy(p),d&&this._applyHavingClause(t,d),o=p||y||d?this.db.count("* as cnt").from(t.as("__count_sub")):t.count("* as cnt"),w.push(o.toSQL().sql);const i=await o.first();j=Number(i.cnt)||0}catch(e){logger.warn("Failed to get total count:",e.message),j=k.length}s>0&&null!==j&&(_=Math.ceil(j/s),g=b<_);const v=g?b+1:null,J=b>1?b-1:null;return{data:k,totalCount:j,...this.controllerWrapper.debug?{sqlDebug:w}:{},...s>0?{pagination:{page:b,limit:m,offset:W,totalPages:_,hasNext:g,hasPrev:b>1,nextPage:v,prevPage:J}}:{}}}catch(t){if(t instanceof KormError)throw t;throw logger.error("QueryService.getQuery error:",t),new Error(`Failed to execute query: ${t.message} on model ${e.name}`)}}getSumQuery(e,t){const{where:r={},join:o,leftJoin:i,rightJoin:s,innerJoin:n}=t,a=t.data||t,l=a.sumColumn,h=a.sumFormula;if(!l&&!h)throw KormError.validationFailed({errors:[{field:"data",message:"Sum action requires either data.sumColumn or data.sumFormula"}],source:"sum"});const c=e=>'"'+String(e).replace(/["\\]/g,"")+'"';let u;if(l){const e=String(l).trim().replace(/[^a-zA-Z0-9_]/g,"");if(!e)throw KormError.validationFailed({errors:[{field:"data.sumColumn",message:"data.sumColumn must be a valid column name"}],source:"sum"});u="SUM("+c(e)+")"}else{const e=String(h).trim();if(!/\{[a-zA-Z0-9_]+\}/.test(e))throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula must contain at least one {columnName}"}],source:"sum"});let t=0;for(const r of e)if("("===r)t++;else if(")"===r&&(t--,t<0))break;if(0!==t)throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula has unbalanced or misordered parentheses ( and )"}],source:"sum"});const r=e.replace(/\{[a-zA-Z0-9_]+\}/g,"@");if(!/^[\s0-9.+*\/\-()@]+$/.test(r))throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula may only use BODMAS: numbers, + - * /, parentheses, and {columnName}"}],source:"sum"});u="SUM("+e.replace(/\{([a-zA-Z0-9_]+)\}/g,(e,t)=>c(t))+")"}const p=this.getQueryBuilder(e);return o&&this._applyJoins(p,o,"join"),i&&this._applyJoins(p,i,"leftJoin"),s&&this._applyJoins(p,s,"rightJoin"),n&&this._applyJoins(p,n,"innerJoin"),this._applyWhereClause(p,r,[]),p.select(this.db.raw(u+" as sum")),p}_applyWhereWithArray(e,t,r){for(const o of t)this._applyWhereClause(e,o,r)}_applyWhereClause(e,t,r=[]){if(Array.isArray(t))this._applyWhereWithArray(e,t,r);else{if(t&&Object.keys(t).length>0){let r=this.helperUtility.getDotWalkQuery(t);r=this.helperUtility.objectFilter(r,(e,t)=>!e.startsWith("!")&&("__exists__"!==e&&("object"!=typeof t||null===t||Array.isArray(t))));for(const[t,o]of Object.entries(r)){const{joinType:r="AND",column:i}=this.parseWhereColumn(t),{operator:s,value:n}=this.parseWhereValue(o);"AND"===r?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}this._applyNestedWhere(e,t,r)}}_getNestedWhereConditions(e,t){if(!e||"object"!=typeof e)return{};const r=`${t}.`,o={};for(const[i,s]of Object.entries(e))if(i.startsWith(r)){o[i.slice(r.length)]=s}else i===t&&!0===s&&(o.__exists__=!0);return o}_getTopLevelRelationsFromWhere(e){if(!e||"object"!=typeof e)return[];const t=new Set;for(const r of Object.keys(e))if(r.includes(".")){const e=r.split(".")[0];t.add(e)}else r.startsWith("!")&&t.add(r);return[...t]}_applyNestedWhere(e,t,r){const o=this,i=e._getMyModel(),s=this._getTopLevelRelationsFromWhere(t);if(0!==s.length)for(const r of s){const s=r.startsWith("!"),n=s?r.slice(1):r,a=this._getNestedWhereConditions(t,r);if(0===Object.keys(a).length)continue;const l=i.hasRelations?.[n];if(!l){logger.warn(`Relation ${n} not found in model ${i.modelName}`);continue}let h;try{h=this.utils.getModel(this.controllerWrapper,l.table||n)}catch(e){try{h=this.utils.getModel(this.controllerWrapper,n)}catch(e){logger.warn(`Model for relation ${n} (table: ${l.table}) not found`);continue}}e[s?"whereNotExists":"whereExists"](function(){const e=o.getQueryBuilder(h,this.select("*").from(l.table));e.whereRaw("??.?? = ??.??",[l.table,l.foreignKey,i.table,l.localKey]);if(!(!0===a.__exists__&&1===Object.keys(a).length)){const t={...a};delete t.__exists__,o._applyWhereClause(e,t,[])}})}}_applyJoins(e,t,r){const o=Array.isArray(t)?t:[t];for(const t of o)"string"==typeof t?e[r](t):"object"==typeof t&&(t.table&&t.on?e[r](t.table,t.on):t.table&&t.first&&t.operator&&t.second&&e[r](t.table,t.first,t.operator,t.second))}_applyHavingClause(e,t){for(const[r,o]of Object.entries(t))"object"==typeof o&&o.operator?e.having(r,o.operator,o.value):e.having(r,"=",o)}_applyOrderBy(e,t){if(Array.isArray(t))for(const r of t)this._applyOrderByEntry(e,r);else this._applyOrderByEntry(e,t)}_applyOrderByEntry(e,t){if("string"!=typeof t){if(t&&"object"==typeof t){if(!t.column)throw KormError.validationFailed({errors:[{field:"orderBy",message:`orderBy object must include a "column" key (got keys: ${Object.keys(t).join(", ")||"none"})`,value:t,rule:"required"}],source:"orderBy"});e.orderBy(t.column,t.direction||"asc")}}else e.orderBy(t)}}module.exports=QueryBuilder;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
const QueryBuilder=require("./QueryBuilder"),KormError=require("../../KormError");class QueryService{constructor(e,r,t=null){this.db=e,this.utils=r,this.controllerWrapper=t,this.queryBuilder=new QueryBuilder(e,r,t)}async getQuery(e,r){return await this.queryBuilder.getQuery(e,r)}async getSoftDeleteQuery(e,r){return await this.queryBuilder.getQuery(e,{...r,where:{...r.where||{},deleted_at:null}})}async executeShowQuery(e,r){const t=await this.getQuery(e,{...r,limit:1,offset:0});return t.data.length>0?t.data[0]:null}_buildCountQuery(e,r={}){const t=this.queryBuilder.getQueryBuilder(e),{join:u,leftJoin:i,rightJoin:
|
|
1
|
+
const QueryBuilder=require("./QueryBuilder"),KormError=require("../../KormError");class QueryService{constructor(e,r,t=null){this.db=e,this.utils=r,this.controllerWrapper=t,this.queryBuilder=new QueryBuilder(e,r,t)}async getQuery(e,r){return await this.queryBuilder.getQuery(e,r)}async getSoftDeleteQuery(e,r){return await this.queryBuilder.getQuery(e,{...r,where:{...r.where||{},deleted_at:null}})}async executeShowQuery(e,r){const t=await this.getQuery(e,{...r,limit:1,offset:0});return t.data.length>0?t.data[0]:null}_buildCountQuery(e,r={}){const t=this.queryBuilder.getQueryBuilder(e),{join:u,leftJoin:i,rightJoin:l,innerJoin:n,where:a={}}=r;return u&&this.queryBuilder._applyJoins(t,u,"join"),i&&this.queryBuilder._applyJoins(t,i,"leftJoin"),l&&this.queryBuilder._applyJoins(t,l,"rightJoin"),n&&this.queryBuilder._applyJoins(t,n,"innerJoin"),this.queryBuilder._applyWhereClause(t,a,[]),t.count()}async executeCountQuery(e,r){const t=await this._buildCountQuery(e,r||{}),u=t&&t[0]?Object.values(t[0])[0]:0,i=Number(u);return Number.isNaN(i)?0:i}async executeSumQuery(e,r){const t=await this.queryBuilder.getSumQuery(e,r).first(),u=t&&null!=t.sum?t.sum:0;return Number(u)}async executeCreateQuery(e,r){return await this.db(e.table).insert(r.data).returning("*")}async executeUpdateQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update(r.data).returning("*")}async executeDeleteQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.delete()}async executeSoftDeleteQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update({deleted_at:new Date}).returning("*")}async executeRestoreQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update({deleted_at:null}).returning("*")}async executeUpsertQuery(e,r){return await this.db(e.table).insert(r.data).onConflict(r.conflict).merge(r.data)}_primaryKeyColumns(e){return(Array.isArray(e.columns)?e.columns:[]).filter(e=>e&&e.primary).map(e=>e.name)}_buildReplaceQuery(e,r){const t=r.conflict||this._primaryKeyColumns(e);if(!t||0===t.length)throw KormError.validationFailed({errors:[{field:"conflict",message:"replace on Postgres needs a primary key on the model or an explicit `conflict` field",rule:"required"}],source:"replace"});return this.db(e.table).insert(r.data).onConflict(t).merge(r.data)}async executeReplaceQuery(e,r){return await this._buildReplaceQuery(e,r),r.data}buildDryRun(e,r,t){const u=this._dryRunBuilders(e,r,t).map(e=>{const r=e.toSQL();return{sql:r.sql,bindings:r.bindings}});return{success:!0,dryRun:!0,action:t,model:e.name,sql:u[0]?u[0].sql:null,bindings:u[0]?u[0].bindings:[],statements:u}}_dryRunBuilders(e,r,t){const u=e.table,i=r.where||{};switch(t){case"list":return[this.queryBuilder.buildSelectQuery(e,r)];case"show":return[this.queryBuilder.buildSelectQuery(e,{...r,limit:1,offset:0})];case"count":return[this._buildCountQuery(e,r)];case"sum":return[this.queryBuilder.getSumQuery(e,r)];case"create":return[this.db(u).insert(r.data).returning("*")];case"update":{const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,i,[]),[t.update(r.data).returning("*")]}case"softDelete":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.update({deleted_at:new Date}).returning("*")]}case"delete":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.delete()]}case"replace":return[this._buildReplaceQuery(e,r)];case"upsert":return[this.db(u).insert(r.data).onConflict(r.conflict).merge(r.data)];case"sync":{const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,i,[]),[this.db(u).insert(r.data).onConflict(r.conflict).merge(r.data),t.delete()]}case"restore":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.update({deleted_at:null}).returning("*")]}default:return[]}}async executeSyncQuery(e,r){return this.db.transaction(async t=>{const u=await t(e.table).insert(r.data).onConflict(r.conflict).merge(r.data),i=this.queryBuilder.getQueryBuilder(e,t(e.table));this.queryBuilder._applyWhereClause(i,r.where||{},[]);return{insertOrUpdateQuery:u,deleteQuery:await i.delete()}})}}module.exports=QueryService;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
const QueryService=require("./QueryService"),HookService=require("./HookService"),KormError=require("../../KormError");class CurdTable{constructor(e,r,t=null){if(this.db=e,this.utils=r,this.controllerWrapper=t,this.hookService=new HookService(e,r,t),this.queryService=new QueryService(e,r,t),!this.queryService)throw new KormError("CurdTable requires queryService (execute*Query / getQuery).",{code:KormError.CODES.INTERNAL})}async processRequest(e,r=null,t={}){const
|
|
1
|
+
const QueryService=require("./QueryService"),HookService=require("./HookService"),KormError=require("../../KormError"),{mapWithConcurrency:mapWithConcurrency,resolveFanOutConcurrency:resolveFanOutConcurrency}=require("../../ConnectionResilience");class CurdTable{constructor(e,r,t=null){if(this.db=e,this.utils=r,this.controllerWrapper=t,this.hookService=new HookService(e,r,t),this.queryService=new QueryService(e,r,t),!this.queryService)throw new KormError("CurdTable requires queryService (execute*Query / getQuery).",{code:KormError.CODES.INTERNAL})}async _injectSoftDeleteWhere(e,r,t="deleted_at"){return this.hookService?.executeHasSoftDeleteHook&&await this.hookService.executeHasSoftDeleteHook(e)&&(r={...r,where:{...r.where||{},[t]:null}}),r}async processRequest(e,r=null,t={},o=0){if(o>10)throw new KormError("other_requests nesting depth exceeded maximum (10)",{code:KormError.CODES.INTERNAL,context:{depth:o}});const c=this.controllerWrapper,s=this.utils.getModel(this.controllerWrapper,r),i=e?.action||"list";let a=null;if(new Set(["update","delete","softDelete"]).has(i)){const r=e?.where;if(!r||"object"==typeof r&&0===Object.keys(r).length)throw KormError.validationFailed({errors:[{field:"where",message:`where is required for action "${i}"`}],source:i})}const u={model:s,action:i,request:e,ctx:t,controller:c};if(this.hookService?.executeValidatorHook&&await this.hookService.executeValidatorHook({...u}),e?.dryRun)return this.buildDryRunResult(s,e,i);switch(this.hookService?.executeBeforeHook&&(e.beforeActionData=await this.hookService.executeBeforeHook({...u})),i){case"count":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeCountQuery(s,e);break;case"sum":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeSumQuery(s,e);break;case"list":a=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.getSoftDeleteQuery(s,e):await this.queryService.getQuery(s,e);break;case"show":e=await this._injectSoftDeleteWhere(s,e),a=await this.queryService.executeShowQuery(s,e);break;case"create":a=await this.queryService.executeCreateQuery(s,e);break;case"update":{const r=await this.queryService.executeUpdateQuery(s,e);if(!(r&&r.length>0))throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record updated successfully",data:r,success:!0};break}case"replace":if(a=await this.queryService.executeReplaceQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record replaced successfully",data:a,success:!0};break;case"upsert":if(a=await this.queryService.executeUpsertQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record upserted successfully",data:a,success:!0};break;case"sync":if(a=await this.queryService.executeSyncQuery(s,e),!a)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record synced successfully",data:a,success:!0};break;case"delete":{let r=null;if(r=await this.hookService.executeHasSoftDeleteHook(s)?await this.queryService.executeSoftDeleteQuery(s,e):await this.queryService.executeDeleteQuery(s,e),!r)throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record deleted successfully",data:r,success:!0};break}case"restore":{if(!await(this.hookService?.executeHasSoftDeleteHook?.(s)))throw KormError.unknownAction({action:i,model:s.name});const r=await this.queryService.executeRestoreQuery(s,e);if(!(r&&r.length>0))throw KormError.noMatchingRow({action:i,model:s.name});a={message:"Record restored successfully",data:r,success:!0};break}default:if(!this.hookService?.executeCustomAction)throw KormError.unknownAction({action:i,model:s?.name});a=await this.hookService.executeCustomAction({...u})}if(this.hookService?.executeAfterHook&&(a=await this.hookService.executeAfterHook({...u,data:a})),"object"==typeof a&&null!==a&&e?.other_requests&&"object"==typeof e.other_requests){const r={},c=Object.entries(e.other_requests),s=50;let i=0;for(const[,e]of c)i+=Array.isArray(e)?e.length:1;if(i>s)throw new KormError(`other_requests fan-out exceeded maximum (${s})`,{code:KormError.CODES.INTERNAL,context:{fanOut:i}});for(const[e,s]of c)if(this.controllerWrapper?._authz?.hasRules()&&this.controllerWrapper._authz.enforce(e,s?.action||"list",s,t),Array.isArray(s)){const c=resolveFanOutConcurrency(this.db,this.controllerWrapper?.fanOutConcurrency);r[e]=await mapWithConcurrency(s,c,r=>this.processRequest(r,e,t,o+1))}else r[e]=await this.processRequest(s,e,t,o+1);a.other_responses=r}return a}async buildDryRunResult(e,r,t){if(!["list","show","count","sum","create","update","replace","upsert","sync","delete","restore"].includes(t))throw KormError.unknownAction({action:t,model:e?.name});let o=t,c=r;const s=await this.hookService.executeHasSoftDeleteHook(e);if(s&&"delete"===t)o="softDelete";else if(s&&"list"===t)c={...r,where:{...r.where||{},deleted_at:null}};else if("restore"===t&&!s)throw KormError.unknownAction({action:t,model:e?.name});return this.queryService.buildDryRun(e,c,o)}}module.exports=CurdTable;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
const HelperUtility=require("./HelperUtility"),logger=require("../../Logger"),KormError=require("../../KormError");class QueryBuilder{constructor(e,t,r=null){this.db=e,this.utils=t,this.controllerWrapper=r,this.helperUtility=new HelperUtility}getHookService(){return this.controllerWrapper.hookService}getQueryBuilder(e,t=null){let r=this.db(e.table);return t&&(r=t),r._getMyModel=()=>e,r}parseValue(e){return this.helperUtility.parseValue(e)}parseWhereValue(e){return this.helperUtility.parseWhereValue(e)}parseWhereColumn(e){return this.helperUtility.parseWhereColumn(e)}_applyOrWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.orWhereBetween(t,o);break;case"notBetween":e.orWhereNotBetween(t,o);break;case"in":e.orWhereIn(t,o);break;case"notIn":e.orWhereNotIn(t,o);break;case"like":e.orWhere(t,"like",o);break;default:Array.isArray(o)?e.orWhereIn(t,o):e.orWhere(t,r,o)}else e.orWhereNull(t)}_applyAndWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.whereBetween(t,o);break;case"notBetween":e.whereNotBetween(t,o);break;case"in":e.whereIn(t,o);break;case"notIn":e.whereNotIn(t,o);break;case"like":e.where(t,"like",o);break;default:Array.isArray(o)?e.whereIn(t,o):e.where(t,r,o)}else e.whereNull(t)}buildWithTree(e,t=null){return this.helperUtility.dotWalkTree(e,{resolver:({current:e,part:r,source:o})=>t?t({current:e,part:r,source:o}):{}})}_getWithWhereForRelation(e,t){if(!e||"object"!=typeof e)return{direct:{},nested:{}};const r=`${t}.`,o={},i={};for(const[t,s]of Object.entries(e))if(t.startsWith(r)){const e=t.slice(r.length);e.includes(".")?i[e]=s:o[e]=s}return{direct:o,nested:i}}_applyWithWhereConditions(e,t){for(const[r,o]of Object.entries(t)){const{joinType:t="AND",column:i}=this.parseWhereColumn(r),{operator:s,value:n}=this.parseWhereValue(o);"AND"===t?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}async fetchRelatedRows(e,t,r={}){if(e.through){const o=await this.db(e.through).whereIn(e.throughLocalKey,t),i=this.db(e.table).whereIn(e.foreignKey,o.map(t=>t[e.throughForeignKey]));return Object.keys(r).length>0&&this._applyWithWhereConditions(i,r),i}{const o=this.db(e.table).whereIn(e.foreignKey,t);return Object.keys(r).length>0&&this._applyWithWhereConditions(o,r),o}}async fetchAndAttachRelated(e){const{parentRows:t,relName:r,model:o,withTree:i,relation:s,withWhere:n={}}=e;if(!s){const e=this.getHookService().getModelInstance(o),s=`get${r.charAt(0).toUpperCase()+r.slice(1)}Relation`;if("function"==typeof e[s]){const{direct:l,nested:a}=this._getWithWhereForRelation(n,r),h={rows:t,relName:r,model:o,withTree:i,controller:this.controllerWrapper,relation:o.hasRelations[r],qb:this,db:this.db,withWhere:l,nestedWithWhere:a};await e[s](h)}else logger.warn(`Method ${s} not found in model ${o.name}`);return t}const l=t.map(e=>e[s.localKey]),a="one"===s?.type;let h=[];const{direct:c,nested:p}=this._getWithWhereForRelation(n,r);let u={...c};try{const e=this.utils.getModel(this.controllerWrapper,r);if(this.controllerWrapper?.hookService){await this.controllerWrapper.hookService.executeHasSoftDeleteHook(e)&&(u={...u,deleted_at:null})}}catch(e){}h=await this.fetchRelatedRows(s,l,u);const y=new Map;for(const e of h){const t=e[s.foreignKey];y.has(t)||y.set(t,[]),y.get(t).push(e)}for(const e of t){const t=e[s.localKey];a&&1==y.get(t)?.length?e[r]=y.get(t)[0]:e[r]=y.get(t)||[]}const d=Object.keys(i);for(const e of d){const o=i[e],s=t.filter(e=>a?e[r]:e[r].length>0).map(e=>e[r]).reduce((e,t)=>e.concat(t),[]),n=this.utils.getModel(this.controllerWrapper,r);await this.fetchAndAttachRelated({parentRows:s,relName:e,model:n,withTree:o,relation:n.hasRelations[e],withWhere:p})}return t}filterWhere(e,t=""){return t?Object.keys(e).filter(e=>e.includes(t)).reduce((r,o)=>(r[o.replace(t,"")]=e[o],r),{}):Object.keys(e).filter(e=>!e.includes(".")).reduce((t,r)=>(t[r]=e[r],t),{})}buildSelectQuery(e,t){const{where:r={},with:o,select:i,orderBy:s,limit:n=10,offset:l=0,page:a,groupBy:h,having:c,distinct:p,join:u,leftJoin:y,rightJoin:d,innerJoin:f}=t,g=this.getQueryBuilder(e);i&&(Array.isArray(i)||"string"==typeof i)?g.select(i):g.select("*"),p&&(Array.isArray(p)||"string"==typeof p?g.distinct(p):g.distinct()),u&&this._applyJoins(g,u,"join"),y&&this._applyJoins(g,y,"leftJoin"),d&&this._applyJoins(g,d,"rightJoin"),f&&this._applyJoins(g,f,"innerJoin"),this._applyWhereClause(g,r,o),h&&g.groupBy(h),c&&this._applyHavingClause(g,c);const W=void 0===s?this.helperUtility.resolveDefaultOrderBy(e):s;W&&this._applyOrderBy(g,W);let w=l;return a&&n>0&&(w=(Math.max(1,parseInt(a))-1)*n),n>0&&(g.limit(n),w>0&&g.offset(w)),g}async getQuery(e,t){try{const{where:r={},with:o,withWhere:i,limit:s=10,offset:n=0,page:l,join:a,leftJoin:h,rightJoin:c,innerJoin:p}=t,u=this.buildSelectQuery(e,t);let y=!1;const d=s;let f=n,g=1,W=0;l&&s>0&&(g=Math.max(1,parseInt(l)),f=(g-1)*s);const w=[],m=u.toSQL();w.push(m.sql);const b=await u;if(o&&o.length>0){const t=this.buildWithTree(o);for(const r of Object.keys(t))await this.fetchAndAttachRelated({parentRows:b,relName:r,model:e,withTree:t[r],relation:e.hasRelations[r],withWhere:i||{}})}let _=null;if(s>0)try{const t=this.getQueryBuilder(e);r&&Object.keys(r).length>0&&this._applyWhereClause(t,r),a&&this._applyJoins(t,a,"join"),h&&this._applyJoins(t,h,"leftJoin"),c&&this._applyJoins(t,c,"rightJoin"),p&&this._applyJoins(t,p,"innerJoin");const o=t.count("* as cnt");w.push(o.toSQL().sql);_=(await o.first()).cnt}catch(e){logger.warn("Failed to get total count:",e.message),_=b.length}s>0&&null!==_&&(W=Math.ceil(_/s),y=g<W);const j=y?g+1:null,k=g>1?g-1:null;return{data:b,totalCount:_,...this.controllerWrapper.debug?{sqlDebug:w}:{},...s>0?{pagination:{page:g,limit:d,offset:f,totalPages:W,hasNext:y,hasPrev:g>1,nextPage:j,prevPage:k}}:{}}}catch(t){throw logger.error("QueryService.getQuery error:",t),new Error(`Failed to execute query: ${t.message} on model ${e.name}`)}}getSumQuery(e,t){const{where:r={},join:o,leftJoin:i,rightJoin:s,innerJoin:n}=t,l=t.data||t,a=l.sumColumn,h=l.sumFormula;if(!a&&!h)throw new Error("Sum action requires either data.sumColumn or data.sumFormula");const c=e=>'"'+String(e).replace(/["\\]/g,"")+'"';let p;if(a){const e=String(a).trim().replace(/[^a-zA-Z0-9_]/g,"");if(!e)throw new Error("data.sumColumn must be a valid column name");p="SUM("+c(e)+")"}else{const e=String(h).trim();if(!/\{[a-zA-Z0-9_]+\}/.test(e))throw new Error("data.sumFormula must contain at least one {columnName}");let t=0;for(const r of e)if("("===r)t++;else if(")"===r&&(t--,t<0))break;if(0!==t)throw new Error("data.sumFormula has unbalanced or misordered parentheses ( and )");const r=e.replace(/\{[a-zA-Z0-9_]+\}/g,"@");if(!/^[\s0-9.+*\/\-()@]+$/.test(r))throw new Error("data.sumFormula may only use BODMAS: numbers, + - * /, parentheses, and {columnName}");p="SUM("+e.replace(/\{([a-zA-Z0-9_]+)\}/g,(e,t)=>c(t))+")"}const u=this.getQueryBuilder(e);return o&&this._applyJoins(u,o,"join"),i&&this._applyJoins(u,i,"leftJoin"),s&&this._applyJoins(u,s,"rightJoin"),n&&this._applyJoins(u,n,"innerJoin"),this._applyWhereClause(u,r,[]),u.select(this.db.raw(p+" as sum")),u}_applyWhereWithArray(e,t,r){for(const o of t)this._applyWhereClause(e,o,r)}_applyWhereClause(e,t,r=[]){if(Array.isArray(t))this._applyWhereWithArray(e,t,r);else{if(t&&Object.keys(t).length>0){let r=this.helperUtility.getDotWalkQuery(t);r=this.helperUtility.objectFilter(r,(e,t)=>!e.startsWith("!")&&("__exists__"!==e&&("object"!=typeof t||null===t)));for(const[t,o]of Object.entries(r)){const{joinType:r="AND",column:i}=this.parseWhereColumn(t),{operator:s,value:n}=this.parseWhereValue(o);"AND"===r?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}this._applyNestedWhere(e,t,r)}}_getNestedWhereConditions(e,t){if(!e||"object"!=typeof e)return{};const r=`${t}.`,o={};for(const[i,s]of Object.entries(e))if(i.startsWith(r)){o[i.slice(r.length)]=s}else i===t&&!0===s&&(o.__exists__=!0);return o}_getTopLevelRelationsFromWhere(e){if(!e||"object"!=typeof e)return[];const t=new Set;for(const r of Object.keys(e))if(r.includes(".")){const e=r.split(".")[0];t.add(e)}else r.startsWith("!")&&t.add(r);return[...t]}_applyNestedWhere(e,t,r){const o=this,i=e._getMyModel(),s=this._getTopLevelRelationsFromWhere(t);if(0!==s.length)for(const r of s){const s=r.startsWith("!"),n=s?r.slice(1):r,l=this._getNestedWhereConditions(t,r);if(0===Object.keys(l).length)continue;const a=i.hasRelations?.[n];if(!a){logger.warn(`Relation ${n} not found in model ${i.modelName}`);continue}let h;try{h=this.utils.getModel(this.controllerWrapper,a.table||n)}catch(e){try{h=this.utils.getModel(this.controllerWrapper,n)}catch(e){logger.warn(`Model for relation ${n} (table: ${a.table}) not found`);continue}}e[s?"whereNotExists":"whereExists"](function(){const e=o.getQueryBuilder(h,this.select("*").from(a.table));e.whereRaw("??.?? = ??.??",[a.table,a.foreignKey,i.table,a.localKey]);if(!(!0===l.__exists__&&1===Object.keys(l).length)){const t={...l};delete t.__exists__,o._applyWhereClause(e,t,[])}})}}_applyJoins(e,t,r){const o=Array.isArray(t)?t:[t];for(const t of o)"string"==typeof t?e[r](t):"object"==typeof t&&(t.table&&t.on?e[r](t.table,t.on):t.table&&t.first&&t.operator&&t.second&&e[r](t.table,t.first,t.operator,t.second))}_applyWithWhere(e,t){try{if(Array.isArray(t))for(const r of t)"string"==typeof r?e.withWhere(r):"object"==typeof r&&e.withWhere(r.column,r.operator,r.value);else if("object"==typeof t)for(const[r,o]of Object.entries(t))e.withWhere(r,o)}catch(e){logger.warn("Failed to apply withWhere:",e.message)}}_applyHavingClause(e,t){for(const[r,o]of Object.entries(t))"object"==typeof o&&o.operator?e.having(r,o.operator,o.value):e.having(r,o)}_applyOrderBy(e,t){if(Array.isArray(t))for(const r of t)this._applyOrderByEntry(e,r);else this._applyOrderByEntry(e,t)}_applyOrderByEntry(e,t){if("string"!=typeof t){if(t&&"object"==typeof t){if(!t.column)throw KormError.validationFailed({errors:[{field:"orderBy",message:`orderBy object must include a "column" key (got keys: ${Object.keys(t).join(", ")||"none"})`,value:t,rule:"required"}],source:"orderBy"});e.orderBy(t.column,t.direction||"asc")}}else e.orderBy(t)}}module.exports=QueryBuilder;
|
|
1
|
+
const HelperUtility=require("./HelperUtility"),logger=require("../../Logger"),KormError=require("../../KormError");class QueryBuilder{constructor(e,t,r=null){this.db=e,this.utils=t,this.controllerWrapper=r,this.helperUtility=new HelperUtility}getHookService(){return this.controllerWrapper.hookService}getQueryBuilder(e,t=null){let r=this.db(e.table);return t&&(r=t),r._getMyModel=()=>e,r}parseValue(e){return this.helperUtility.parseValue(e)}parseWhereValue(e){return this.helperUtility.parseWhereValue(e)}parseWhereColumn(e){return this.helperUtility.parseWhereColumn(e)}_applyOrWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.orWhereBetween(t,o);break;case"notBetween":e.orWhereNotBetween(t,o);break;case"in":e.orWhereIn(t,o);break;case"notIn":e.orWhereNotIn(t,o);break;case"like":e.orWhere(t,"like",o);break;default:Array.isArray(o)?e.orWhereIn(t,o):e.orWhere(t,r,o)}else e.orWhereNull(t)}_applyAndWhereCondition(e,t,r,o){if(null!==o)switch(r){case"between":e.whereBetween(t,o);break;case"notBetween":e.whereNotBetween(t,o);break;case"in":e.whereIn(t,o);break;case"notIn":e.whereNotIn(t,o);break;case"like":e.where(t,"like",o);break;default:Array.isArray(o)?e.whereIn(t,o):e.where(t,r,o)}else e.whereNull(t)}buildWithTree(e,t=null){return this.helperUtility.dotWalkTree(e,{resolver:({current:e,part:r,source:o})=>t?t({current:e,part:r,source:o}):{}})}_getWithWhereForRelation(e,t){if(!e||"object"!=typeof e)return{direct:{},nested:{}};const r=`${t}.`,o={},i={};for(const[t,s]of Object.entries(e))if(t.startsWith(r)){const e=t.slice(r.length);e.includes(".")?i[e]=s:o[e]=s}return{direct:o,nested:i}}_applyWithWhereConditions(e,t){for(const[r,o]of Object.entries(t)){const{joinType:t="AND",column:i}=this.parseWhereColumn(r),{operator:s,value:n}=this.parseWhereValue(o);"AND"===t?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}async fetchRelatedRows(e,t,r={}){if(e.through){const o=await this.db(e.through).whereIn(e.throughLocalKey,t),i=this.db(e.table).whereIn(e.foreignKey,o.map(t=>t[e.throughForeignKey]));return Object.keys(r).length>0&&this._applyWithWhereConditions(i,r),i}{const o=this.db(e.table).whereIn(e.foreignKey,t);return Object.keys(r).length>0&&this._applyWithWhereConditions(o,r),o}}async fetchAndAttachRelated(e){const{parentRows:t,relName:r,model:o,withTree:i,relation:s,withWhere:n={}}=e;if(!s){const e=this.getHookService().getModelInstance(o),s=`get${r.charAt(0).toUpperCase()+r.slice(1)}Relation`;if("function"==typeof e[s]){const{direct:a,nested:l}=this._getWithWhereForRelation(n,r),h={rows:t,relName:r,model:o,withTree:i,controller:this.controllerWrapper,relation:o.hasRelations[r],qb:this,db:this.db,withWhere:a,nestedWithWhere:l};await e[s](h)}else logger.warn(`Method ${s} not found in model ${o.name}`);return t}const a=t.map(e=>e[s.localKey]),l="one"===s?.type;let h=[];const{direct:c,nested:u}=this._getWithWhereForRelation(n,r);let p={...c};try{const e=this.utils.getModel(this.controllerWrapper,r);if(this.controllerWrapper?.hookService){await this.controllerWrapper.hookService.executeHasSoftDeleteHook(e)&&(p={...p,deleted_at:null})}}catch(e){}h=await this.fetchRelatedRows(s,a,p);const d=new Map;for(const e of h){const t=e[s.foreignKey];d.has(t)||d.set(t,[]),d.get(t).push(e)}for(const e of t){const t=e[s.localKey];l&&1==d.get(t)?.length?e[r]=d.get(t)[0]:e[r]=d.get(t)||[]}const y=Object.keys(i);for(const e of y){const o=i[e],s=t.filter(e=>l?e[r]:e[r].length>0).map(e=>e[r]).reduce((e,t)=>e.concat(t),[]),n=this.utils.getModel(this.controllerWrapper,r);await this.fetchAndAttachRelated({parentRows:s,relName:e,model:n,withTree:o,relation:n.hasRelations[e],withWhere:u})}return t}filterWhere(e,t=""){return t?Object.keys(e).filter(e=>e.includes(t)).reduce((r,o)=>(r[o.replace(t,"")]=e[o],r),{}):Object.keys(e).filter(e=>!e.includes(".")).reduce((t,r)=>(t[r]=e[r],t),{})}buildSelectQuery(e,t){const{where:r={},with:o,select:i,orderBy:s,limit:n=10,offset:a=0,page:l,groupBy:h,having:c,distinct:u,join:p,leftJoin:d,rightJoin:y,innerJoin:f}=t,g=this.getQueryBuilder(e);i&&(Array.isArray(i)||"string"==typeof i)?g.select(i):g.select("*"),u&&(Array.isArray(u)||"string"==typeof u?g.distinct(u):g.distinct()),p&&this._applyJoins(g,p,"join"),d&&this._applyJoins(g,d,"leftJoin"),y&&this._applyJoins(g,y,"rightJoin"),f&&this._applyJoins(g,f,"innerJoin"),this._applyWhereClause(g,r,o),h&&g.groupBy(h),c&&this._applyHavingClause(g,c);const m=void 0===s?this.helperUtility.resolveDefaultOrderBy(e):s;m&&this._applyOrderBy(g,m);let W=a;return l&&n>0&&(W=(Math.max(1,parseInt(l))-1)*n),n>0&&(g.limit(n),W>0&&g.offset(W)),g}async getQuery(e,t){try{const{where:r={},with:o,withWhere:i,limit:s=10,offset:n=0,page:a,join:l,leftJoin:h,rightJoin:c,innerJoin:u,groupBy:p,having:d,distinct:y}=t,f=this.buildSelectQuery(e,t);let g=!1;const m=s;let W=n,b=1,_=0;a&&s>0?(b=Math.max(1,parseInt(a)),W=(b-1)*s):W>0&&s>0&&(b=Math.floor(W/s)+1);const w=[],A=f.toSQL();w.push(A.sql);const k=await f;if(o&&o.length>0){const t=this.buildWithTree(o);for(const r of Object.keys(t))await this.fetchAndAttachRelated({parentRows:k,relName:r,model:e,withTree:t[r],relation:e.hasRelations[r],withWhere:i||{}})}let j=null;if(s>0)try{const t=this.getQueryBuilder(e);let o;r&&Object.keys(r).length>0&&this._applyWhereClause(t,r),l&&this._applyJoins(t,l,"join"),h&&this._applyJoins(t,h,"leftJoin"),c&&this._applyJoins(t,c,"rightJoin"),u&&this._applyJoins(t,u,"innerJoin"),y&&(Array.isArray(y)||"string"==typeof y?t.distinct(y):t.distinct()),p&&t.groupBy(p),d&&this._applyHavingClause(t,d),o=p||y||d?this.db.count("* as cnt").from(t.as("__count_sub")):t.count("* as cnt"),w.push(o.toSQL().sql);const i=await o.first();j=Number(i.cnt)||0}catch(e){logger.warn("Failed to get total count:",e.message),j=k.length}s>0&&null!==j&&(_=Math.ceil(j/s),g=b<_);const v=g?b+1:null,J=b>1?b-1:null;return{data:k,totalCount:j,...this.controllerWrapper.debug?{sqlDebug:w}:{},...s>0?{pagination:{page:b,limit:m,offset:W,totalPages:_,hasNext:g,hasPrev:b>1,nextPage:v,prevPage:J}}:{}}}catch(t){if(t instanceof KormError)throw t;throw logger.error("QueryService.getQuery error:",t),new Error(`Failed to execute query: ${t.message} on model ${e.name}`)}}getSumQuery(e,t){const{where:r={},join:o,leftJoin:i,rightJoin:s,innerJoin:n}=t,a=t.data||t,l=a.sumColumn,h=a.sumFormula;if(!l&&!h)throw KormError.validationFailed({errors:[{field:"data",message:"Sum action requires either data.sumColumn or data.sumFormula"}],source:"sum"});const c=e=>'"'+String(e).replace(/["\\]/g,"")+'"';let u;if(l){const e=String(l).trim().replace(/[^a-zA-Z0-9_]/g,"");if(!e)throw KormError.validationFailed({errors:[{field:"data.sumColumn",message:"data.sumColumn must be a valid column name"}],source:"sum"});u="SUM("+c(e)+")"}else{const e=String(h).trim();if(!/\{[a-zA-Z0-9_]+\}/.test(e))throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula must contain at least one {columnName}"}],source:"sum"});let t=0;for(const r of e)if("("===r)t++;else if(")"===r&&(t--,t<0))break;if(0!==t)throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula has unbalanced or misordered parentheses ( and )"}],source:"sum"});const r=e.replace(/\{[a-zA-Z0-9_]+\}/g,"@");if(!/^[\s0-9.+*\/\-()@]+$/.test(r))throw KormError.validationFailed({errors:[{field:"data.sumFormula",message:"data.sumFormula may only use BODMAS: numbers, + - * /, parentheses, and {columnName}"}],source:"sum"});u="SUM("+e.replace(/\{([a-zA-Z0-9_]+)\}/g,(e,t)=>c(t))+")"}const p=this.getQueryBuilder(e);return o&&this._applyJoins(p,o,"join"),i&&this._applyJoins(p,i,"leftJoin"),s&&this._applyJoins(p,s,"rightJoin"),n&&this._applyJoins(p,n,"innerJoin"),this._applyWhereClause(p,r,[]),p.select(this.db.raw(u+" as sum")),p}_applyWhereWithArray(e,t,r){for(const o of t)this._applyWhereClause(e,o,r)}_applyWhereClause(e,t,r=[]){if(Array.isArray(t))this._applyWhereWithArray(e,t,r);else{if(t&&Object.keys(t).length>0){let r=this.helperUtility.getDotWalkQuery(t);r=this.helperUtility.objectFilter(r,(e,t)=>!e.startsWith("!")&&("__exists__"!==e&&("object"!=typeof t||null===t||Array.isArray(t))));for(const[t,o]of Object.entries(r)){const{joinType:r="AND",column:i}=this.parseWhereColumn(t),{operator:s,value:n}=this.parseWhereValue(o);"AND"===r?this._applyAndWhereCondition(e,i,s,n):this._applyOrWhereCondition(e,i,s,n)}}this._applyNestedWhere(e,t,r)}}_getNestedWhereConditions(e,t){if(!e||"object"!=typeof e)return{};const r=`${t}.`,o={};for(const[i,s]of Object.entries(e))if(i.startsWith(r)){o[i.slice(r.length)]=s}else i===t&&!0===s&&(o.__exists__=!0);return o}_getTopLevelRelationsFromWhere(e){if(!e||"object"!=typeof e)return[];const t=new Set;for(const r of Object.keys(e))if(r.includes(".")){const e=r.split(".")[0];t.add(e)}else r.startsWith("!")&&t.add(r);return[...t]}_applyNestedWhere(e,t,r){const o=this,i=e._getMyModel(),s=this._getTopLevelRelationsFromWhere(t);if(0!==s.length)for(const r of s){const s=r.startsWith("!"),n=s?r.slice(1):r,a=this._getNestedWhereConditions(t,r);if(0===Object.keys(a).length)continue;const l=i.hasRelations?.[n];if(!l){logger.warn(`Relation ${n} not found in model ${i.modelName}`);continue}let h;try{h=this.utils.getModel(this.controllerWrapper,l.table||n)}catch(e){try{h=this.utils.getModel(this.controllerWrapper,n)}catch(e){logger.warn(`Model for relation ${n} (table: ${l.table}) not found`);continue}}e[s?"whereNotExists":"whereExists"](function(){const e=o.getQueryBuilder(h,this.select("*").from(l.table));e.whereRaw("??.?? = ??.??",[l.table,l.foreignKey,i.table,l.localKey]);if(!(!0===a.__exists__&&1===Object.keys(a).length)){const t={...a};delete t.__exists__,o._applyWhereClause(e,t,[])}})}}_applyJoins(e,t,r){const o=Array.isArray(t)?t:[t];for(const t of o)"string"==typeof t?e[r](t):"object"==typeof t&&(t.table&&t.on?e[r](t.table,t.on):t.table&&t.first&&t.operator&&t.second&&e[r](t.table,t.first,t.operator,t.second))}_applyHavingClause(e,t){for(const[r,o]of Object.entries(t))"object"==typeof o&&o.operator?e.having(r,o.operator,o.value):e.having(r,"=",o)}_applyOrderBy(e,t){if(Array.isArray(t))for(const r of t)this._applyOrderByEntry(e,r);else this._applyOrderByEntry(e,t)}_applyOrderByEntry(e,t){if("string"!=typeof t){if(t&&"object"==typeof t){if(!t.column)throw KormError.validationFailed({errors:[{field:"orderBy",message:`orderBy object must include a "column" key (got keys: ${Object.keys(t).join(", ")||"none"})`,value:t,rule:"required"}],source:"orderBy"});e.orderBy(t.column,t.direction||"asc")}}else e.orderBy(t)}}module.exports=QueryBuilder;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
const QueryBuilder=require("./QueryBuilder");class QueryService{constructor(e,t
|
|
1
|
+
const QueryBuilder=require("./QueryBuilder");class QueryService{constructor(e,r,t=null){this.db=e,this.utils=r,this.controllerWrapper=t,this.queryBuilder=new QueryBuilder(e,r,t)}async getQuery(e,r){return await this.queryBuilder.getQuery(e,r)}async getSoftDeleteQuery(e,r){return await this.queryBuilder.getQuery(e,{...r,where:{...r.where||{},deleted_at:null}})}async executeShowQuery(e,r){const t=await this.getQuery(e,{...r,limit:1,offset:0});return t.data.length>0?t.data[0]:null}_buildCountQuery(e,r={}){const t=this.queryBuilder.getQueryBuilder(e),{join:u,leftJoin:i,rightJoin:s,innerJoin:n,where:a={}}=r;return u&&this.queryBuilder._applyJoins(t,u,"join"),i&&this.queryBuilder._applyJoins(t,i,"leftJoin"),s&&this.queryBuilder._applyJoins(t,s,"rightJoin"),n&&this.queryBuilder._applyJoins(t,n,"innerJoin"),this.queryBuilder._applyWhereClause(t,a,[]),t.count()}async executeCountQuery(e,r){const t=await this._buildCountQuery(e,r||{}),u=t&&t[0]?Object.values(t[0])[0]:0,i=Number(u);return Number.isNaN(i)?0:i}async executeSumQuery(e,r){const t=await this.queryBuilder.getSumQuery(e,r).first(),u=t&&null!=t.sum?t.sum:0;return Number(u)}async executeCreateQuery(e,r){return await this.db(e.table).insert(r.data).returning("*")}async executeUpdateQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update(r.data).returning("*")}async executeDeleteQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.delete()}async executeSoftDeleteQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update({deleted_at:new Date}).returning("*")}async executeRestoreQuery(e,r){const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,r.where||{},[]),await t.update({deleted_at:null}).returning("*")}async executeUpsertQuery(e,r){return await this.db(e.table).insert(r.data).onConflict(r.conflict).merge(r.data)}_buildReplaceQuery(e,r){const{sql:t,bindings:u}=this.db(e.table).insert(r.data).toSQL();return this.db.raw(t.replace(/^\s*insert/i,"INSERT OR REPLACE"),u)}async executeReplaceQuery(e,r){return await this._buildReplaceQuery(e,r),r.data}buildDryRun(e,r,t){const u=this._dryRunBuilders(e,r,t).map(e=>{const r=e.toSQL();return{sql:r.sql,bindings:r.bindings}});return{success:!0,dryRun:!0,action:t,model:e.name,sql:u[0]?u[0].sql:null,bindings:u[0]?u[0].bindings:[],statements:u}}_dryRunBuilders(e,r,t){const u=e.table,i=r.where||{};switch(t){case"list":return[this.queryBuilder.buildSelectQuery(e,r)];case"show":return[this.queryBuilder.buildSelectQuery(e,{...r,limit:1,offset:0})];case"count":return[this._buildCountQuery(e,r)];case"sum":return[this.queryBuilder.getSumQuery(e,r)];case"create":return[this.db(u).insert(r.data).returning("*")];case"update":{const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,i,[]),[t.update(r.data).returning("*")]}case"softDelete":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.update({deleted_at:new Date}).returning("*")]}case"delete":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.delete()]}case"replace":return[this._buildReplaceQuery(e,r)];case"upsert":return[this.db(u).insert(r.data).onConflict(r.conflict).merge(r.data)];case"sync":{const t=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(t,i,[]),[this.db(u).insert(r.data).onConflict(r.conflict).merge(r.data),t.delete()]}case"restore":{const r=this.queryBuilder.getQueryBuilder(e);return this.queryBuilder._applyWhereClause(r,i,[]),[r.update({deleted_at:null}).returning("*")]}default:return[]}}async executeSyncQuery(e,r){return this.db.transaction(async t=>{const u=await t(e.table).insert(r.data).onConflict(r.conflict).merge(r.data),i=this.queryBuilder.getQueryBuilder(e,t(e.table));this.queryBuilder._applyWhereClause(i,r.where||{},[]);return{insertOrUpdateQuery:u,deleteQuery:await i.delete()}})}}module.exports=QueryService;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
const BaseSyncTable=require("../BaseSyncTable"),logger=require("../../Logger");class SQLiteSyncTable extends BaseSyncTable{_getClientName(){return"sqlite"}async _listTables(){return await this.db("sqlite_master").where({type:"table"}).whereNot("name","like","sqlite_%").pluck("name")}_supportsUnsigned(){return!1}async _applyExtras(e){for(const[t,
|
|
1
|
+
const BaseSyncTable=require("../BaseSyncTable"),logger=require("../../Logger");class SQLiteSyncTable extends BaseSyncTable{_getClientName(){return"sqlite"}async _listTables(){return await this.db("sqlite_master").where({type:"table"}).whereNot("name","like","sqlite_%").pluck("name")}_supportsUnsigned(){return!1}async _applyExtras(e){for(const[t,a]of Object.entries(e.columns)){("string"==typeof a?this.utils.formatColumnSchema(t,a):a).onUpdate&&this._warnOnUnsupportedModifier("onUpdate",e.table,t)}}async _getRelations(e){const t=await this.db.raw("PRAGMA foreign_key_list(??)",[e]),a={};for(const e of t)a[e.from]={one:{table:e.table,column:e.to}};return a}async getTablesOfDatabase(){return this._listTables()}async executeSql(e,t=[]){return logger.debug("executeSql (legacy)",{sql:e,params:t}),this.db.raw(e,t)}}module.exports=SQLiteSyncTable;
|
package/index.d.ts
CHANGED
|
@@ -4,6 +4,16 @@ export interface InitializeOptions {
|
|
|
4
4
|
schema?: any;
|
|
5
5
|
resolverPath?: string;
|
|
6
6
|
debug?: boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Retry policy for transient connection errors. Defaults to
|
|
9
|
+
* `{ attempts: 2, backoffMs: 100, writes: false }`. Pass `false` to disable.
|
|
10
|
+
*/
|
|
11
|
+
retry?: RetryOptions | false;
|
|
12
|
+
/**
|
|
13
|
+
* Max sibling `other_requests` executed concurrently. Defaults to
|
|
14
|
+
* `min(5, pool.max - 1)` so a fan-out cannot exhaust the pool.
|
|
15
|
+
*/
|
|
16
|
+
fanOutConcurrency?: number;
|
|
7
17
|
}
|
|
8
18
|
|
|
9
19
|
/**
|
|
@@ -284,8 +294,41 @@ export interface KormInstance {
|
|
|
284
294
|
|
|
285
295
|
loadModelClass?(name: string): any;
|
|
286
296
|
getModelInstance?(name: string): any;
|
|
297
|
+
|
|
298
|
+
// ---- Connection lifecycle ---------------------------------------------
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Liveness probe against the pool (`SELECT 1`). Never throws — resolves
|
|
302
|
+
* `{ ok: false, error }` on failure. Use it for a `/healthz` endpoint or an
|
|
303
|
+
* idle keepalive heartbeat.
|
|
304
|
+
*/
|
|
305
|
+
ping(): Promise<{ ok: boolean; error?: string }>;
|
|
306
|
+
/**
|
|
307
|
+
* Close the underlying Knex pool. Idempotent and non-throwing. Call from a
|
|
308
|
+
* SIGTERM/SIGINT handler so the process drains its connections instead of
|
|
309
|
+
* leaving sockets for the database server to time out.
|
|
310
|
+
*/
|
|
311
|
+
destroy(): Promise<{ ok: boolean; error?: string }>;
|
|
312
|
+
/** True once `destroy()` has run; further `processRequest` calls throw. */
|
|
313
|
+
readonly isDestroyed: boolean;
|
|
287
314
|
}
|
|
288
315
|
|
|
316
|
+
/** Retry policy for transient connection errors (ECONNRESET, connection lost). */
|
|
317
|
+
export interface RetryOptions {
|
|
318
|
+
/** Total attempts including the first. Default 2. */
|
|
319
|
+
attempts?: number;
|
|
320
|
+
/** Linear backoff base in ms (attempt N waits N x backoffMs). Default 100. */
|
|
321
|
+
backoffMs?: number;
|
|
322
|
+
/**
|
|
323
|
+
* Replay write actions too. Off by default: a `create` that landed before
|
|
324
|
+
* the socket died would be duplicated by a blind replay.
|
|
325
|
+
*/
|
|
326
|
+
writes?: boolean;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Knex pool settings recommended for a long-running server process. */
|
|
330
|
+
export function recommendedPoolConfig(engine?: string): { pool: Record<string, any> };
|
|
331
|
+
|
|
289
332
|
/** Authorization context passed as the 3rd arg to processRequest. */
|
|
290
333
|
export type AuthContext = Record<string, any>;
|
|
291
334
|
|
package/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const ControllerWrapper=require("./ControllerWrapper"),BaseHelperUtility=require("./BaseHelperUtility"),RequestValidator=require("./RequestValidator"),Emitter=require("./Emitter"),logger=require("./Logger"),KormError=require("./KormError"),{createServer:createMcpServer}=require("./src/mcp/server"),{generateTools:generateMcpTools}=require("./src/mcp/toolGenerator");module.exports={LibClasses:{Emitter:Emitter,KormError:KormError},KormError:KormError,initializeKORM(){return ControllerWrapper.initializeKORM(...arguments)},helperUtility:new BaseHelperUtility,emitter:new Emitter,logger:logger,validate:RequestValidator.validate,lib:{createValidationMiddleware:RequestValidator.createValidationMiddleware,validateEmail:RequestValidator.validateEmail,validatePassword:RequestValidator.validatePassword,validatePhone:RequestValidator.validatePhone,validatePAN:RequestValidator.validatePAN,validateAadhaar:RequestValidator.validateAadhaar},mcp:{createServer:createMcpServer,generateTools:generateMcpTools}};
|
|
1
|
+
const ControllerWrapper=require("./ControllerWrapper"),BaseHelperUtility=require("./BaseHelperUtility"),RequestValidator=require("./RequestValidator"),Emitter=require("./Emitter"),logger=require("./Logger"),KormError=require("./KormError"),{createServer:createMcpServer}=require("./src/mcp/server"),{generateTools:generateMcpTools}=require("./src/mcp/toolGenerator"),{recommendedPoolConfig:recommendedPoolConfig}=require("./ConnectionResilience");module.exports={LibClasses:{Emitter:Emitter,KormError:KormError},KormError:KormError,initializeKORM(){return ControllerWrapper.initializeKORM(...arguments)},recommendedPoolConfig:recommendedPoolConfig,helperUtility:new BaseHelperUtility,emitter:new Emitter,logger:logger,validate:RequestValidator.validate,lib:{createValidationMiddleware:RequestValidator.createValidationMiddleware,validateEmail:RequestValidator.validateEmail,validatePassword:RequestValidator.validatePassword,validatePhone:RequestValidator.validatePhone,validatePAN:RequestValidator.validatePAN,validateAadhaar:RequestValidator.validateAadhaar},mcp:{createServer:createMcpServer,generateTools:generateMcpTools}};
|
package/jest.config.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
module.exports={testEnvironment:"node",testMatch:["**/test/**/*.test.js","**/test/**/*.spec.js","**/__tests__/**/*.js"],testPathIgnorePatterns:["/node_modules/"],collectCoverage:!1,coverageDirectory:"coverage",coverageReporters:["text","lcov","html"],collectCoverageFrom:["clients/sqlite/**/*.js","clients/Base*.js","ControllerWrapper.js","AuthorizationService.js","RequestValidator.js","BaseHelperUtility.js","Logger.js","Emitter.js","index.js","cli.js","helpers/**/*.js","src/mcp/**/*.js","bin/korm-mcp.js","!**/node_modules/**","!**/test/**","!**/coverage/**","!**/dist/**"],coverageThreshold:{global:{statements:90,branches:85,functions:90,lines:90},"clients/sqlite/CurdTable.js":{statements:
|
|
1
|
+
module.exports={testEnvironment:"node",testMatch:["**/test/**/*.test.js","**/test/**/*.spec.js","**/__tests__/**/*.js"],testPathIgnorePatterns:["/node_modules/"],collectCoverage:!1,coverageDirectory:"coverage",coverageReporters:["text","lcov","html"],collectCoverageFrom:["clients/sqlite/**/*.js","clients/Base*.js","ControllerWrapper.js","ConnectionResilience.js","AuthorizationService.js","RequestValidator.js","BaseHelperUtility.js","Logger.js","Emitter.js","index.js","cli.js","helpers/**/*.js","src/mcp/**/*.js","bin/korm-mcp.js","!**/node_modules/**","!**/test/**","!**/coverage/**","!**/dist/**"],coverageThreshold:{global:{statements:90,branches:85,functions:90,lines:90},"clients/sqlite/CurdTable.js":{statements:99,branches:95},"RequestValidator.js":{statements:99,branches:88},"clients/sqlite/QueryBuilder.js":{statements:95,branches:87}},testTimeout:1e4,moduleNameMapper:{"^@modelcontextprotocol/sdk/(.*)$":"<rootDir>/node_modules/@modelcontextprotocol/sdk/dist/cjs/$1"},clearMocks:!0,verbose:!0};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreamtree-org/korm-js",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Knowledge Object-Relational Mapping - A powerful, modular ORM system for Node.js with dynamic database operations, complex queries, relationships, and nested requests",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Partha Preetham Krishna",
|
package/requestSchema.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";const{parseColumnDef:parseColumnDef,isWritableOnCreate:isWritableOnCreate,isRequiredOnCreate:isRequiredOnCreate,applyNullability:applyNullability}=require("./columnSchema"),READ_ACTIONS=new Set(["list","show","count","sum"]),WRITE_ACTIONS=new Set(["create","update","delete","replace","upsert","sync"]),KNOWN_ACTIONS=new Set([...READ_ACTIONS,...WRITE_ACTIONS]);function buildColumnsMap(e){const t={},r=e&&e.columns||{};for(const[e,i]of Object.entries(r))t[e]=parseColumnDef(i);return t}function buildDataSchemaForCreate(e){const t={},r=[];for(const[i,o]of Object.entries(e))isWritableOnCreate(o)&&(t[i]=applyNullability(o.jsonSchema,o),isRequiredOnCreate(o)&&r.push(i));const i={type:"object",properties:t,additionalProperties:!1};return r.length&&(i.required=r),i}function buildDataSchemaForUpdate(e){const t={};for(const[r,i]of Object.entries(e))isWritableOnCreate(i)&&(t[r]=applyNullability(i.jsonSchema,i));return{type:"object",properties:t,additionalProperties:!1}}function buildWhereSchema(e){const t={};for(const r of Object.keys(e))t[r]={};return{type:"object",properties:t,additionalProperties:!0}}function buildSelectSchema(e){const t=Object.keys(e);return{oneOf:[{type:"string"},{type:"array",items:t.length?{type:"string",enum:t}:{type:"string"}}]}}function buildOrderBySchema(e){const t=Object.keys(e),r={type:"object",properties:{column:t.length?{type:"string",enum:t}:{type:"string"},order:{type:"string",enum:["asc","desc","ASC","DESC"]}},required:["column"],additionalProperties:!1};return{oneOf:[{type:"string"},{type:"array",items:{type:"string"}},r,{type:"array",items:r}]}}function buildWithSchema(e){const t=Object.keys(e||{}),r={type:"string"};return t.length&&(r.description=`Top-level relations available: ${t.join(", ")}. Use dot-paths for deeper traversal, e.g. "${t[0]}.NestedRel".`),{type:"array",items:r}}function buildSumDataSchema(e){const t=Object.keys(e);return{type:"object",properties:{sumColumn:t.length?{type:"string",enum:t}:{type:"string"},sumFormula:{type:"string",description:"Arithmetic expression over column placeholders. Allowed chars: digits, . + - * / ( ), {column} placeholders, whitespace. See docs/agents/06-request-contract.md §5."}},additionalProperties:!1}}function buildConflictSchema(e){const t=Object.keys(e);return{type:"array",items:t.length?{type:"string",enum:t}:{type:"string"},minItems:1}}function commonReadSelectors(e,t){return{where:buildWhereSchema(e),select:buildSelectSchema(e),orderBy:buildOrderBySchema(e),limit:{type:"integer",minimum:1},offset:{type:"integer",minimum:0},page:{type:"integer",minimum:1},with:buildWithSchema(t),withWhere:buildWhereSchema(e),groupBy:{oneOf:[{type:"string"},{type:"array",items:{type:"string"}}]},having:buildWhereSchema(e),distinct:{oneOf:[{type:"boolean"},{type:"string"},{type:"array",items:{type:"string"}}]}}}const ACTION_BUILDERS={list:(e,t)=>({type:"object",properties:commonReadSelectors(e,t),additionalProperties:!1}),show:(e,t)=>({type:"object",properties:{where:buildWhereSchema(e),select:buildSelectSchema(e),with:buildWithSchema(t),withWhere:buildWhereSchema(e)},required:["where"],additionalProperties:!1}),count:e=>({type:"object",properties:{where:buildWhereSchema(e),distinct:{oneOf:[{type:"boolean"},{type:"string"},{type:"array",items:{type:"string"}}]}},additionalProperties:!1}),sum:e=>({type:"object",properties:{data:buildSumDataSchema(e),where:buildWhereSchema(e),groupBy:{oneOf:[{type:"string"},{type:"array",items:{type:"string"}}]}},required:["data"],additionalProperties:!1}),create(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]}},required:["data"],additionalProperties:!1}},update:e=>({type:"object",properties:{where:buildWhereSchema(e),data:buildDataSchemaForUpdate(e)},required:["where","data"],additionalProperties:!1}),delete:e=>({type:"object",properties:{where:buildWhereSchema(e)},required:["where"],additionalProperties:!1}),replace(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]}},required:["data"],additionalProperties:!1}},upsert(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]},conflict:buildConflictSchema(e)},required:["data","conflict"],additionalProperties:!1}},sync(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]},where:buildWhereSchema(e),conflict:buildConflictSchema(e)},required:["data","where","conflict"],additionalProperties:!1}}};function buildRequestSchema({action:e,model:t}){if(!t||"object"!=typeof t)throw new TypeError("buildRequestSchema: `model` is required");if(!KNOWN_ACTIONS.has(e))throw new RangeError(`buildRequestSchema: unknown action "${e}". Known: ${[...KNOWN_ACTIONS].join(", ")}`);const r=buildColumnsMap(t),i=t.hasRelations||{};return ACTION_BUILDERS[e](r,i)}function modelTitle(e){return e.modelName||e.alias||e.table||"Model"}function buildModelRequestSchema(e,t={}){if(!e||"object"!=typeof e)throw new TypeError("buildModelRequestSchema: `model` is required");const r=modelTitle(e),i=[...KNOWN_ACTIONS].map(t=>{const r=buildRequestSchema({action:t,model:e});return{type:"object",title:t,properties:{action:{type:"string",const:t,description:`The "${t}" operation.`},...r.properties,dryRun:{type:"boolean",description:"If true, return the SQL that would run without executing it."}},required:["action",...r.required||[]],additionalProperties:!1}});return{$schema:"https://json-schema.org/draft/2020-12/schema",title:t.title||`KormRequest<${r}>`,description:`Valid processRequest(body, "${r}") shapes. Exactly one action branch applies.`,oneOf:i}}module.exports={buildRequestSchema:buildRequestSchema,buildModelRequestSchema:buildModelRequestSchema,buildColumnsMap:buildColumnsMap,READ_ACTIONS:READ_ACTIONS,WRITE_ACTIONS:WRITE_ACTIONS,KNOWN_ACTIONS:KNOWN_ACTIONS};
|
|
1
|
+
"use strict";const{parseColumnDef:parseColumnDef,isWritableOnCreate:isWritableOnCreate,isRequiredOnCreate:isRequiredOnCreate,applyNullability:applyNullability}=require("./columnSchema"),READ_ACTIONS=new Set(["list","show","count","sum"]),WRITE_ACTIONS=new Set(["create","update","delete","replace","upsert","sync","restore"]),KNOWN_ACTIONS=new Set([...READ_ACTIONS,...WRITE_ACTIONS]);function buildColumnsMap(e){const t={},r=e&&e.columns||{};for(const[e,i]of Object.entries(r))t[e]=parseColumnDef(i);return t}function buildDataSchemaForCreate(e){const t={},r=[];for(const[i,o]of Object.entries(e))isWritableOnCreate(o)&&(t[i]=applyNullability(o.jsonSchema,o),isRequiredOnCreate(o)&&r.push(i));const i={type:"object",properties:t,additionalProperties:!1};return r.length&&(i.required=r),i}function buildDataSchemaForUpdate(e){const t={};for(const[r,i]of Object.entries(e))isWritableOnCreate(i)&&(t[r]=applyNullability(i.jsonSchema,i));return{type:"object",properties:t,additionalProperties:!1}}function buildWhereSchema(e){const t={};for(const r of Object.keys(e))t[r]={};return{type:"object",properties:t,additionalProperties:!0}}function buildSelectSchema(e){const t=Object.keys(e);return{oneOf:[{type:"string"},{type:"array",items:t.length?{type:"string",enum:t}:{type:"string"}}]}}function buildOrderBySchema(e){const t=Object.keys(e),r={type:"object",properties:{column:t.length?{type:"string",enum:t}:{type:"string"},order:{type:"string",enum:["asc","desc","ASC","DESC"]}},required:["column"],additionalProperties:!1};return{oneOf:[{type:"string"},{type:"array",items:{type:"string"}},r,{type:"array",items:r}]}}function buildWithSchema(e){const t=Object.keys(e||{}),r={type:"string"};return t.length&&(r.description=`Top-level relations available: ${t.join(", ")}. Use dot-paths for deeper traversal, e.g. "${t[0]}.NestedRel".`),{type:"array",items:r}}function buildSumDataSchema(e){const t=Object.keys(e);return{type:"object",properties:{sumColumn:t.length?{type:"string",enum:t}:{type:"string"},sumFormula:{type:"string",description:"Arithmetic expression over column placeholders. Allowed chars: digits, . + - * / ( ), {column} placeholders, whitespace. See docs/agents/06-request-contract.md §5."}},additionalProperties:!1}}function buildConflictSchema(e){const t=Object.keys(e);return{type:"array",items:t.length?{type:"string",enum:t}:{type:"string"},minItems:1}}function commonReadSelectors(e,t){return{where:buildWhereSchema(e),select:buildSelectSchema(e),orderBy:buildOrderBySchema(e),limit:{type:"integer",minimum:1},offset:{type:"integer",minimum:0},page:{type:"integer",minimum:1},with:buildWithSchema(t),withWhere:buildWhereSchema(e),groupBy:{oneOf:[{type:"string"},{type:"array",items:{type:"string"}}]},having:buildWhereSchema(e),distinct:{oneOf:[{type:"boolean"},{type:"string"},{type:"array",items:{type:"string"}}]}}}const ACTION_BUILDERS={list:(e,t)=>({type:"object",properties:commonReadSelectors(e,t),additionalProperties:!1}),show:(e,t)=>({type:"object",properties:{where:buildWhereSchema(e),select:buildSelectSchema(e),with:buildWithSchema(t),withWhere:buildWhereSchema(e)},required:["where"],additionalProperties:!1}),count:e=>({type:"object",properties:{where:buildWhereSchema(e),distinct:{oneOf:[{type:"boolean"},{type:"string"},{type:"array",items:{type:"string"}}]}},additionalProperties:!1}),sum:e=>({type:"object",properties:{data:buildSumDataSchema(e),where:buildWhereSchema(e),groupBy:{oneOf:[{type:"string"},{type:"array",items:{type:"string"}}]}},required:["data"],additionalProperties:!1}),create(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]}},required:["data"],additionalProperties:!1}},update:e=>({type:"object",properties:{where:buildWhereSchema(e),data:buildDataSchemaForUpdate(e)},required:["where","data"],additionalProperties:!1}),delete:e=>({type:"object",properties:{where:buildWhereSchema(e)},required:["where"],additionalProperties:!1}),replace(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]}},required:["data"],additionalProperties:!1}},upsert(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]},conflict:buildConflictSchema(e)},required:["data","conflict"],additionalProperties:!1}},sync(e){const t=buildDataSchemaForCreate(e);return{type:"object",properties:{data:{oneOf:[t,{type:"array",items:t,minItems:1}]},where:buildWhereSchema(e),conflict:buildConflictSchema(e)},required:["data","where","conflict"],additionalProperties:!1}},restore:e=>({type:"object",properties:{where:buildWhereSchema(e)},required:["where"],additionalProperties:!1})};function buildRequestSchema({action:e,model:t}){if(!t||"object"!=typeof t)throw new TypeError("buildRequestSchema: `model` is required");if(!KNOWN_ACTIONS.has(e))throw new RangeError(`buildRequestSchema: unknown action "${e}". Known: ${[...KNOWN_ACTIONS].join(", ")}`);const r=buildColumnsMap(t),i=t.hasRelations||{};return ACTION_BUILDERS[e](r,i)}function modelTitle(e){return e.modelName||e.alias||e.table||"Model"}function buildModelRequestSchema(e,t={}){if(!e||"object"!=typeof e)throw new TypeError("buildModelRequestSchema: `model` is required");const r=modelTitle(e),i=[...KNOWN_ACTIONS].map(t=>{const r=buildRequestSchema({action:t,model:e});return{type:"object",title:t,properties:{action:{type:"string",const:t,description:`The "${t}" operation.`},...r.properties,dryRun:{type:"boolean",description:"If true, return the SQL that would run without executing it."}},required:["action",...r.required||[]],additionalProperties:!1}});return{$schema:"https://json-schema.org/draft/2020-12/schema",title:t.title||`KormRequest<${r}>`,description:`Valid processRequest(body, "${r}") shapes. Exactly one action branch applies.`,oneOf:i}}module.exports={buildRequestSchema:buildRequestSchema,buildModelRequestSchema:buildModelRequestSchema,buildColumnsMap:buildColumnsMap,READ_ACTIONS:READ_ACTIONS,WRITE_ACTIONS:WRITE_ACTIONS,KNOWN_ACTIONS:KNOWN_ACTIONS};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";const{toolErrorResult:toolErrorResult,toolSuccessResult:toolSuccessResult}=require("./errors"),{resolveTables:resolveTables}=require("./toolGenerator");function describeColumns(e){const o={};for(const[t,r]of Object.entries(e.columns||{}))o[t]=r;return o}function buildListTablesTool({schema:e,visibleTables:o}){return{name:"korm.list_tables",description:"List the tables (models) currently exposed by this MCP server, with their column counts.",inputSchema:{type:"object",properties:{},additionalProperties:!1},handler:async()=>{try{const t=o().map(o=>{const t=e[o];return{modelName:o,table:t.table,columnCount:Object.keys(t.columns||{}).length,relationCount:Object.keys(t.hasRelations||{}).length}});return toolSuccessResult({tables:t})}catch(e){return toolErrorResult(e)}}}}function buildDescribeSchemaTool({schema:e,visibleTables:o}){return{name:"korm.describe_schema",description:"Describe the columns and relations of a single allowlisted table.",inputSchema:{type:"object",properties:{table:{type:"string",description:'Model name (matches the key in the KORM schema, e.g. "User" — not the SQL table name).'}},required:["table"],additionalProperties:!1},handler:async t=>{try{const r=t&&t.table,
|
|
1
|
+
"use strict";const{toolErrorResult:toolErrorResult,toolSuccessResult:toolSuccessResult}=require("./errors"),{resolveTables:resolveTables}=require("./toolGenerator"),{pingConnection:pingConnection}=require("../../ConnectionResilience");function describeColumns(e){const o={};for(const[t,r]of Object.entries(e.columns||{}))o[t]=r;return o}function buildListTablesTool({schema:e,visibleTables:o}){return{name:"korm.list_tables",description:"List the tables (models) currently exposed by this MCP server, with their column counts.",inputSchema:{type:"object",properties:{},additionalProperties:!1},handler:async()=>{try{const t=o().map(o=>{const t=e[o];return{modelName:o,table:t.table,columnCount:Object.keys(t.columns||{}).length,relationCount:Object.keys(t.hasRelations||{}).length}});return toolSuccessResult({tables:t})}catch(e){return toolErrorResult(e)}}}}function buildDescribeSchemaTool({schema:e,visibleTables:o}){return{name:"korm.describe_schema",description:"Describe the columns and relations of a single allowlisted table.",inputSchema:{type:"object",properties:{table:{type:"string",description:'Model name (matches the key in the KORM schema, e.g. "User" — not the SQL table name).'}},required:["table"],additionalProperties:!1},handler:async t=>{try{const r=t&&t.table,n=o();if(!n.includes(r))return toolErrorResult(new Error(`Table "${r}" is not exposed by this MCP server. Available: ${n.join(", ")||"(none)"}.`));const s=e[r];return toolSuccessResult({modelName:r,table:s.table,columns:describeColumns(s),relations:s.hasRelations||{}})}catch(e){return toolErrorResult(e)}}}}async function pingDb(e){return await pingConnection(e&&e.db)}function buildHealthTool({controller:e,mcpConfig:o,packageInfo:t,visibleTables:r}){return{name:"korm.health",description:"Report MCP server health: engine, library version, allowlist size, DB ping.",inputSchema:{type:"object",properties:{},additionalProperties:!1},handler:async()=>{try{const n=e&&(e.dbClient||e.engine)||"unknown",s=r(),l=await pingDb(e);return toolSuccessResult({status:l.ok?"ok":"degraded",engine:n,version:t.version||"unknown",mode:o.mode,allowedTableCount:s.length,dbPing:l.ok,...l.error?{dbError:l.error}:{}})}catch(e){return toolErrorResult(e)}}}}function buildMetaTools({controller:e,schema:o,mcpConfig:t,packageInfo:r={}}){if(!1===t.metaTools)return[];const n=()=>resolveTables(o,t);return[buildListTablesTool({schema:o,visibleTables:n}),buildDescribeSchemaTool({schema:o,visibleTables:n}),buildHealthTool({controller:e,mcpConfig:t,packageInfo:r,visibleTables:n})]}module.exports={buildMetaTools:buildMetaTools};
|