@latitude-data/sql-compiler
Advanced tools
Comparing version 0.2.0 to 1.0.0-next.0
# @latitude-data/sql-compiler | ||
## 1.0.0-next.0 | ||
### Major Changes | ||
- 9e2dd26: Compiler now can read query's metadata properties without having to resolve every variable, parameter and function in it. | ||
- 26aa69d: Supported methods now must define its requirements, a resolve function to calculate the returned value, and a readMetadata function to returns its metadata. Now the compiler checks and handles these requirements. | ||
### Minor Changes | ||
- 3e87858: Improved handling of object properties in query logic blocks. Now you can: | ||
- Invoke methods from objects. | ||
- Modify object properties. | ||
- Access properties using optional chaining (the `?.` operator). | ||
### Patch Changes | ||
- a8d4658: BREAKING CHANGE: Now `ref` function inside queries requires relative paths instead of the path from the source folder | ||
## 0.2.0 | ||
@@ -4,0 +22,0 @@ |
@@ -1,30 +0,30 @@ | ||
type CompilerAttrs = { | ||
query: string; | ||
resolveFn: ResolveFn; | ||
configFn: ConfigFn; | ||
supportedMethods?: Record<string, SupportedMethod>; | ||
}; | ||
export type SupportedMethod = <T extends boolean>(interpolation: T, ...args: unknown[]) => Promise<T extends true ? string : unknown>; | ||
export type ResolveFn = (value: unknown) => Promise<string>; | ||
export type ConfigFn = (key: string, value: unknown) => void; | ||
import type { CompileContext, QueryMetadata } from './types'; | ||
export declare class Compiler { | ||
private sql; | ||
private supportedMethods; | ||
private resolveFn; | ||
private configFn; | ||
private varStash; | ||
private readFromStash; | ||
private addToStash; | ||
private modifyStash; | ||
constructor({ query, resolveFn, configFn, supportedMethods, }: CompilerAttrs); | ||
compile(): Promise<string>; | ||
private parseBaseNode; | ||
private parseLogicNode; | ||
private resolveLogicNodeExpression; | ||
private parseBaseNodeChildren; | ||
private context; | ||
private currentConfig; | ||
constructor({ sql, supportedMethods, resolveFn }: CompileContext); | ||
/** | ||
* Resolves every block, expression, and function inside the SQL and returns the final query. | ||
* | ||
* Note: Compiling a query may take time in some cases, as some queries may contain expensive | ||
* functions that need to be resolved at runtime. | ||
*/ | ||
compileSQL(): Promise<string>; | ||
/** | ||
* Without compiling the SQL or resolving any expression, quickly reads the config and calls | ||
* to the supported methods present in the SQL. | ||
*/ | ||
readMetadata(): Promise<QueryMetadata>; | ||
/** | ||
* Given a base node, returns the string that will replace it in the final SQL | ||
*/ | ||
private resolveBaseNode; | ||
private resolveBaseNodeChildren; | ||
/** | ||
* Given a base node, returns the list of defined configs and present methods from the supportedMethods. | ||
*/ | ||
private getBaseNodeMetadata; | ||
private baseNodeError; | ||
private expressionError; | ||
private handleFunction; | ||
} | ||
export {}; | ||
//# sourceMappingURL=index.d.ts.map |
export default class Scope { | ||
private readFromStash; | ||
private addToStash; | ||
private modifyStash; | ||
/** | ||
* Global stash | ||
* All variable values are stored in a single global array. This is done to allow multiple | ||
* scopes to share the same variable values and be able to modify them. | ||
* | ||
* For example: | ||
* ```sql | ||
* {var1 = 1} | ||
* {#if <condition>} | ||
* {var1 = 2} | ||
* {var2 = 3} | ||
* {/if} | ||
* ``` | ||
* In this case, there are two scopes: root and if. Both scopes share the same variable `var1`, | ||
* and modifying it in the if scope should also modify it in the root scope. But `var2` is only | ||
* defined in the if scope and should not be accessible in the root scope. | ||
*/ | ||
private static stash; | ||
private static readFromStash; | ||
private static addToStash; | ||
private static modifyStash; | ||
/** | ||
* Local scope | ||
* Every scope has its own local stash that contains the indexes of the variables and constants | ||
* in the global stash. | ||
*/ | ||
private consts; | ||
private vars; | ||
constructor(readFromStash: (index: number) => unknown, addToStash: (value: unknown) => number, modifyStash: (index: number, value: unknown) => void); | ||
constructor(); | ||
exists(name: string): boolean; | ||
@@ -9,0 +32,0 @@ isConst(name: string): boolean; |
@@ -78,2 +78,10 @@ declare const _default: { | ||
}; | ||
invalidConfigValue: { | ||
code: string; | ||
message: string; | ||
}; | ||
configInsideBlock: { | ||
code: string; | ||
message: string; | ||
}; | ||
configDefinitionFailed: (name: string, message: string) => { | ||
@@ -83,2 +91,6 @@ code: string; | ||
}; | ||
configAlreadyDefined: (name: string) => { | ||
code: string; | ||
message: string; | ||
}; | ||
variableAlreadyDeclared: (name: string) => { | ||
@@ -104,2 +116,14 @@ code: string; | ||
}; | ||
invalidAssignment: { | ||
code: string; | ||
message: string; | ||
}; | ||
invalidUpdate: (operation: string, type: string) => { | ||
code: string; | ||
message: string; | ||
}; | ||
propertyNotExists: (property: string) => { | ||
code: string; | ||
message: string; | ||
}; | ||
unknownFunction: (name: string) => { | ||
@@ -109,6 +133,22 @@ code: string; | ||
}; | ||
functionCallError: (name: string, message: string) => { | ||
notAFunction: (objectType: string) => { | ||
code: string; | ||
message: string; | ||
}; | ||
functionCallError: (err: unknown) => { | ||
code: string; | ||
message: string; | ||
}; | ||
functionRequiresStaticArguments: (name: string) => { | ||
code: string; | ||
message: string; | ||
}; | ||
functionRequiresInterpolation: (name: string) => { | ||
code: string; | ||
message: string; | ||
}; | ||
functionDisallowsInterpolation: (name: string) => { | ||
code: string; | ||
message: string; | ||
}; | ||
invalidFunctionResultInterpolation: { | ||
@@ -115,0 +155,0 @@ code: string; |
@@ -1,11 +0,15 @@ | ||
import { ConfigFn, ResolveFn, type SupportedMethod } from './compiler'; | ||
import type { QueryMetadata, ResolveFn, SupportedMethod } from './compiler/types'; | ||
export type CompileParams = { | ||
query: string; | ||
resolveFn: ResolveFn; | ||
configFn: ConfigFn; | ||
supportedMethods?: Record<string, SupportedMethod>; | ||
}; | ||
export default function compile({ query, supportedMethods, resolveFn, configFn, }: CompileParams): Promise<string>; | ||
export declare function compile({ query, supportedMethods, resolveFn, }: CompileParams): Promise<string>; | ||
export declare function readMetadata({ query, supportedMethods, }: { | ||
query: string; | ||
supportedMethods?: Record<string, SupportedMethod>; | ||
}): Promise<QueryMetadata>; | ||
export { default as CompileError } from './error/error'; | ||
export { type SupportedMethod } from './compiler'; | ||
export * from './compiler/types'; | ||
export * from './compiler/utils'; | ||
//# sourceMappingURL=index.d.ts.map |
1172
dist/index.js
import { locate } from 'locate-character'; | ||
import { isIdentifierStart, isIdentifierChar } from 'acorn'; | ||
import * as code_red from 'code-red'; | ||
import { createHash } from 'node:crypto'; | ||
@@ -53,2 +54,6 @@ class CompileError extends Error { | ||
function getKlassName(error) { | ||
const errorKlass = error; | ||
return errorKlass.constructor ? errorKlass.constructor.name : 'Error'; | ||
} | ||
var errors = { | ||
@@ -132,2 +137,10 @@ unexpectedEof: { | ||
}, | ||
invalidConfigValue: { | ||
code: 'invalid-config-value', | ||
message: 'Config values must be literals. Cannot use variables or expressions', | ||
}, | ||
configInsideBlock: { | ||
code: 'config-inside-block', | ||
message: 'Cannot must be defined at root level. Cannot be inside a block', | ||
}, | ||
configDefinitionFailed: (name, message) => ({ | ||
@@ -137,2 +150,6 @@ code: 'config-definition-failed', | ||
}), | ||
configAlreadyDefined: (name) => ({ | ||
code: 'config-already-defined', | ||
message: `Config definition for '${name}' failed: Option already configured`, | ||
}), | ||
variableAlreadyDeclared: (name) => ({ | ||
@@ -158,2 +175,14 @@ code: 'variable-already-declared', | ||
}, | ||
invalidAssignment: { | ||
code: 'invalid-assignment', | ||
message: 'Invalid assignment', | ||
}, | ||
invalidUpdate: (operation, type) => ({ | ||
code: 'invalid-update', | ||
message: `Cannot use ${operation} operation on ${type}`, | ||
}), | ||
propertyNotExists: (property) => ({ | ||
code: 'property-not-exists', | ||
message: `Property '${property}' does not exist on object`, | ||
}), | ||
unknownFunction: (name) => ({ | ||
@@ -163,6 +192,26 @@ code: 'unknown-function', | ||
}), | ||
functionCallError: (name, message) => ({ | ||
code: 'function-call-error', | ||
message: `Error calling function '${name}': ${message}`, | ||
notAFunction: (objectType) => ({ | ||
code: 'not-a-function', | ||
message: `Object '${objectType}' is callable`, | ||
}), | ||
functionCallError: (err) => { | ||
const error = err; | ||
const errorKlassName = getKlassName(error); | ||
return { | ||
code: 'function-call-error', | ||
message: `Error calling function: \n${errorKlassName} ${error.message}`, | ||
}; | ||
}, | ||
functionRequiresStaticArguments: (name) => ({ | ||
code: 'function-requires-static-arguments', | ||
message: `Function '${name}' can only receive literal values as arguments`, | ||
}), | ||
functionRequiresInterpolation: (name) => ({ | ||
code: 'function-requires-interpolation', | ||
message: `Function '${name}' cannot be used inside a logic block. It must be directly interpolated into the query`, | ||
}), | ||
functionDisallowsInterpolation: (name) => ({ | ||
code: 'function-disallows-interpolation', | ||
message: `Function '${name}' cannot be directly interpolated into the query`, | ||
}), | ||
invalidFunctionResultInterpolation: { | ||
@@ -819,2 +868,148 @@ code: 'invalid-function-result-interpolation', | ||
class Scope { | ||
/** | ||
* Global stash | ||
* All variable values are stored in a single global array. This is done to allow multiple | ||
* scopes to share the same variable values and be able to modify them. | ||
* | ||
* For example: | ||
* ```sql | ||
* {var1 = 1} | ||
* {#if <condition>} | ||
* {var1 = 2} | ||
* {var2 = 3} | ||
* {/if} | ||
* ``` | ||
* In this case, there are two scopes: root and if. Both scopes share the same variable `var1`, | ||
* and modifying it in the if scope should also modify it in the root scope. But `var2` is only | ||
* defined in the if scope and should not be accessible in the root scope. | ||
*/ | ||
static stash = []; // Stash of every variable value in every scope | ||
static readFromStash(index) { | ||
return Scope.stash[index]; | ||
} | ||
static addToStash(value) { | ||
Scope.stash.push(value); | ||
return Scope.stash.length - 1; | ||
} | ||
static modifyStash(index, value) { | ||
Scope.stash[index] = value; | ||
} | ||
/** | ||
* Local scope | ||
* Every scope has its own local stash that contains the indexes of the variables and constants | ||
* in the global stash. | ||
*/ | ||
consts = {}; // Index of every constant in the stash in the current scope | ||
vars = {}; // Index of every variable in the stash in the current scope | ||
constructor() { } | ||
exists(name) { | ||
return name in this.consts || name in this.vars; | ||
} | ||
isConst(name) { | ||
return name in this.consts; | ||
} | ||
get(name) { | ||
const index = this.consts[name] ?? this.vars[name] ?? undefined; | ||
if (index === undefined) | ||
throw new Error(`Variable '${name}' does not exist`); | ||
return Scope.readFromStash(index); | ||
} | ||
defineConst(name, value) { | ||
if (this.exists(name)) | ||
throw new Error(`Variable '${name}' already exists`); | ||
this.consts[name] = Scope.addToStash(value); | ||
} | ||
set(name, value) { | ||
if (this.isConst(name)) | ||
throw new Error(`Constant '${name}' cannot be modified`); | ||
if (!this.exists(name)) { | ||
this.vars[name] = Scope.addToStash(value); | ||
return; | ||
} | ||
const index = this.vars[name]; | ||
Scope.modifyStash(index, value); | ||
} | ||
copy() { | ||
const scope = new Scope(); | ||
scope.consts = { ...this.consts }; | ||
scope.vars = { ...this.vars }; | ||
return scope; | ||
} | ||
} | ||
var NodeType; | ||
(function (NodeType) { | ||
NodeType["Literal"] = "Literal"; | ||
NodeType["Identifier"] = "Identifier"; | ||
NodeType["ObjectExpression"] = "ObjectExpression"; | ||
NodeType["ArrayExpression"] = "ArrayExpression"; | ||
NodeType["SequenceExpression"] = "SequenceExpression"; | ||
NodeType["LogicalExpression"] = "LogicalExpression"; | ||
NodeType["BinaryExpression"] = "BinaryExpression"; | ||
NodeType["UnaryExpression"] = "UnaryExpression"; | ||
NodeType["AssignmentExpression"] = "AssignmentExpression"; | ||
NodeType["UpdateExpression"] = "UpdateExpression"; | ||
NodeType["MemberExpression"] = "MemberExpression"; | ||
NodeType["ConditionalExpression"] = "ConditionalExpression"; | ||
NodeType["CallExpression"] = "CallExpression"; | ||
NodeType["ChainExpression"] = "ChainExpression"; | ||
})(NodeType || (NodeType = {})); | ||
function mergeMetadata(...metadata) { | ||
const config = metadata.reduce((acc, m) => ({ ...acc, ...m.config }), {}); | ||
const methods = metadata.reduce((acc, m) => new Set([...acc, ...m.methods]), new Set()); | ||
const hashes = metadata.map((m) => m.sqlHash).filter(Boolean); | ||
let sqlHash = undefined; | ||
if (hashes.length === 1) { | ||
sqlHash = hashes[0]; | ||
} | ||
else if (hashes.length > 1) { | ||
const hash = createHash('sha256'); | ||
for (const h of hashes) | ||
hash.update(h); | ||
sqlHash = hash.digest('hex'); | ||
} | ||
const rawSqls = metadata.map((m) => m.rawSql).filter(Boolean); | ||
if (rawSqls.length > 1) { | ||
throw new Error('Cannot merge metadata with multiple rawSqls'); | ||
} | ||
const rawSql = rawSqls[0]; | ||
return { | ||
config, | ||
methods, | ||
rawSql, | ||
sqlHash, | ||
}; | ||
} | ||
function emptyMetadata() { | ||
return { | ||
config: {}, | ||
methods: new Set(), | ||
rawSql: undefined, | ||
sqlHash: undefined, | ||
}; | ||
} | ||
/** | ||
* ### ArrayExpression | ||
* Returns an array of values | ||
*/ | ||
async function resolve$c({ node, ...props }) { | ||
return await Promise.all(node.elements.map((element) => element | ||
? resolveLogicNode({ | ||
node: element, | ||
...props, | ||
}) | ||
: null)); | ||
} | ||
async function readMetadata$b({ node, ...props }) { | ||
const childrenMetadata = await Promise.all(node.elements.map(async (element) => { | ||
if (element) | ||
return await getLogicNodeMetadata({ node: element, ...props }); | ||
return emptyMetadata(); | ||
})); | ||
return mergeMetadata(...childrenMetadata); | ||
} | ||
// https://github.com/estree/estree/blob/master/es5.md#binary-operations | ||
@@ -860,91 +1055,527 @@ const BINARY_OPERATOR_METHODS = { | ||
// https://github.com/estree/estree/blob/master/es5.md#memberexpression | ||
const MEMBER_EXPRESSION_METHOD = (object, property) => object[property]; | ||
const MEMBER_EXPRESSION_METHOD = (object, property) => { | ||
const value = object[property]; | ||
return typeof value === 'function' ? value.bind(object) : value; | ||
}; | ||
// https://github.com/estree/estree/blob/master/es5.md#assignmentexpression | ||
const ASSIGNMENT_OPERATOR_METHODS = { | ||
'+=': (left, right) => (left += right), | ||
'-=': (left, right) => (left -= right), | ||
'*=': (left, right) => (left *= right), | ||
'/=': (left, right) => (left /= right), | ||
'%=': (left, right) => (left %= right), | ||
'<<=': (left, right) => (left <<= right), | ||
'>>=': (left, right) => (left >>= right), | ||
'>>>=': (left, right) => (left >>>= right), | ||
'|=': (left, right) => (left |= right), | ||
'^=': (left, right) => (left ^= right), | ||
'&=': (left, right) => (left &= right), | ||
'=': (_, right) => right, | ||
'+=': (left, right) => left + right, | ||
'-=': (left, right) => left - right, | ||
'*=': (left, right) => left * right, | ||
'/=': (left, right) => left / right, | ||
'%=': (left, right) => left % right, | ||
'<<=': (left, right) => left << right, | ||
'>>=': (left, right) => left >> right, | ||
'>>>=': (left, right) => left >>> right, | ||
'|=': (left, right) => left | right, | ||
'^=': (left, right) => left ^ right, | ||
'&=': (left, right) => left & right, | ||
}; | ||
class Scope { | ||
readFromStash; | ||
addToStash; | ||
modifyStash; | ||
consts = {}; | ||
vars = {}; | ||
constructor(readFromStash, addToStash, modifyStash) { | ||
this.readFromStash = readFromStash; | ||
this.addToStash = addToStash; | ||
this.modifyStash = modifyStash; | ||
/** | ||
* ### AssignmentExpression | ||
* Represents an assignment or update to a variable or property. Returns the newly assigned value. | ||
* The assignment can be made to an existing variable or property, or to a new one. Assignments to constants are not allowed. | ||
* | ||
* Examples: `foo = 1` `obj.foo = 'bar'` `foo += 1` | ||
*/ | ||
async function resolve$b({ node, scope, raiseError, ...props }) { | ||
const assignmentOperator = node.operator; | ||
if (!(assignmentOperator in ASSIGNMENT_OPERATOR_METHODS)) { | ||
raiseError(errors.unsupportedOperator(assignmentOperator), node); | ||
} | ||
exists(name) { | ||
return name in this.consts || name in this.vars; | ||
const assignmentMethod = ASSIGNMENT_OPERATOR_METHODS[assignmentOperator]; | ||
const assignmentValue = await resolveLogicNode({ | ||
node: node.right, | ||
scope, | ||
raiseError, | ||
...props, | ||
}); | ||
if (node.left.type === 'Identifier') { | ||
return await assignToVariable({ | ||
assignmentOperator, | ||
assignmentMethod, | ||
assignmentValue, | ||
node: node.left, | ||
scope, | ||
raiseError, | ||
...props, | ||
}); | ||
} | ||
isConst(name) { | ||
return name in this.consts; | ||
if (node.left.type === 'MemberExpression') { | ||
return await assignToProperty({ | ||
assignmentOperator, | ||
assignmentMethod, | ||
assignmentValue, | ||
node: node.left, | ||
scope, | ||
raiseError, | ||
...props, | ||
}); | ||
} | ||
get(name) { | ||
const index = this.consts[name] ?? this.vars[name] ?? undefined; | ||
if (index === undefined) | ||
throw new Error(`Variable ${name} does not exist`); | ||
return this.readFromStash(index); | ||
raiseError(errors.invalidAssignment, node); | ||
} | ||
async function assignToVariable({ assignmentOperator, assignmentMethod, assignmentValue, node, scope, raiseError, }) { | ||
const assignedVariableName = node.name; | ||
if (scope.isConst(assignedVariableName)) { | ||
raiseError(errors.constantReassignment, node); | ||
} | ||
defineConst(name, value) { | ||
if (this.exists(name)) | ||
throw new Error(`Variable ${name} already exists`); | ||
this.consts[name] = this.addToStash(value); | ||
if (assignmentOperator != '=' && !scope.exists(assignedVariableName)) { | ||
raiseError(errors.variableNotDeclared(assignedVariableName), node); | ||
} | ||
set(name, value) { | ||
if (this.isConst(name)) | ||
throw new Error(`Variable ${name} is a constant`); | ||
if (!this.exists(name)) { | ||
this.vars[name] = this.addToStash(value); | ||
return; | ||
const updatedValue = assignmentMethod(scope.exists(assignedVariableName) | ||
? scope.get(assignedVariableName) | ||
: undefined, assignmentValue); | ||
scope.set(assignedVariableName, updatedValue); | ||
return updatedValue; | ||
} | ||
async function assignToProperty({ assignmentOperator, assignmentMethod, assignmentValue, node, ...props }) { | ||
const { raiseError } = props; | ||
const object = (await resolveLogicNode({ | ||
node: node.object, | ||
...props, | ||
})); | ||
const property = (node.computed | ||
? await resolveLogicNode({ | ||
node: node.property, | ||
...props, | ||
}) | ||
: node.property.name); | ||
if (assignmentOperator != '=' && !(property in object)) { | ||
raiseError(errors.propertyNotExists(property), node); | ||
} | ||
const originalValue = object[property]; | ||
const updatedValue = assignmentMethod(originalValue, assignmentValue); | ||
object[property] = updatedValue; | ||
return updatedValue; | ||
} | ||
async function readMetadata$a({ node, ...props }) { | ||
return mergeMetadata(await getLogicNodeMetadata({ node: node.right, ...props }), await getLogicNodeMetadata({ node: node.left, ...props })); | ||
} | ||
/** | ||
* ### Chain Expression | ||
* Represents a chain of operations. This is only being used for optional member expressions '?.' | ||
*/ | ||
async function resolve$a({ node, ...props }) { | ||
return resolveLogicNode({ | ||
node: node.expression, | ||
...props, | ||
}); | ||
} | ||
async function readMetadata$9({ node, ...props }) { | ||
return await getLogicNodeMetadata({ | ||
node: node.expression, | ||
...props, | ||
}); | ||
} | ||
/** | ||
* ### CallExpression | ||
* Represents a method call. | ||
* | ||
* Examples: `foo()` `foo.bar()` | ||
*/ | ||
async function resolve$9(props) { | ||
const { node, supportedMethods, raiseError } = props; | ||
if (node.callee.type === NodeType.Identifier && | ||
node.callee.name in supportedMethods) { | ||
return resolveSupportedMethod(props); | ||
} | ||
const method = (await resolveLogicNode({ | ||
...props, | ||
node: node.callee, | ||
})); | ||
if (typeof method !== 'function') { | ||
raiseError(errors.notAFunction(typeof method), node); | ||
} | ||
const args = await resolveArgs(props); | ||
return await runMethod({ ...props, method, args }); | ||
} | ||
async function resolveSupportedMethod(props) { | ||
const { node, supportedMethods, raiseError, willInterpolate, resolveFn } = props; | ||
const methodName = node.callee.name; | ||
const { requirements: reqs, resolve: method } = supportedMethods[methodName]; | ||
const requirements = { | ||
interpolationPolicy: 'allow', | ||
interpolationMethod: 'parameterize', | ||
requireStaticArguments: false, | ||
...(reqs ?? {}), | ||
}; | ||
if (requirements.requireStaticArguments && !onlyContainsStaticArgs(node)) { | ||
raiseError(errors.functionRequiresStaticArguments(methodName), node); | ||
} | ||
if (requirements.interpolationPolicy === 'require' && !willInterpolate) { | ||
raiseError(errors.functionRequiresInterpolation(methodName), node); | ||
} | ||
if (requirements.interpolationPolicy === 'disallow' && willInterpolate) { | ||
raiseError(errors.functionDisallowsInterpolation(methodName), node); | ||
} | ||
const args = await resolveArgs(props); | ||
const result = await runMethod({ ...props, method, args }); | ||
if (!willInterpolate) | ||
return result; | ||
if (requirements?.interpolationMethod === 'raw') { | ||
return String(result); | ||
} | ||
return resolveFn(result); | ||
} | ||
function resolveArgs(props) { | ||
const { node } = props; | ||
return Promise.all(node.arguments.map((arg) => resolveLogicNode({ | ||
...props, | ||
node: arg, | ||
willInterpolate: false, | ||
}))); | ||
} | ||
function onlyContainsStaticArgs(node) { | ||
return node.arguments.every((arg) => arg.type === NodeType.Literal); | ||
} | ||
async function runMethod({ method, args, node, willInterpolate, raiseError, }) { | ||
try { | ||
const result = await method(...args); | ||
if (willInterpolate) { | ||
return String(result); | ||
} | ||
const index = this.vars[name]; | ||
this.modifyStash(index, value); | ||
return result; | ||
} | ||
copy() { | ||
const scope = new Scope(this.readFromStash, this.addToStash, this.modifyStash); | ||
scope.consts = { ...this.consts }; | ||
scope.vars = { ...this.vars }; | ||
return scope; | ||
catch (error) { | ||
if (error instanceof CompileError) | ||
throw error; | ||
raiseError(errors.functionCallError(error), node); | ||
} | ||
} | ||
async function readMetadata$8(props) { | ||
const { node, supportedMethods } = props; | ||
const argumentsMetadata = await Promise.all(node.arguments.map((arg) => getLogicNodeMetadata({ | ||
...props, | ||
node: arg, | ||
}))); | ||
const calleeMetadata = await getLogicNodeMetadata({ | ||
...props, | ||
node: node.callee, | ||
}); | ||
let resultsMetadata = emptyMetadata(); | ||
if (node.callee.type === NodeType.Identifier) { | ||
const methodName = node.callee.name; | ||
if (methodName in supportedMethods) { | ||
calleeMetadata.methods.add(methodName); | ||
const args = onlyContainsStaticArgs(node) | ||
? node.arguments.map((arg) => arg.value) | ||
: []; | ||
resultsMetadata = await supportedMethods[methodName].readMetadata(args); | ||
} | ||
} | ||
return mergeMetadata(calleeMetadata, resultsMetadata, ...argumentsMetadata); | ||
} | ||
class Compiler { | ||
sql; | ||
supportedMethods; | ||
resolveFn; | ||
configFn; | ||
varStash; | ||
readFromStash(index) { | ||
return this.varStash[index]; | ||
/** | ||
* ### BinaryExpression | ||
* Represents a simple operation between two operands. | ||
* | ||
* Example: `{a > b}` | ||
*/ | ||
async function resolve$8({ node, raiseError, ...props }) { | ||
const binaryOperator = node.operator; | ||
if (!(binaryOperator in BINARY_OPERATOR_METHODS)) { | ||
raiseError(errors.unsupportedOperator(binaryOperator), node); | ||
} | ||
addToStash(value) { | ||
this.varStash.push(value); | ||
return this.varStash.length - 1; | ||
const leftOperand = await resolveLogicNode({ | ||
node: node.left, | ||
raiseError, | ||
...props, | ||
}); | ||
const rightOperand = await resolveLogicNode({ | ||
node: node.right, | ||
raiseError, | ||
...props, | ||
}); | ||
return BINARY_OPERATOR_METHODS[binaryOperator]?.(leftOperand, rightOperand); | ||
} | ||
async function readMetadata$7({ node, ...props }) { | ||
return mergeMetadata(await getLogicNodeMetadata({ | ||
node: node.left, | ||
...props, | ||
}), await getLogicNodeMetadata({ | ||
node: node.right, | ||
...props, | ||
})); | ||
} | ||
/** | ||
* ### ConditionalExpression | ||
* Represents a ternary operation. | ||
* | ||
* Example: `a ? b : c` | ||
*/ | ||
async function resolve$7({ node, ...props }) { | ||
const condition = await resolveLogicNode({ node: node.test, ...props }); | ||
return await resolveLogicNode({ | ||
node: condition ? node.consequent : node.alternate, | ||
...props, | ||
}); | ||
} | ||
async function readMetadata$6({ node, ...props }) { | ||
return mergeMetadata(await getLogicNodeMetadata({ | ||
node: node.test, | ||
...props, | ||
}), await getLogicNodeMetadata({ | ||
node: node.consequent, | ||
...props, | ||
}), await getLogicNodeMetadata({ | ||
node: node.alternate, | ||
...props, | ||
})); | ||
} | ||
/** | ||
* ### Identifier | ||
* Represents a variable from the scope. | ||
*/ | ||
async function resolve$6({ node, scope, raiseError, }) { | ||
if (!scope.exists(node.name)) { | ||
raiseError(errors.variableNotDeclared(node.name), node); | ||
} | ||
modifyStash(index, value) { | ||
this.varStash[index] = value; | ||
return scope.get(node.name); | ||
} | ||
/** | ||
* ### Literal | ||
* Represents a literal value. | ||
*/ | ||
async function resolve$5({ node }) { | ||
return node.value; | ||
} | ||
/** | ||
* ### ObjectExpression | ||
* Represents a javascript Object | ||
*/ | ||
async function resolve$4({ node, scope, raiseError, ...props }) { | ||
const resolvedObject = {}; | ||
for (const prop of node.properties) { | ||
if (prop.type !== 'Property') { | ||
throw raiseError(errors.invalidObjectKey, node); | ||
} | ||
const key = prop.key; | ||
const value = await resolveLogicNode({ | ||
node: prop.value, | ||
scope, | ||
raiseError, | ||
...props, | ||
}); | ||
resolvedObject[key.name] = value; | ||
} | ||
constructor({ query, resolveFn, configFn, supportedMethods = {}, }) { | ||
this.sql = query; | ||
this.resolveFn = resolveFn; | ||
this.configFn = configFn; | ||
this.supportedMethods = supportedMethods; | ||
this.varStash = []; | ||
return resolvedObject; | ||
} | ||
function isProperty(prop) { | ||
return prop.type === 'Property'; | ||
} | ||
async function readMetadata$5({ node, ...props }) { | ||
const propertiesMetadata = await Promise.all(node.properties.filter(isProperty).map((prop) => Promise.all([ | ||
getLogicNodeMetadata({ | ||
node: prop.key, | ||
...props, | ||
}), | ||
getLogicNodeMetadata({ | ||
node: prop.value, | ||
...props, | ||
}), | ||
]))); | ||
return mergeMetadata(...propertiesMetadata.flat()); | ||
} | ||
/** | ||
* ### MemberExpression | ||
* Represents a property from an object. If the property does not exist in the object, it will return undefined. | ||
*/ | ||
async function resolve$3({ node, ...props }) { | ||
const object = await resolveLogicNode({ | ||
node: node.object, | ||
...props, | ||
}); | ||
// Accessing to the property can be optional (?.) | ||
if (object == null && node.optional) | ||
return undefined; | ||
const property = node.computed | ||
? await resolveLogicNode({ | ||
node: node.property, | ||
...props, | ||
}) | ||
: node.property.name; | ||
return MEMBER_EXPRESSION_METHOD(object, property); | ||
} | ||
async function readMetadata$4({ node, ...props }) { | ||
return mergeMetadata(await getLogicNodeMetadata({ | ||
node: node.object, | ||
...props, | ||
}), node.computed | ||
? await getLogicNodeMetadata({ node: node.property, ...props }) | ||
: emptyMetadata()); | ||
} | ||
/** | ||
* ### SequenceExpression | ||
* Represents a sequence of expressions. It is only used to evaluate ?. operators. | ||
*/ | ||
async function resolve$2({ node, ...props }) { | ||
return await Promise.all(node.expressions.map((expression) => resolveLogicNode({ node: expression, ...props }))); | ||
} | ||
async function readMetadata$3({ node, ...props }) { | ||
const childrenMetadata = await Promise.all(node.expressions.map(async (expression) => getLogicNodeMetadata({ node: expression, ...props }))); | ||
return mergeMetadata(...childrenMetadata); | ||
} | ||
/** | ||
* ### UnaryExpression | ||
* Represents a simple operation on a single operand, either as a prefix or suffix. | ||
* | ||
* Example: `{!a}` | ||
*/ | ||
async function resolve$1({ node, raiseError, ...props }) { | ||
const unaryOperator = node.operator; | ||
if (!(unaryOperator in UNARY_OPERATOR_METHODS)) { | ||
raiseError(errors.unsupportedOperator(unaryOperator), node); | ||
} | ||
async compile() { | ||
const fragment = parse(this.sql); | ||
const localScope = new Scope(this.readFromStash.bind(this), this.addToStash.bind(this), this.modifyStash.bind(this)); | ||
const compiledSql = (await this.parseBaseNode(fragment, localScope)) | ||
const unaryArgument = await resolveLogicNode({ | ||
node: node.argument, | ||
raiseError, | ||
...props, | ||
}); | ||
const unaryPrefix = node.prefix; | ||
return UNARY_OPERATOR_METHODS[unaryOperator]?.(unaryArgument, unaryPrefix); | ||
} | ||
async function readMetadata$2({ node, ...props }) { | ||
return await getLogicNodeMetadata({ | ||
node: node.argument, | ||
...props, | ||
}); | ||
} | ||
/** | ||
* ### UpdateExpression | ||
* Represents a javascript update expression. | ||
* Depending on the operator, it can increment or decrement a value. | ||
* Depending on the position of the operator, the return value can be resolved before or after the operation. | ||
* | ||
* Examples: `{--foo}` `{bar++}` | ||
*/ | ||
async function resolve({ node, scope, raiseError, ...props }) { | ||
const updateOperator = node.operator; | ||
if (!['++', '--'].includes(updateOperator)) { | ||
raiseError(errors.unsupportedOperator(updateOperator), node); | ||
} | ||
const assignmentOperators = { | ||
'++': '+=', | ||
'--': '-=', | ||
}; | ||
const originalValue = await resolveLogicNode({ | ||
node: node.argument, | ||
scope, | ||
raiseError, | ||
...props, | ||
}); | ||
if (typeof originalValue !== 'number') { | ||
raiseError(errors.invalidUpdate(updateOperator, typeof originalValue), node); | ||
} | ||
// Simulate an AssignmentExpression with the same operation | ||
const assignmentNode = { | ||
...node, | ||
type: 'AssignmentExpression', | ||
left: node.argument, | ||
operator: assignmentOperators[updateOperator], | ||
right: { | ||
type: 'Literal', | ||
value: 1, | ||
}, | ||
}; | ||
// Perform the assignment | ||
const updatedValue = await resolveLogicNode({ | ||
node: assignmentNode, | ||
scope, | ||
raiseError, | ||
...props, | ||
}); | ||
return node.prefix ? updatedValue : originalValue; | ||
} | ||
async function readMetadata$1({ node, ...props }) { | ||
return await getLogicNodeMetadata({ | ||
node: node.argument, | ||
...props, | ||
}); | ||
} | ||
const nodeResolvers = { | ||
[NodeType.ArrayExpression]: resolve$c, | ||
[NodeType.AssignmentExpression]: resolve$b, | ||
[NodeType.BinaryExpression]: resolve$8, | ||
[NodeType.CallExpression]: resolve$9, | ||
[NodeType.ChainExpression]: resolve$a, | ||
[NodeType.ConditionalExpression]: resolve$7, | ||
[NodeType.Identifier]: resolve$6, | ||
[NodeType.Literal]: resolve$5, | ||
[NodeType.LogicalExpression]: resolve$8, | ||
[NodeType.ObjectExpression]: resolve$4, | ||
[NodeType.MemberExpression]: resolve$3, | ||
[NodeType.SequenceExpression]: resolve$2, | ||
[NodeType.UnaryExpression]: resolve$1, | ||
[NodeType.UpdateExpression]: resolve, | ||
}; | ||
const nodeMetadataReader = { | ||
[NodeType.Identifier]: async () => emptyMetadata(), // No metadata to read | ||
[NodeType.Literal]: async () => emptyMetadata(), // No metadata to read | ||
[NodeType.ArrayExpression]: readMetadata$b, | ||
[NodeType.AssignmentExpression]: readMetadata$a, | ||
[NodeType.BinaryExpression]: readMetadata$7, | ||
[NodeType.CallExpression]: readMetadata$8, | ||
[NodeType.ChainExpression]: readMetadata$9, | ||
[NodeType.ConditionalExpression]: readMetadata$6, | ||
[NodeType.LogicalExpression]: readMetadata$7, | ||
[NodeType.ObjectExpression]: readMetadata$5, | ||
[NodeType.MemberExpression]: readMetadata$4, | ||
[NodeType.SequenceExpression]: readMetadata$3, | ||
[NodeType.UnaryExpression]: readMetadata$2, | ||
[NodeType.UpdateExpression]: readMetadata$1, | ||
}; | ||
/** | ||
* Given a node, calculates the resulting value. | ||
*/ | ||
async function resolveLogicNode(props) { | ||
const type = props.node.type; | ||
if (!nodeResolvers[type]) { | ||
throw new Error(`Unknown node type: ${type}`); | ||
} | ||
const nodeResolver = nodeResolvers[props.node.type]; | ||
return nodeResolver(props); | ||
} | ||
/** | ||
* Given a node, extracts the supported methods that are being invoked. | ||
*/ | ||
async function getLogicNodeMetadata(props) { | ||
const type = props.node.type; | ||
if (!nodeMetadataReader[type]) { | ||
throw new Error(`Unknown node type: ${type}`); | ||
} | ||
const methodExtractor = nodeMetadataReader[props.node.type]; | ||
return methodExtractor(props); | ||
} | ||
class Compiler { | ||
context; | ||
currentConfig = {}; | ||
constructor({ sql, supportedMethods = {}, resolveFn }) { | ||
this.context = { sql, supportedMethods, resolveFn }; | ||
} | ||
/** | ||
* Resolves every block, expression, and function inside the SQL and returns the final query. | ||
* | ||
* Note: Compiling a query may take time in some cases, as some queries may contain expensive | ||
* functions that need to be resolved at runtime. | ||
*/ | ||
async compileSQL() { | ||
const fragment = parse(this.context.sql); | ||
const localScope = new Scope(); | ||
const compiledSql = (await this.resolveBaseNode(fragment, localScope, 0)) | ||
.replace(/ +/g, ' ') // Remove extra spaces | ||
@@ -954,29 +1585,91 @@ .trim(); // Remove leading and trailing spaces | ||
} | ||
parseBaseNode = async (node, localScope) => { | ||
if (!node) | ||
/** | ||
* Without compiling the SQL or resolving any expression, quickly reads the config and calls | ||
* to the supported methods present in the SQL. | ||
*/ | ||
async readMetadata() { | ||
const fragment = parse(this.context.sql); | ||
const rawSql = this.context.sql; | ||
const sqlHash = createHash('sha256').update(rawSql).digest('hex'); | ||
const baseMetadata = await this.getBaseNodeMetadata({ | ||
baseNode: fragment, | ||
depth: 0, | ||
}); | ||
return mergeMetadata(baseMetadata, { ...emptyMetadata(), sqlHash, rawSql }); | ||
} | ||
/** | ||
* Given a base node, returns the string that will replace it in the final SQL | ||
*/ | ||
resolveBaseNode = async (baseNode, localScope, depth) => { | ||
if (!baseNode) | ||
return ''; | ||
if (node.type === 'Fragment') { | ||
return this.parseBaseNodeChildren(node.children, localScope); | ||
if (baseNode.type === 'Fragment') { | ||
// Parent node, only one of its kind | ||
return this.resolveBaseNodeChildren(baseNode.children, localScope, depth); | ||
} | ||
if (node.type === 'Comment') { | ||
return node.raw; | ||
if (baseNode.type === 'Comment') { | ||
return baseNode.raw; | ||
} | ||
if (node.type === 'Text') { | ||
return node.raw; | ||
if (baseNode.type === 'Text') { | ||
return baseNode.raw; | ||
} | ||
if (node.type === 'MustacheTag') { | ||
return await this.parseLogicNode(node.expression, localScope); | ||
if (baseNode.type === 'MustacheTag') { | ||
const expression = baseNode.expression; | ||
// Some node expressions do not inject any value into the SQL | ||
const silentExpressions = [NodeType.AssignmentExpression]; | ||
if (silentExpressions.includes(expression.type)) { | ||
await resolveLogicNode({ | ||
node: expression, | ||
scope: localScope, | ||
raiseError: this.expressionError.bind(this), | ||
supportedMethods: this.context.supportedMethods, | ||
willInterpolate: false, | ||
resolveFn: this.context.resolveFn, | ||
}); | ||
return ''; | ||
} | ||
if ( | ||
// If the expression is a call to a supported method, the result WILL BE INTERPOLATED | ||
expression.type === NodeType.CallExpression && | ||
expression.callee.type === NodeType.Identifier && | ||
expression.callee.name in this.context.supportedMethods) { | ||
return (await resolveLogicNode({ | ||
node: expression, | ||
scope: localScope, | ||
raiseError: this.expressionError.bind(this), | ||
supportedMethods: this.context.supportedMethods, | ||
willInterpolate: true, | ||
resolveFn: this.context.resolveFn, | ||
})); | ||
} | ||
const value = await resolveLogicNode({ | ||
node: expression, | ||
scope: localScope, | ||
raiseError: this.expressionError.bind(this), | ||
supportedMethods: this.context.supportedMethods, | ||
willInterpolate: false, | ||
resolveFn: this.context.resolveFn, | ||
}); | ||
const resolvedValue = await this.context.resolveFn(value); | ||
return resolvedValue; | ||
} | ||
if (node.type === 'ConstTag') { | ||
if (baseNode.type === 'ConstTag') { | ||
// Only allow equal expressions to define constants | ||
const expression = node.expression; | ||
const expression = baseNode.expression; | ||
if (expression.type !== 'AssignmentExpression' || | ||
expression.operator !== '=' || | ||
expression.left.type !== 'Identifier') { | ||
this.baseNodeError(errors.invalidConstantDefinition, node); | ||
this.baseNodeError(errors.invalidConstantDefinition, baseNode); | ||
} | ||
const constName = expression.left.name; | ||
const constValue = await this.resolveLogicNodeExpression(expression.right, localScope); | ||
const constValue = await resolveLogicNode({ | ||
node: expression.right, | ||
scope: localScope, | ||
raiseError: this.expressionError.bind(this), | ||
supportedMethods: this.context.supportedMethods, | ||
willInterpolate: false, | ||
resolveFn: this.context.resolveFn, | ||
}); | ||
if (localScope.exists(constName)) { | ||
this.baseNodeError(errors.variableAlreadyDeclared(constName), node); | ||
this.baseNodeError(errors.variableAlreadyDeclared(constName), baseNode); | ||
} | ||
@@ -986,40 +1679,52 @@ localScope.defineConst(constName, constValue); | ||
} | ||
if (node.type === 'ConfigTag') { | ||
const expression = node.expression; | ||
if (baseNode.type === 'ConfigTag') { | ||
if (depth > 0) { | ||
this.baseNodeError(errors.configInsideBlock, baseNode); | ||
} | ||
const expression = baseNode.expression; | ||
if (expression.type !== 'AssignmentExpression' || | ||
expression.operator !== '=' || | ||
expression.left.type !== 'Identifier') { | ||
this.baseNodeError(errors.invalidConfigDefinition, node); | ||
this.baseNodeError(errors.invalidConfigDefinition, baseNode); | ||
} | ||
const optionKey = expression.left.name; | ||
const optionValue = await this.resolveLogicNodeExpression(expression.right, localScope); | ||
try { | ||
this.configFn(optionKey, optionValue); | ||
if (expression.right.type !== 'Literal') { | ||
this.baseNodeError(errors.invalidConfigValue, baseNode); | ||
} | ||
catch (error) { | ||
const errorMessage = error.message; | ||
this.baseNodeError(errors.configDefinitionFailed(optionKey, errorMessage), node); | ||
} | ||
return ''; | ||
} | ||
if (node.type === 'IfBlock') { | ||
return (await this.resolveLogicNodeExpression(node.expression, localScope)) | ||
? this.parseBaseNodeChildren(node.children, localScope) | ||
: await this.parseBaseNode(node.else, localScope); | ||
if (baseNode.type === 'IfBlock') { | ||
const condition = await resolveLogicNode({ | ||
node: baseNode.expression, | ||
scope: localScope, | ||
raiseError: this.expressionError.bind(this), | ||
supportedMethods: this.context.supportedMethods, | ||
willInterpolate: false, | ||
resolveFn: this.context.resolveFn, | ||
}); | ||
return condition | ||
? this.resolveBaseNodeChildren(baseNode.children, localScope, depth + 1) | ||
: await this.resolveBaseNode(baseNode.else, localScope, depth + 1); | ||
} | ||
if (node.type === 'ElseBlock') { | ||
return this.parseBaseNodeChildren(node.children, localScope); | ||
if (baseNode.type === 'ElseBlock') { | ||
return this.resolveBaseNodeChildren(baseNode.children, localScope, depth + 1); | ||
} | ||
if (node.type === 'EachBlock') { | ||
const iterableElement = await this.resolveLogicNodeExpression(node.expression, localScope); | ||
if (baseNode.type === 'EachBlock') { | ||
const iterableElement = await resolveLogicNode({ | ||
node: baseNode.expression, | ||
scope: localScope, | ||
raiseError: this.expressionError.bind(this), | ||
supportedMethods: this.context.supportedMethods, | ||
willInterpolate: false, | ||
resolveFn: this.context.resolveFn, | ||
}); | ||
if (!Array.isArray(iterableElement) || !iterableElement.length) { | ||
return await this.parseBaseNode(node.else, localScope); | ||
return await this.resolveBaseNode(baseNode.else, localScope, depth + 1); | ||
} | ||
const contextVar = node.context.name; | ||
const indexVar = node.index; | ||
const contextVar = baseNode.context.name; | ||
const indexVar = baseNode.index; | ||
if (localScope.exists(contextVar)) { | ||
this.baseNodeError(errors.variableAlreadyDeclared(contextVar), node); | ||
this.baseNodeError(errors.variableAlreadyDeclared(contextVar), baseNode); | ||
} | ||
if (indexVar && localScope.exists(indexVar)) { | ||
this.baseNodeError(errors.variableAlreadyDeclared(indexVar), node); | ||
this.baseNodeError(errors.variableAlreadyDeclared(indexVar), baseNode); | ||
} | ||
@@ -1032,134 +1737,100 @@ const parsedChildren = []; | ||
localScope.set(contextVar, element); | ||
parsedChildren.push(await this.parseBaseNodeChildren(node.children, localScope)); | ||
parsedChildren.push(await this.resolveBaseNodeChildren(baseNode.children, localScope, depth + 1)); | ||
} | ||
return parsedChildren.join('') || ''; | ||
} | ||
throw this.baseNodeError(errors.unsupportedBaseNodeType(node.type), node); | ||
throw this.baseNodeError(errors.unsupportedBaseNodeType(baseNode.type), baseNode); | ||
}; | ||
parseLogicNode = async (node, localScope) => { | ||
if (node.type === 'AssignmentExpression') { | ||
await this.resolveLogicNodeExpression(node, localScope); | ||
return ''; | ||
resolveBaseNodeChildren = async (children, localScope, depth) => { | ||
const parsedChildren = []; | ||
const childrenScope = localScope.copy(); // All children share the same scope | ||
for (const child of children || []) { | ||
const parsedChild = await this.resolveBaseNode(child, childrenScope, depth); | ||
parsedChildren.push(parsedChild); | ||
} | ||
if (node.type === 'CallExpression') { | ||
return await this.handleFunction(node, true, localScope); | ||
} | ||
const value = await this.resolveLogicNodeExpression(node, localScope); | ||
const resolvedValue = await this.resolveFn(value); | ||
return resolvedValue; | ||
return parsedChildren.join('') || ''; | ||
}; | ||
resolveLogicNodeExpression = async (node, localScope) => { | ||
if (node.type === 'Literal') { | ||
return node.value; | ||
/** | ||
* Given a base node, returns the list of defined configs and present methods from the supportedMethods. | ||
*/ | ||
getBaseNodeMetadata = async ({ baseNode, depth, }) => { | ||
if (!baseNode) | ||
return emptyMetadata(); | ||
if (baseNode.type === 'Fragment') { | ||
const childrenMetadata = await Promise.all((baseNode.children || []).map((child) => this.getBaseNodeMetadata({ | ||
baseNode: child, | ||
depth, | ||
}))); | ||
return mergeMetadata(...childrenMetadata); | ||
} | ||
if (node.type === 'Identifier') { | ||
if (!localScope.exists(node.name)) { | ||
this.expressionError(errors.variableNotDeclared(node.name), node); | ||
} | ||
return localScope.get(node.name); | ||
// Not computed nodes. Do not contain any configs or methods | ||
if (['Comment', 'Text'].includes(baseNode.type)) { | ||
return emptyMetadata(); | ||
} | ||
if (node.type === 'ObjectExpression') { | ||
const resolvedObject = {}; | ||
for (const prop of node.properties) { | ||
if (prop.type !== 'Property') { | ||
throw this.expressionError(errors.invalidObjectKey, node); | ||
} | ||
const key = prop.key; | ||
const value = await this.resolveLogicNodeExpression(prop.value, localScope); | ||
resolvedObject[key.name] = value; | ||
} | ||
return resolvedObject; | ||
if (baseNode.type === 'MustacheTag') { | ||
const expression = baseNode.expression; | ||
return await getLogicNodeMetadata({ | ||
node: expression, | ||
supportedMethods: this.context.supportedMethods, | ||
}); | ||
} | ||
if (node.type === 'ArrayExpression') { | ||
return await Promise.all(node.elements.map((element) => element ? this.resolveLogicNodeExpression(element, localScope) : null)); | ||
if (baseNode.type === 'ConstTag') { | ||
// Only allow equal expressions to define constants | ||
const expression = baseNode.expression; | ||
return await getLogicNodeMetadata({ | ||
node: expression, | ||
supportedMethods: this.context.supportedMethods, | ||
}); | ||
} | ||
if (node.type === 'SequenceExpression') { | ||
return await Promise.all(node.expressions.map((expression) => this.resolveLogicNodeExpression(expression, localScope))); | ||
} | ||
if (node.type === 'LogicalExpression' || node.type === 'BinaryExpression') { | ||
const binaryOperator = node.operator; | ||
if (!(binaryOperator in BINARY_OPERATOR_METHODS)) { | ||
this.expressionError(errors.unsupportedOperator(binaryOperator), node); | ||
if (baseNode.type === 'ConfigTag') { | ||
if (depth > 0) { | ||
this.baseNodeError(errors.configInsideBlock, baseNode); | ||
} | ||
const leftOperand = await this.resolveLogicNodeExpression(node.left, localScope); | ||
const rightOperand = await this.resolveLogicNodeExpression(node.right, localScope); | ||
return BINARY_OPERATOR_METHODS[binaryOperator]?.(leftOperand, rightOperand); | ||
} | ||
if (node.type === 'UnaryExpression') { | ||
const unaryOperator = node.operator; | ||
if (!(unaryOperator in UNARY_OPERATOR_METHODS)) { | ||
this.expressionError(errors.unsupportedOperator(unaryOperator), node); | ||
const expression = baseNode.expression; | ||
if (expression.type !== 'AssignmentExpression' || | ||
expression.operator !== '=' || | ||
expression.left.type !== 'Identifier') { | ||
this.baseNodeError(errors.invalidConfigDefinition, baseNode); | ||
} | ||
const unaryArgument = await this.resolveLogicNodeExpression(node.argument, localScope); | ||
const unaryPrefix = node.prefix; | ||
return UNARY_OPERATOR_METHODS[unaryOperator]?.(unaryArgument, unaryPrefix); | ||
} | ||
if (node.type === 'AssignmentExpression') { | ||
const assignedVariableName = node.left.name; | ||
let assignedValue = await this.resolveLogicNodeExpression(node.right, localScope); | ||
const assignmentOperator = node.operator; | ||
if (assignmentOperator != '=') { | ||
if (!(assignmentOperator in ASSIGNMENT_OPERATOR_METHODS)) { | ||
this.expressionError(errors.unsupportedOperator(assignmentOperator), node); | ||
} | ||
if (!localScope.exists(assignedVariableName)) { | ||
this.expressionError(errors.variableNotDeclared(assignedVariableName), node); | ||
} | ||
assignedValue = ASSIGNMENT_OPERATOR_METHODS[assignmentOperator]?.(localScope.get(assignedVariableName), assignedValue); | ||
if (expression.right.type !== 'Literal') { | ||
this.baseNodeError(errors.invalidConfigValue, baseNode); | ||
} | ||
if (localScope.isConst(assignedVariableName)) { | ||
this.expressionError(errors.constantReassignment, node); | ||
const configName = expression.left.name; | ||
const configValue = expression.right.value; | ||
if (configName in this.currentConfig) { | ||
this.baseNodeError(errors.configAlreadyDefined(configName), baseNode); | ||
} | ||
localScope.set(assignedVariableName, assignedValue); | ||
return assignedValue; | ||
this.currentConfig[configName] = configValue; | ||
return { | ||
...emptyMetadata(), | ||
config: { | ||
[configName]: configValue, | ||
}, | ||
}; | ||
} | ||
if (node.type === 'UpdateExpression') { | ||
const updateOperator = node.operator; | ||
if (!['++', '--'].includes(updateOperator)) { | ||
this.expressionError(errors.unsupportedOperator(updateOperator), node); | ||
} | ||
const updatedVariableName = node.argument.name; | ||
if (!localScope.exists(updatedVariableName)) { | ||
this.expressionError(errors.variableNotDeclared(updatedVariableName), node); | ||
} | ||
if (localScope.isConst(updatedVariableName)) { | ||
this.expressionError(errors.constantReassignment, node); | ||
} | ||
const originalValue = localScope.get(updatedVariableName); | ||
const updatedValue = updateOperator === '++' | ||
? originalValue + 1 | ||
: originalValue - 1; | ||
localScope.set(updatedVariableName, updatedValue); | ||
return node.prefix ? updatedValue : originalValue; | ||
if (baseNode.type === 'IfBlock' || baseNode.type === 'EachBlock') { | ||
const expression = baseNode.expression; | ||
const conditionMetadata = await getLogicNodeMetadata({ | ||
node: expression, | ||
supportedMethods: this.context.supportedMethods, | ||
}); | ||
const elseMetadata = await this.getBaseNodeMetadata({ | ||
baseNode: baseNode.else, | ||
depth: depth + 1, | ||
}); | ||
const childrenMetadata = await Promise.all((baseNode.children || []).map((child) => this.getBaseNodeMetadata({ | ||
baseNode: child, | ||
depth: depth + 1, | ||
}))); | ||
return mergeMetadata(conditionMetadata, elseMetadata, ...childrenMetadata); | ||
} | ||
if (node.type === 'MemberExpression') { | ||
const object = (await this.resolveLogicNodeExpression(node.object, localScope)); | ||
const property = node.computed | ||
? await this.resolveLogicNodeExpression(node.property, localScope) | ||
: node.property.name; | ||
return MEMBER_EXPRESSION_METHOD(object, property); | ||
if (baseNode.type === 'ElseBlock') { | ||
const childrenMetadata = await Promise.all((baseNode.children || []).map((child) => this.getBaseNodeMetadata({ | ||
baseNode: child, | ||
depth: depth + 1, | ||
}))); | ||
return mergeMetadata(...childrenMetadata); | ||
} | ||
if (node.type === 'ConditionalExpression') { | ||
const test = await this.resolveLogicNodeExpression(node.test, localScope); | ||
const consequent = await this.resolveLogicNodeExpression(node.consequent, localScope); | ||
const alternate = await this.resolveLogicNodeExpression(node.alternate, localScope); | ||
return test ? consequent : alternate; | ||
} | ||
if (node.type === 'CallExpression') { | ||
return await this.handleFunction(node, false, localScope); | ||
} | ||
if (node.type === 'NewExpression') { | ||
throw this.expressionError(errors.unsupportedOperator('new'), node); | ||
} | ||
throw this.expressionError(errors.unsupportedExpressionType(node.type), node); | ||
throw this.baseNodeError(errors.unsupportedBaseNodeType(baseNode.type), baseNode); | ||
}; | ||
parseBaseNodeChildren = async (children, localScope) => { | ||
const parsedChildren = []; | ||
const childrenScope = localScope.copy(); | ||
for (const child of children || []) { | ||
const parsedChild = await this.parseBaseNode(child, childrenScope); | ||
parsedChildren.push(parsedChild); | ||
} | ||
return parsedChildren.join('') || ''; | ||
}; | ||
baseNodeError({ code, message }, node) { | ||
@@ -1169,3 +1840,3 @@ error(message, { | ||
code, | ||
source: this.sql || '', | ||
source: this.context.sql || '', | ||
start: node.start || 0, | ||
@@ -1176,3 +1847,3 @@ end: node.end || undefined, | ||
expressionError({ code, message }, node) { | ||
const source = (node.loc?.source ?? this.sql).split('\n'); | ||
const source = (node.loc?.source ?? this.context.sql).split('\n'); | ||
const start = source | ||
@@ -1188,3 +1859,3 @@ .slice(0, node.loc?.start.line - 1) | ||
code, | ||
source: this.sql || '', | ||
source: this.context.sql || '', | ||
start, | ||
@@ -1194,37 +1865,20 @@ end, | ||
} | ||
handleFunction = async (node, interpolation, localScope) => { | ||
const methodName = node.callee.name; | ||
if (!(methodName in this.supportedMethods)) { | ||
this.expressionError(errors.unknownFunction(methodName), node); | ||
} | ||
const method = this.supportedMethods[methodName]; | ||
const args = []; | ||
for (const arg of node.arguments) { | ||
args.push(await this.resolveLogicNodeExpression(arg, localScope)); | ||
} | ||
try { | ||
const returnedValue = (await method(interpolation, ...args)); | ||
if (interpolation && typeof returnedValue !== 'string') { | ||
this.expressionError(errors.invalidFunctionResultInterpolation, node); | ||
} | ||
return returnedValue; | ||
} | ||
catch (error) { | ||
if (error instanceof CompileError) | ||
throw error; | ||
this.expressionError(errors.functionCallError(methodName, error.message), node); | ||
} | ||
}; | ||
} | ||
function compile({ query, supportedMethods, resolveFn, configFn, }) { | ||
function compile({ query, supportedMethods, resolveFn, }) { | ||
return new Compiler({ | ||
query, | ||
supportedMethods, | ||
sql: query, | ||
supportedMethods: supportedMethods || {}, | ||
resolveFn, | ||
configFn, | ||
}).compile(); | ||
}).compileSQL(); | ||
} | ||
function readMetadata({ query, supportedMethods, }) { | ||
return new Compiler({ | ||
sql: query, | ||
supportedMethods: supportedMethods || {}, | ||
resolveFn: () => Promise.resolve(''), | ||
}).readMetadata(); | ||
} | ||
export { CompileError, compile as default }; | ||
export { CompileError, compile, emptyMetadata, mergeMetadata, readMetadata }; | ||
//# sourceMappingURL=index.js.map |
{ | ||
"name": "@latitude-data/sql-compiler", | ||
"version": "0.2.0", | ||
"version": "1.0.0-next.0", | ||
"license": "LGPL", | ||
@@ -10,2 +10,3 @@ "description": "Compiler for Latitude's custom sql sintax based on svelte", | ||
"@types/estree": "^1.0.1", | ||
"@types/node": "^20.12.12", | ||
"rollup": "^4.10.0", | ||
@@ -12,0 +13,0 @@ "typescript": "^5.2.2", |
import { BaseNode, type TemplateNode } from '../parser/interfaces' | ||
import { type Node, type Identifier, type SimpleCallExpression } from 'estree' | ||
import type { Node, Identifier, Literal } from 'estree' | ||
import parse from '../parser/index' | ||
import { | ||
ASSIGNMENT_OPERATOR_METHODS, | ||
BINARY_OPERATOR_METHODS, | ||
MEMBER_EXPRESSION_METHOD, | ||
UNARY_OPERATOR_METHODS, | ||
} from './operators' | ||
import CompileError, { error } from '../error/error' | ||
import { error } from '../error/error' | ||
import errors from '../error/errors' | ||
import Scope from './scope' | ||
import { NodeType } from './logic/types' | ||
import type { CompileContext, QueryMetadata } from './types' | ||
import { getLogicNodeMetadata, resolveLogicNode } from './logic' | ||
import { emptyMetadata, mergeMetadata } from './utils' | ||
import { createHash } from 'node:crypto' | ||
type CompilerAttrs = { | ||
query: string | ||
resolveFn: ResolveFn | ||
configFn: ConfigFn | ||
supportedMethods?: Record<string, SupportedMethod> | ||
} | ||
export type SupportedMethod = <T extends boolean>( | ||
interpolation: T, | ||
...args: unknown[] | ||
) => Promise<T extends true ? string : unknown> | ||
export type ResolveFn = (value: unknown) => Promise<string> | ||
export type ConfigFn = (key: string, value: unknown) => void | ||
export class Compiler { | ||
private sql: string | ||
private supportedMethods: Record<string, SupportedMethod> | ||
private resolveFn: ResolveFn | ||
private configFn: ConfigFn | ||
private context: CompileContext | ||
private currentConfig: Record<string, unknown> = {} | ||
private varStash: unknown[] | ||
private readFromStash(index: number): unknown { | ||
return this.varStash[index] | ||
constructor({ sql, supportedMethods = {}, resolveFn }: CompileContext) { | ||
this.context = { sql, supportedMethods, resolveFn } | ||
} | ||
private addToStash(value: unknown): number { | ||
this.varStash.push(value) | ||
return this.varStash.length - 1 | ||
} | ||
private modifyStash(index: number, value: unknown): void { | ||
this.varStash[index] = value | ||
} | ||
constructor({ | ||
query, | ||
resolveFn, | ||
configFn, | ||
supportedMethods = {}, | ||
}: CompilerAttrs) { | ||
this.sql = query | ||
this.resolveFn = resolveFn | ||
this.configFn = configFn | ||
this.supportedMethods = supportedMethods | ||
this.varStash = [] | ||
} | ||
async compile(): Promise<string> { | ||
const fragment = parse(this.sql) | ||
const localScope = new Scope( | ||
this.readFromStash.bind(this), | ||
this.addToStash.bind(this), | ||
this.modifyStash.bind(this), | ||
) | ||
const compiledSql = (await this.parseBaseNode(fragment, localScope)) | ||
/** | ||
* Resolves every block, expression, and function inside the SQL and returns the final query. | ||
* | ||
* Note: Compiling a query may take time in some cases, as some queries may contain expensive | ||
* functions that need to be resolved at runtime. | ||
*/ | ||
async compileSQL(): Promise<string> { | ||
const fragment = parse(this.context.sql) | ||
const localScope = new Scope() | ||
const compiledSql = (await this.resolveBaseNode(fragment, localScope, 0)) | ||
.replace(/ +/g, ' ') // Remove extra spaces | ||
@@ -76,27 +37,90 @@ .trim() // Remove leading and trailing spaces | ||
private parseBaseNode = async ( | ||
node: BaseNode, | ||
/** | ||
* Without compiling the SQL or resolving any expression, quickly reads the config and calls | ||
* to the supported methods present in the SQL. | ||
*/ | ||
async readMetadata(): Promise<QueryMetadata> { | ||
const fragment = parse(this.context.sql) | ||
const rawSql = this.context.sql | ||
const sqlHash = createHash('sha256').update(rawSql).digest('hex') | ||
const baseMetadata = await this.getBaseNodeMetadata({ | ||
baseNode: fragment, | ||
depth: 0, | ||
}) | ||
return mergeMetadata(baseMetadata, { ...emptyMetadata(), sqlHash, rawSql }) | ||
} | ||
/** | ||
* Given a base node, returns the string that will replace it in the final SQL | ||
*/ | ||
private resolveBaseNode = async ( | ||
baseNode: BaseNode, | ||
localScope: Scope, | ||
depth: number, | ||
): Promise<string> => { | ||
if (!node) return '' | ||
if (!baseNode) return '' | ||
if (node.type === 'Fragment') { | ||
return this.parseBaseNodeChildren(node.children, localScope) | ||
if (baseNode.type === 'Fragment') { | ||
// Parent node, only one of its kind | ||
return this.resolveBaseNodeChildren(baseNode.children, localScope, depth) | ||
} | ||
if (node.type === 'Comment') { | ||
return node.raw | ||
if (baseNode.type === 'Comment') { | ||
return baseNode.raw | ||
} | ||
if (node.type === 'Text') { | ||
return node.raw | ||
if (baseNode.type === 'Text') { | ||
return baseNode.raw | ||
} | ||
if (node.type === 'MustacheTag') { | ||
return await this.parseLogicNode(node.expression, localScope) | ||
if (baseNode.type === 'MustacheTag') { | ||
const expression = baseNode.expression | ||
// Some node expressions do not inject any value into the SQL | ||
const silentExpressions = [NodeType.AssignmentExpression] | ||
if (silentExpressions.includes(expression.type as NodeType)) { | ||
await resolveLogicNode({ | ||
node: expression, | ||
scope: localScope, | ||
raiseError: this.expressionError.bind(this), | ||
supportedMethods: this.context.supportedMethods, | ||
willInterpolate: false, | ||
resolveFn: this.context.resolveFn, | ||
}) | ||
return '' | ||
} | ||
if ( | ||
// If the expression is a call to a supported method, the result WILL BE INTERPOLATED | ||
expression.type === NodeType.CallExpression && | ||
expression.callee.type === NodeType.Identifier && | ||
expression.callee.name in this.context.supportedMethods | ||
) { | ||
return (await resolveLogicNode({ | ||
node: expression, | ||
scope: localScope, | ||
raiseError: this.expressionError.bind(this), | ||
supportedMethods: this.context.supportedMethods, | ||
willInterpolate: true, | ||
resolveFn: this.context.resolveFn, | ||
})) as string | ||
} | ||
const value = await resolveLogicNode({ | ||
node: expression, | ||
scope: localScope, | ||
raiseError: this.expressionError.bind(this), | ||
supportedMethods: this.context.supportedMethods, | ||
willInterpolate: false, | ||
resolveFn: this.context.resolveFn, | ||
}) | ||
const resolvedValue = await this.context.resolveFn(value) | ||
return resolvedValue | ||
} | ||
if (node.type === 'ConstTag') { | ||
if (baseNode.type === 'ConstTag') { | ||
// Only allow equal expressions to define constants | ||
const expression = node.expression | ||
const expression = baseNode.expression | ||
if ( | ||
@@ -107,12 +131,16 @@ expression.type !== 'AssignmentExpression' || | ||
) { | ||
this.baseNodeError(errors.invalidConstantDefinition, node) | ||
this.baseNodeError(errors.invalidConstantDefinition, baseNode) | ||
} | ||
const constName = (expression.left as Identifier).name | ||
const constValue = await this.resolveLogicNodeExpression( | ||
expression.right, | ||
localScope, | ||
) | ||
const constValue = await resolveLogicNode({ | ||
node: expression.right, | ||
scope: localScope, | ||
raiseError: this.expressionError.bind(this), | ||
supportedMethods: this.context.supportedMethods, | ||
willInterpolate: false, | ||
resolveFn: this.context.resolveFn, | ||
}) | ||
if (localScope.exists(constName)) { | ||
this.baseNodeError(errors.variableAlreadyDeclared(constName), node) | ||
this.baseNodeError(errors.variableAlreadyDeclared(constName), baseNode) | ||
} | ||
@@ -123,4 +151,8 @@ localScope.defineConst(constName, constValue) | ||
if (node.type === 'ConfigTag') { | ||
const expression = node.expression | ||
if (baseNode.type === 'ConfigTag') { | ||
if (depth > 0) { | ||
this.baseNodeError(errors.configInsideBlock, baseNode) | ||
} | ||
const expression = baseNode.expression | ||
if ( | ||
@@ -131,51 +163,54 @@ expression.type !== 'AssignmentExpression' || | ||
) { | ||
this.baseNodeError(errors.invalidConfigDefinition, node) | ||
this.baseNodeError(errors.invalidConfigDefinition, baseNode) | ||
} | ||
const optionKey = (expression.left as Identifier).name | ||
const optionValue = await this.resolveLogicNodeExpression( | ||
expression.right, | ||
localScope, | ||
) | ||
try { | ||
this.configFn(optionKey, optionValue) | ||
} catch (error: unknown) { | ||
const errorMessage = (error as Error).message | ||
this.baseNodeError( | ||
errors.configDefinitionFailed(optionKey, errorMessage), | ||
node, | ||
) | ||
if (expression.right.type !== 'Literal') { | ||
this.baseNodeError(errors.invalidConfigValue, baseNode) | ||
} | ||
return '' | ||
} | ||
if (node.type === 'IfBlock') { | ||
return (await this.resolveLogicNodeExpression( | ||
node.expression, | ||
localScope, | ||
)) | ||
? this.parseBaseNodeChildren(node.children, localScope) | ||
: await this.parseBaseNode(node.else, localScope) | ||
if (baseNode.type === 'IfBlock') { | ||
const condition = await resolveLogicNode({ | ||
node: baseNode.expression, | ||
scope: localScope, | ||
raiseError: this.expressionError.bind(this), | ||
supportedMethods: this.context.supportedMethods, | ||
willInterpolate: false, | ||
resolveFn: this.context.resolveFn, | ||
}) | ||
return condition | ||
? this.resolveBaseNodeChildren(baseNode.children, localScope, depth + 1) | ||
: await this.resolveBaseNode(baseNode.else, localScope, depth + 1) | ||
} | ||
if (node.type === 'ElseBlock') { | ||
return this.parseBaseNodeChildren(node.children, localScope) | ||
if (baseNode.type === 'ElseBlock') { | ||
return this.resolveBaseNodeChildren( | ||
baseNode.children, | ||
localScope, | ||
depth + 1, | ||
) | ||
} | ||
if (node.type === 'EachBlock') { | ||
const iterableElement = await this.resolveLogicNodeExpression( | ||
node.expression, | ||
localScope, | ||
) | ||
if (baseNode.type === 'EachBlock') { | ||
const iterableElement = await resolveLogicNode({ | ||
node: baseNode.expression, | ||
scope: localScope, | ||
raiseError: this.expressionError.bind(this), | ||
supportedMethods: this.context.supportedMethods, | ||
willInterpolate: false, | ||
resolveFn: this.context.resolveFn, | ||
}) | ||
if (!Array.isArray(iterableElement) || !iterableElement.length) { | ||
return await this.parseBaseNode(node.else, localScope) | ||
return await this.resolveBaseNode(baseNode.else, localScope, depth + 1) | ||
} | ||
const contextVar = node.context.name | ||
const indexVar = node.index | ||
const contextVar = baseNode.context.name | ||
const indexVar = baseNode.index | ||
if (localScope.exists(contextVar)) { | ||
this.baseNodeError(errors.variableAlreadyDeclared(contextVar), node) | ||
this.baseNodeError(errors.variableAlreadyDeclared(contextVar), baseNode) | ||
} | ||
if (indexVar && localScope.exists(indexVar)) { | ||
this.baseNodeError(errors.variableAlreadyDeclared(indexVar), node) | ||
this.baseNodeError(errors.variableAlreadyDeclared(indexVar), baseNode) | ||
} | ||
@@ -189,3 +224,7 @@ | ||
parsedChildren.push( | ||
await this.parseBaseNodeChildren(node.children, localScope), | ||
await this.resolveBaseNodeChildren( | ||
baseNode.children, | ||
localScope, | ||
depth + 1, | ||
), | ||
) | ||
@@ -196,218 +235,151 @@ } | ||
throw this.baseNodeError(errors.unsupportedBaseNodeType(node.type), node) | ||
throw this.baseNodeError( | ||
errors.unsupportedBaseNodeType(baseNode.type), | ||
baseNode, | ||
) | ||
} | ||
private parseLogicNode = async ( | ||
node: Node, | ||
private resolveBaseNodeChildren = async ( | ||
children: TemplateNode[] | undefined, | ||
localScope: Scope, | ||
depth: number, | ||
): Promise<string> => { | ||
if (node.type === 'AssignmentExpression') { | ||
await this.resolveLogicNodeExpression(node, localScope) | ||
return '' | ||
} | ||
if (node.type === 'CallExpression') { | ||
return await this.handleFunction( | ||
node as SimpleCallExpression, | ||
true, | ||
localScope, | ||
const parsedChildren: string[] = [] | ||
const childrenScope = localScope.copy() // All children share the same scope | ||
for (const child of children || []) { | ||
const parsedChild = await this.resolveBaseNode( | ||
child, | ||
childrenScope, | ||
depth, | ||
) | ||
parsedChildren.push(parsedChild) | ||
} | ||
const value = await this.resolveLogicNodeExpression(node, localScope) | ||
const resolvedValue = await this.resolveFn(value) | ||
return resolvedValue | ||
return parsedChildren.join('') || '' | ||
} | ||
private resolveLogicNodeExpression = async ( | ||
node: Node, | ||
localScope: Scope, | ||
): Promise<unknown> => { | ||
if (node.type === 'Literal') { | ||
return node.value | ||
} | ||
/** | ||
* Given a base node, returns the list of defined configs and present methods from the supportedMethods. | ||
*/ | ||
private getBaseNodeMetadata = async ({ | ||
baseNode, | ||
depth, | ||
}: { | ||
baseNode: BaseNode | ||
depth: number | ||
}): Promise<QueryMetadata> => { | ||
if (!baseNode) return emptyMetadata() | ||
if (node.type === 'Identifier') { | ||
if (!localScope.exists(node.name)) { | ||
this.expressionError(errors.variableNotDeclared(node.name), node) | ||
} | ||
return localScope.get(node.name) | ||
} | ||
if (node.type === 'ObjectExpression') { | ||
const resolvedObject: { [key: string]: any } = {} | ||
for (const prop of node.properties) { | ||
if (prop.type !== 'Property') { | ||
throw this.expressionError(errors.invalidObjectKey, node) | ||
} | ||
const key = prop.key as Identifier | ||
const value = await this.resolveLogicNodeExpression( | ||
prop.value, | ||
localScope, | ||
) | ||
resolvedObject[key.name] = value | ||
} | ||
return resolvedObject | ||
} | ||
if (node.type === 'ArrayExpression') { | ||
return await Promise.all( | ||
node.elements.map((element) => | ||
element ? this.resolveLogicNodeExpression(element, localScope) : null, | ||
if (baseNode.type === 'Fragment') { | ||
const childrenMetadata = await Promise.all( | ||
(baseNode.children || []).map((child) => | ||
this.getBaseNodeMetadata({ | ||
baseNode: child, | ||
depth, | ||
}), | ||
), | ||
) | ||
return mergeMetadata(...childrenMetadata) | ||
} | ||
if (node.type === 'SequenceExpression') { | ||
return await Promise.all( | ||
node.expressions.map((expression) => | ||
this.resolveLogicNodeExpression(expression, localScope), | ||
), | ||
) | ||
// Not computed nodes. Do not contain any configs or methods | ||
if (['Comment', 'Text'].includes(baseNode.type)) { | ||
return emptyMetadata() | ||
} | ||
if (node.type === 'LogicalExpression' || node.type === 'BinaryExpression') { | ||
const binaryOperator = node.operator | ||
if (!(binaryOperator in BINARY_OPERATOR_METHODS)) { | ||
this.expressionError(errors.unsupportedOperator(binaryOperator), node) | ||
} | ||
const leftOperand = await this.resolveLogicNodeExpression( | ||
node.left, | ||
localScope, | ||
) | ||
const rightOperand = await this.resolveLogicNodeExpression( | ||
node.right, | ||
localScope, | ||
) | ||
return BINARY_OPERATOR_METHODS[binaryOperator]?.( | ||
leftOperand, | ||
rightOperand, | ||
) | ||
if (baseNode.type === 'MustacheTag') { | ||
const expression = baseNode.expression | ||
return await getLogicNodeMetadata({ | ||
node: expression, | ||
supportedMethods: this.context.supportedMethods, | ||
}) | ||
} | ||
if (node.type === 'UnaryExpression') { | ||
const unaryOperator = node.operator | ||
if (!(unaryOperator in UNARY_OPERATOR_METHODS)) { | ||
this.expressionError(errors.unsupportedOperator(unaryOperator), node) | ||
} | ||
if (baseNode.type === 'ConstTag') { | ||
// Only allow equal expressions to define constants | ||
const expression = baseNode.expression | ||
const unaryArgument = await this.resolveLogicNodeExpression( | ||
node.argument, | ||
localScope, | ||
) | ||
const unaryPrefix = node.prefix | ||
return UNARY_OPERATOR_METHODS[unaryOperator]?.(unaryArgument, unaryPrefix) | ||
return await getLogicNodeMetadata({ | ||
node: expression, | ||
supportedMethods: this.context.supportedMethods, | ||
}) | ||
} | ||
if (node.type === 'AssignmentExpression') { | ||
const assignedVariableName = (node.left as Identifier).name | ||
let assignedValue = await this.resolveLogicNodeExpression( | ||
node.right, | ||
localScope, | ||
) | ||
const assignmentOperator = node.operator | ||
if (baseNode.type === 'ConfigTag') { | ||
if (depth > 0) { | ||
this.baseNodeError(errors.configInsideBlock, baseNode) | ||
} | ||
if (assignmentOperator != '=') { | ||
if (!(assignmentOperator in ASSIGNMENT_OPERATOR_METHODS)) { | ||
this.expressionError( | ||
errors.unsupportedOperator(assignmentOperator), | ||
node, | ||
) | ||
} | ||
if (!localScope.exists(assignedVariableName)) { | ||
this.expressionError( | ||
errors.variableNotDeclared(assignedVariableName), | ||
node, | ||
) | ||
} | ||
assignedValue = ASSIGNMENT_OPERATOR_METHODS[assignmentOperator]?.( | ||
localScope.get(assignedVariableName), | ||
assignedValue, | ||
) | ||
const expression = baseNode.expression | ||
if ( | ||
expression.type !== 'AssignmentExpression' || | ||
expression.operator !== '=' || | ||
expression.left.type !== 'Identifier' | ||
) { | ||
this.baseNodeError(errors.invalidConfigDefinition, baseNode) | ||
} | ||
if (localScope.isConst(assignedVariableName)) { | ||
this.expressionError(errors.constantReassignment, node) | ||
if (expression.right.type !== 'Literal') { | ||
this.baseNodeError(errors.invalidConfigValue, baseNode) | ||
} | ||
localScope.set(assignedVariableName, assignedValue) | ||
return assignedValue | ||
} | ||
if (node.type === 'UpdateExpression') { | ||
const updateOperator = node.operator | ||
if (!['++', '--'].includes(updateOperator)) { | ||
this.expressionError(errors.unsupportedOperator(updateOperator), node) | ||
const configName = (expression.left as Identifier).name | ||
const configValue = (expression.right as Literal).value | ||
if (configName in this.currentConfig) { | ||
this.baseNodeError(errors.configAlreadyDefined(configName), baseNode) | ||
} | ||
const updatedVariableName = (node.argument as Identifier).name | ||
if (!localScope.exists(updatedVariableName)) { | ||
this.expressionError( | ||
errors.variableNotDeclared(updatedVariableName), | ||
node, | ||
) | ||
} | ||
if (localScope.isConst(updatedVariableName)) { | ||
this.expressionError(errors.constantReassignment, node) | ||
} | ||
const originalValue = localScope.get(updatedVariableName) | ||
const updatedValue = | ||
updateOperator === '++' | ||
? (originalValue as number) + 1 | ||
: (originalValue as number) - 1 | ||
localScope.set(updatedVariableName, updatedValue) | ||
return node.prefix ? updatedValue : originalValue | ||
} | ||
if (node.type === 'MemberExpression') { | ||
const object = (await this.resolveLogicNodeExpression( | ||
node.object, | ||
localScope, | ||
)) as { | ||
[key: string]: any | ||
this.currentConfig[configName] = configValue | ||
return { | ||
...emptyMetadata(), | ||
config: { | ||
[configName]: configValue, | ||
}, | ||
} | ||
const property = node.computed | ||
? await this.resolveLogicNodeExpression(node.property, localScope) | ||
: (node.property as Identifier).name | ||
return MEMBER_EXPRESSION_METHOD(object, property) | ||
} | ||
if (node.type === 'ConditionalExpression') { | ||
const test = await this.resolveLogicNodeExpression(node.test, localScope) | ||
const consequent = await this.resolveLogicNodeExpression( | ||
node.consequent, | ||
localScope, | ||
if (baseNode.type === 'IfBlock' || baseNode.type === 'EachBlock') { | ||
const expression = baseNode.expression | ||
const conditionMetadata = await getLogicNodeMetadata({ | ||
node: expression, | ||
supportedMethods: this.context.supportedMethods, | ||
}) | ||
const elseMetadata = await this.getBaseNodeMetadata({ | ||
baseNode: baseNode.else, | ||
depth: depth + 1, | ||
}) | ||
const childrenMetadata = await Promise.all( | ||
(baseNode.children || []).map((child) => | ||
this.getBaseNodeMetadata({ | ||
baseNode: child, | ||
depth: depth + 1, | ||
}), | ||
), | ||
) | ||
const alternate = await this.resolveLogicNodeExpression( | ||
node.alternate, | ||
localScope, | ||
) | ||
return test ? consequent : alternate | ||
} | ||
if (node.type === 'CallExpression') { | ||
return await this.handleFunction(node, false, localScope) | ||
return mergeMetadata(conditionMetadata, elseMetadata, ...childrenMetadata) | ||
} | ||
if (node.type === 'NewExpression') { | ||
throw this.expressionError(errors.unsupportedOperator('new'), node) | ||
if (baseNode.type === 'ElseBlock') { | ||
const childrenMetadata = await Promise.all( | ||
(baseNode.children || []).map((child) => | ||
this.getBaseNodeMetadata({ | ||
baseNode: child, | ||
depth: depth + 1, | ||
}), | ||
), | ||
) | ||
return mergeMetadata(...childrenMetadata) | ||
} | ||
throw this.expressionError( | ||
errors.unsupportedExpressionType(node.type), | ||
node, | ||
throw this.baseNodeError( | ||
errors.unsupportedBaseNodeType(baseNode.type), | ||
baseNode, | ||
) | ||
} | ||
private parseBaseNodeChildren = async ( | ||
children: TemplateNode[] | undefined, | ||
localScope: Scope, | ||
): Promise<string> => { | ||
const parsedChildren: string[] = [] | ||
const childrenScope = localScope.copy() | ||
for (const child of children || []) { | ||
const parsedChild = await this.parseBaseNode(child, childrenScope) | ||
parsedChildren.push(parsedChild) | ||
} | ||
return parsedChildren.join('') || '' | ||
} | ||
private baseNodeError( | ||
@@ -420,3 +392,3 @@ { code, message }: { code: string; message: string }, | ||
code, | ||
source: this.sql || '', | ||
source: this.context.sql || '', | ||
start: node.start || 0, | ||
@@ -431,3 +403,3 @@ end: node.end || undefined, | ||
): never { | ||
const source = (node.loc?.source ?? this.sql)!.split('\n') | ||
const source = (node.loc?.source ?? this.context.sql)!.split('\n') | ||
const start = | ||
@@ -446,3 +418,3 @@ source | ||
code, | ||
source: this.sql || '', | ||
source: this.context.sql || '', | ||
start, | ||
@@ -452,34 +424,2 @@ end, | ||
} | ||
private handleFunction = async <T extends boolean>( | ||
node: SimpleCallExpression, | ||
interpolation: T, | ||
localScope: Scope, | ||
): Promise<T extends true ? string : unknown> => { | ||
const methodName = (node.callee as Identifier).name | ||
if (!(methodName in this.supportedMethods)) { | ||
this.expressionError(errors.unknownFunction(methodName), node) | ||
} | ||
const method = this.supportedMethods[methodName]! as SupportedMethod | ||
const args: unknown[] = [] | ||
for (const arg of node.arguments) { | ||
args.push(await this.resolveLogicNodeExpression(arg, localScope)) | ||
} | ||
try { | ||
const returnedValue = (await method( | ||
interpolation, | ||
...args, | ||
)) as T extends true ? string : unknown | ||
if (interpolation && typeof returnedValue !== 'string') { | ||
this.expressionError(errors.invalidFunctionResultInterpolation, node) | ||
} | ||
return returnedValue | ||
} catch (error: unknown) { | ||
if (error instanceof CompileError) throw error | ||
this.expressionError( | ||
errors.functionCallError(methodName, (error as Error).message), | ||
node, | ||
) | ||
} | ||
} | ||
} |
export default class Scope { | ||
private consts: Record<string, number> = {} | ||
private vars: Record<string, number> = {} | ||
/** | ||
* Global stash | ||
* All variable values are stored in a single global array. This is done to allow multiple | ||
* scopes to share the same variable values and be able to modify them. | ||
* | ||
* For example: | ||
* ```sql | ||
* {var1 = 1} | ||
* {#if <condition>} | ||
* {var1 = 2} | ||
* {var2 = 3} | ||
* {/if} | ||
* ``` | ||
* In this case, there are two scopes: root and if. Both scopes share the same variable `var1`, | ||
* and modifying it in the if scope should also modify it in the root scope. But `var2` is only | ||
* defined in the if scope and should not be accessible in the root scope. | ||
*/ | ||
private static stash: unknown[] = [] // Stash of every variable value in every scope | ||
private static readFromStash(index: number): unknown { | ||
return Scope.stash[index] | ||
} | ||
private static addToStash(value: unknown): number { | ||
Scope.stash.push(value) | ||
return Scope.stash.length - 1 | ||
} | ||
constructor( | ||
private readFromStash: (index: number) => unknown, | ||
private addToStash: (value: unknown) => number, | ||
private modifyStash: (index: number, value: unknown) => void, | ||
) {} | ||
private static modifyStash(index: number, value: unknown): void { | ||
Scope.stash[index] = value | ||
} | ||
/** | ||
* Local scope | ||
* Every scope has its own local stash that contains the indexes of the variables and constants | ||
* in the global stash. | ||
*/ | ||
private consts: Record<string, number> = {} // Index of every constant in the stash in the current scope | ||
private vars: Record<string, number> = {} // Index of every variable in the stash in the current scope | ||
constructor() {} | ||
exists(name: string): boolean { | ||
@@ -21,27 +52,25 @@ return name in this.consts || name in this.vars | ||
const index = this.consts[name] ?? this.vars[name] ?? undefined | ||
if (index === undefined) throw new Error(`Variable ${name} does not exist`) | ||
return this.readFromStash(index) | ||
if (index === undefined) | ||
throw new Error(`Variable '${name}' does not exist`) | ||
return Scope.readFromStash(index) | ||
} | ||
defineConst(name: string, value: unknown): void { | ||
if (this.exists(name)) throw new Error(`Variable ${name} already exists`) | ||
this.consts[name] = this.addToStash(value) | ||
if (this.exists(name)) throw new Error(`Variable '${name}' already exists`) | ||
this.consts[name] = Scope.addToStash(value) | ||
} | ||
set(name: string, value: unknown): void { | ||
if (this.isConst(name)) throw new Error(`Variable ${name} is a constant`) | ||
if (this.isConst(name)) | ||
throw new Error(`Constant '${name}' cannot be modified`) | ||
if (!this.exists(name)) { | ||
this.vars[name] = this.addToStash(value) | ||
this.vars[name] = Scope.addToStash(value) | ||
return | ||
} | ||
const index = this.vars[name]! | ||
this.modifyStash(index, value) | ||
Scope.modifyStash(index, value) | ||
} | ||
copy(): Scope { | ||
const scope = new Scope( | ||
this.readFromStash, | ||
this.addToStash, | ||
this.modifyStash, | ||
) | ||
const scope = new Scope() | ||
scope.consts = { ...this.consts } | ||
@@ -48,0 +77,0 @@ scope.vars = { ...this.vars } |
@@ -0,1 +1,6 @@ | ||
function getKlassName(error: unknown): string { | ||
const errorKlass = error as Error | ||
return errorKlass.constructor ? errorKlass.constructor.name : 'Error' | ||
} | ||
export default { | ||
@@ -81,2 +86,11 @@ unexpectedEof: { | ||
}, | ||
invalidConfigValue: { | ||
code: 'invalid-config-value', | ||
message: | ||
'Config values must be literals. Cannot use variables or expressions', | ||
}, | ||
configInsideBlock: { | ||
code: 'config-inside-block', | ||
message: 'Cannot must be defined at root level. Cannot be inside a block', | ||
}, | ||
configDefinitionFailed: (name: string, message: string) => ({ | ||
@@ -86,2 +100,6 @@ code: 'config-definition-failed', | ||
}), | ||
configAlreadyDefined: (name: string) => ({ | ||
code: 'config-already-defined', | ||
message: `Config definition for '${name}' failed: Option already configured`, | ||
}), | ||
variableAlreadyDeclared: (name: string) => ({ | ||
@@ -107,2 +125,15 @@ code: 'variable-already-declared', | ||
}, | ||
invalidAssignment: { | ||
code: 'invalid-assignment', | ||
message: 'Invalid assignment', | ||
}, | ||
invalidUpdate: (operation: string, type: string) => ({ | ||
code: 'invalid-update', | ||
message: `Cannot use ${operation} operation on ${type}`, | ||
}), | ||
propertyNotExists: (property: string) => ({ | ||
code: 'property-not-exists', | ||
message: `Property '${property}' does not exist on object`, | ||
}), | ||
unknownFunction: (name: string) => ({ | ||
@@ -112,6 +143,26 @@ code: 'unknown-function', | ||
}), | ||
functionCallError: (name: string, message: string) => ({ | ||
code: 'function-call-error', | ||
message: `Error calling function '${name}': ${message}`, | ||
notAFunction: (objectType: string) => ({ | ||
code: 'not-a-function', | ||
message: `Object '${objectType}' is callable`, | ||
}), | ||
functionCallError: (err: unknown) => { | ||
const error = err as Error | ||
const errorKlassName = getKlassName(error) | ||
return { | ||
code: 'function-call-error', | ||
message: `Error calling function: \n${errorKlassName} ${error.message}`, | ||
} | ||
}, | ||
functionRequiresStaticArguments: (name: string) => ({ | ||
code: 'function-requires-static-arguments', | ||
message: `Function '${name}' can only receive literal values as arguments`, | ||
}), | ||
functionRequiresInterpolation: (name: string) => ({ | ||
code: 'function-requires-interpolation', | ||
message: `Function '${name}' cannot be used inside a logic block. It must be directly interpolated into the query`, | ||
}), | ||
functionDisallowsInterpolation: (name: string) => ({ | ||
code: 'function-disallows-interpolation', | ||
message: `Function '${name}' cannot be directly interpolated into the query`, | ||
}), | ||
invalidFunctionResultInterpolation: { | ||
@@ -118,0 +169,0 @@ code: 'invalid-function-result-interpolation', |
@@ -1,2 +0,7 @@ | ||
import { Compiler, ConfigFn, ResolveFn, type SupportedMethod } from './compiler' | ||
import { Compiler } from './compiler' | ||
import type { | ||
QueryMetadata, | ||
ResolveFn, | ||
SupportedMethod, | ||
} from './compiler/types' | ||
@@ -6,21 +11,33 @@ export type CompileParams = { | ||
resolveFn: ResolveFn | ||
configFn: ConfigFn | ||
supportedMethods?: Record<string, SupportedMethod> | ||
} | ||
export default function compile({ | ||
export function compile({ | ||
query, | ||
supportedMethods, | ||
resolveFn, | ||
configFn, | ||
}: CompileParams): Promise<string> { | ||
return new Compiler({ | ||
query, | ||
supportedMethods, | ||
sql: query, | ||
supportedMethods: supportedMethods || {}, | ||
resolveFn, | ||
configFn, | ||
}).compile() | ||
}).compileSQL() | ||
} | ||
export function readMetadata({ | ||
query, | ||
supportedMethods, | ||
}: { | ||
query: string | ||
supportedMethods?: Record<string, SupportedMethod> | ||
}): Promise<QueryMetadata> { | ||
return new Compiler({ | ||
sql: query, | ||
supportedMethods: supportedMethods || {}, | ||
resolveFn: () => Promise.resolve(''), | ||
}).readMetadata() | ||
} | ||
export { default as CompileError } from './error/error' | ||
export { type SupportedMethod } from './compiler' | ||
export * from './compiler/types' | ||
export * from './compiler/utils' |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
No README
QualityPackage does not have a README. This may indicate a failed publish or a low quality package.
Found 1 instance in 1 package
375453
124
6337
1
147
8