diff --git a/packages/build/src/containers/container.ts b/packages/build/src/containers/container.ts index 219b0073..67730cb2 100644 --- a/packages/build/src/containers/container.ts +++ b/packages/build/src/containers/container.ts @@ -10,9 +10,9 @@ export class Container { return this.inversifyContainer.get(type); } - public load(options: IBuildOptions) { + public async load(options: IBuildOptions) { const coreContainer = new CoreContainer(); - coreContainer.load(options); + await coreContainer.load(options); this.inversifyContainer.parent = coreContainer.getInversifyContainer(); this.inversifyContainer.load(schemaParserModule(options.schemaParser)); } diff --git a/packages/build/src/lib/schema-parser/middleware/addMissingErrors.ts b/packages/build/src/lib/schema-parser/middleware/addMissingErrors.ts index 41581760..60a32a80 100644 --- a/packages/build/src/lib/schema-parser/middleware/addMissingErrors.ts +++ b/packages/build/src/lib/schema-parser/middleware/addMissingErrors.ts @@ -1,6 +1,12 @@ import { AllTemplateMetadata, APISchema } from '@vulcan/core'; import { SchemaParserMiddleware } from './middleware'; +interface ErrorCode { + code: string; + lineNo: number; + columnNo: number; +} + // Add error code to definition if it is used in query but not defined in schema export const addMissingErrors = (allMetadata: AllTemplateMetadata): SchemaParserMiddleware => @@ -10,9 +16,10 @@ export const addMissingErrors = const templateName = transformedSchemas.templateSource; const metadata = allMetadata[templateName]; // Skip validation if no metadata found - if (!metadata?.errors) return; + if (!metadata?.['error.vulcan.com']) return; - metadata.errors.forEach((error) => { + const errorCodes: ErrorCode[] = metadata['error.vulcan.com'].errorCodes; + errorCodes.forEach((error) => { if (!transformedSchemas.errors.some((e) => e.code === error.code)) { transformedSchemas.errors.push({ code: error.code, diff --git a/packages/build/src/lib/schema-parser/middleware/checkParameter.ts b/packages/build/src/lib/schema-parser/middleware/checkParameter.ts index 288deff0..d0a55b2a 100644 --- a/packages/build/src/lib/schema-parser/middleware/checkParameter.ts +++ b/packages/build/src/lib/schema-parser/middleware/checkParameter.ts @@ -1,6 +1,12 @@ import { AllTemplateMetadata, APISchema } from '@vulcan/core'; import { SchemaParserMiddleware } from './middleware'; +interface Parameter { + name: string; + lineNo: number; + columnNo: number; +} + export const checkParameter = (allMetadata: AllTemplateMetadata): SchemaParserMiddleware => async (schemas, next) => { @@ -9,9 +15,9 @@ export const checkParameter = const templateName = transformedSchemas.templateSource; const metadata = allMetadata[templateName]; // Skip validation if no metadata found - if (!metadata?.parameters) return; + if (!metadata?.['parameter.vulcan.com']) return; - const parameters = metadata.parameters; + const parameters: Parameter[] = metadata['parameter.vulcan.com']; parameters.forEach((parameter) => { // We only check the first value of nested parameters const name = parameter.name.split('.')[0]; diff --git a/packages/build/src/lib/vulcanBuilder.ts b/packages/build/src/lib/vulcanBuilder.ts index 1972de4d..b395f830 100644 --- a/packages/build/src/lib/vulcanBuilder.ts +++ b/packages/build/src/lib/vulcanBuilder.ts @@ -10,7 +10,7 @@ import { export class VulcanBuilder { public async build(options: IBuildOptions) { const container = new Container(); - container.load(options); + await container.load(options); const schemaParser = container.get(TYPES.SchemaParser); const templateEngine = container.get( CORE_TYPES.TemplateEngine diff --git a/packages/build/test/schema-parser/middleware/addMissingErrors.spec.ts b/packages/build/test/schema-parser/middleware/addMissingErrors.spec.ts index 295ccdee..39eb2815 100644 --- a/packages/build/test/schema-parser/middleware/addMissingErrors.spec.ts +++ b/packages/build/test/schema-parser/middleware/addMissingErrors.spec.ts @@ -12,18 +12,19 @@ it('Should add missing error codes', async () => { }; const metadata: AllTemplateMetadata = { 'some-name': { - parameters: [], - errors: [ - { - code: 'ERROR 1', - locations: [ - { - lineNo: 0, - columnNo: 0, - }, - ], - }, - ], + 'error.vulcan.com': { + errorCodes: [ + { + code: 'ERROR 1', + locations: [ + { + lineNo: 0, + columnNo: 0, + }, + ], + }, + ], + }, }, }; // Act @@ -48,17 +49,19 @@ it('Existed error codes should be kept', async () => { const metadata: AllTemplateMetadata = { 'some-name': { parameters: [], - errors: [ - { - code: 'ERROR 1', - locations: [ - { - lineNo: 0, - columnNo: 0, - }, - ], - }, - ], + 'error.vulcan.com': { + errorCodes: [ + { + code: 'ERROR 1', + locations: [ + { + lineNo: 0, + columnNo: 0, + }, + ], + }, + ], + }, }, }; // Act @@ -82,7 +85,7 @@ it('Should tolerate empty error data', async () => { const metadata: object = { 'some-name': { parameters: [], - errors: null, + 'error.vulcan.com': null, }, }; // Act diff --git a/packages/build/test/schema-parser/middleware/checkParameter.spec.ts b/packages/build/test/schema-parser/middleware/checkParameter.spec.ts index 470713ac..78f40888 100644 --- a/packages/build/test/schema-parser/middleware/checkParameter.spec.ts +++ b/packages/build/test/schema-parser/middleware/checkParameter.spec.ts @@ -50,7 +50,7 @@ it(`Should throw when any parameter hasn't be defined`, async () => { }; const metadata: AllTemplateMetadata = { 'some-name': { - parameters: [ + 'parameter.vulcan.com': [ { name: 'param1', locations: [], @@ -81,7 +81,7 @@ it('Should tolerate empty parameter data', async () => { }; const metadata: object = { 'some-name': { - parameters: null, + 'parameter.vulcan.com': null, errors: [], }, }; diff --git a/packages/core/src/containers/container.ts b/packages/core/src/containers/container.ts index e464c1d5..7811c811 100644 --- a/packages/core/src/containers/container.ts +++ b/packages/core/src/containers/container.ts @@ -14,10 +14,12 @@ export class Container { return this.inversifyContainer.get(type); } - public load(options: ICoreOptions) { + public async load(options: ICoreOptions) { this.inversifyContainer.load(artifactBuilderModule(options.artifact)); this.inversifyContainer.load(executorModule()); - this.inversifyContainer.load(templateEngineModule(options.template)); + await this.inversifyContainer.loadAsync( + templateEngineModule(options.template) + ); this.inversifyContainer.load(validatorModule()); } diff --git a/packages/core/src/containers/modules/executor.ts b/packages/core/src/containers/modules/executor.ts index 0d0dfe90..6e5492dd 100644 --- a/packages/core/src/containers/modules/executor.ts +++ b/packages/core/src/containers/modules/executor.ts @@ -1,13 +1,25 @@ import { ContainerModule } from 'inversify'; -import { Executor } from '@vulcan/core/template-engine'; +// TODO: Should replace with a real implementation +import { + QueryBuilder, + Executor, +} from '../../lib/template-engine/built-in-extensions/query-builder/reqTagRunner'; import { TYPES } from '../types'; +class MockBuilder implements QueryBuilder { + public count() { + return this; + } + + public async value() { + return []; + } +} + export const executorModule = () => new ContainerModule((bind) => { bind(TYPES.Executor).toConstantValue({ // TODO: Mock value - executeQuery: async () => { - return []; - }, + createBuilder: async () => new MockBuilder(), }); }); diff --git a/packages/core/src/containers/modules/templateEngine.ts b/packages/core/src/containers/modules/templateEngine.ts index d34871e9..18af5dfe 100644 --- a/packages/core/src/containers/modules/templateEngine.ts +++ b/packages/core/src/containers/modules/templateEngine.ts @@ -4,23 +4,21 @@ import { TemplateProviderType, } from '@vulcan/core/models'; import { - ErrorExtension, FileTemplateProvider, - NunjucksCompilerExtension, - ReqExtension, TemplateProvider, - UniqueExtension, InMemoryCodeLoader, NunjucksCompiler, Compiler, TemplateEngine, } from '@vulcan/core/template-engine'; -import { ContainerModule, interfaces } from 'inversify'; +import { AsyncContainerModule, interfaces } from 'inversify'; import { TemplateEngineOptions } from '../../options'; import * as nunjucks from 'nunjucks'; +// TODO: fix the path +import { bindExtensions } from '@vulcan/core/template-engine/extension-loader'; export const templateEngineModule = (options: ITemplateEngineOptions) => - new ContainerModule((bind) => { + new AsyncContainerModule(async (bind) => { // Options bind( TYPES.TemplateEngineInputOptions @@ -39,16 +37,23 @@ export const templateEngineModule = (options: ITemplateEngineOptions) => TYPES.Factory_TemplateProvider ).toAutoNamedFactory(TYPES.TemplateProvider); - // Extensions - bind(TYPES.CompilerExtension) - .to(UniqueExtension) - .inSingletonScope(); - bind(TYPES.CompilerExtension) - .to(ErrorExtension) - .inSingletonScope(); - bind(TYPES.CompilerExtension) - .to(ReqExtension) - .inSingletonScope(); + // Compiler environment + bind(TYPES.CompilerEnvironment) + .toDynamicValue((context) => { + // We only need loader in runtime + const loader = context.container.get( + TYPES.CompilerLoader + ); + return new nunjucks.Environment(loader); + }) + .inSingletonScope() + .whenTargetNamed('runtime'); + bind(TYPES.CompilerEnvironment) + .toDynamicValue(() => { + return new nunjucks.Environment(); + }) + .inSingletonScope() + .whenTargetNamed('compileTime'); // Loader bind(TYPES.CompilerLoader) @@ -62,4 +67,7 @@ export const templateEngineModule = (options: ITemplateEngineOptions) => bind(TYPES.TemplateEngine) .to(TemplateEngine) .inSingletonScope(); + + // Load Extensions + await bindExtensions(bind); }); diff --git a/packages/core/src/containers/types.ts b/packages/core/src/containers/types.ts index 492f581b..22ae4086 100644 --- a/packages/core/src/containers/types.ts +++ b/packages/core/src/containers/types.ts @@ -12,6 +12,7 @@ export const TYPES = { Factory_TemplateProvider: Symbol.for('Factory_TemplateProvider'), CompilerExtension: Symbol.for('CompilerExtension'), CompilerLoader: Symbol.for('CompilerLoader'), + CompilerEnvironment: Symbol.for('CompilerEnvironment'), Compiler: Symbol.for('Compiler'), TemplateEngine: Symbol.for('TemplateEngine'), TemplateEngineOptions: Symbol.for('TemplateEngineOptions'), diff --git a/packages/core/src/lib/template-engine/built-in-extensions/custom-error/constants.ts b/packages/core/src/lib/template-engine/built-in-extensions/custom-error/constants.ts new file mode 100644 index 00000000..e828b664 --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/custom-error/constants.ts @@ -0,0 +1 @@ +export const METADATA_NAME = 'error.vulcan.com'; diff --git a/packages/core/src/lib/template-engine/built-in-extensions/custom-error/errorTagBuilder.ts b/packages/core/src/lib/template-engine/built-in-extensions/custom-error/errorTagBuilder.ts new file mode 100644 index 00000000..8330d868 --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/custom-error/errorTagBuilder.ts @@ -0,0 +1,73 @@ +import { + OnAstVisit, + ProvideMetadata, + TagBuilder, +} from '../../extension-loader'; +import * as nunjucks from 'nunjucks'; +import { chain } from 'lodash'; +import { METADATA_NAME } from './constants'; + +interface ErrorCode { + code: string; + lineNo: number; + columnNo: number; +} + +export class ErrorTagBuilder + extends TagBuilder + implements OnAstVisit, ProvideMetadata +{ + public tags = ['error']; + private errorCodes: ErrorCode[] = []; + public metadataName = METADATA_NAME; + + public parse(parser: nunjucks.parser.Parser, nodes: typeof nunjucks.nodes) { + // get the tag token + const token = parser.nextToken(); + + const errorMessage = parser.parseSignature(null, true); + parser.advanceAfterBlockEnd(token.value); + + // Add some fake nodes to the AST to indicate error position + errorMessage.addChild( + new nodes.Literal(token.lineno, token.colno, token.lineno) + ); + errorMessage.addChild( + new nodes.Literal(token.lineno, token.colno, token.colno) + ); + + return this.createAsyncExtensionNode(errorMessage, []); + } + + public onVisit(node: nunjucks.nodes.Node) { + if (node instanceof nunjucks.nodes.CallExtension) { + if (node.extName !== this.getName()) return; + + const errorCodeNode = node.args.children[0]; + if (!(errorCodeNode instanceof nunjucks.nodes.Literal)) + throw new Error(`Expected literal, got ${errorCodeNode.typename}`); + + this.errorCodes.push({ + code: errorCodeNode.value, + lineNo: errorCodeNode.lineno, + columnNo: errorCodeNode.colno, + }); + } + } + + public getMetadata() { + return { + errorCodes: chain(this.errorCodes) + .groupBy('code') + .values() + .map((errorCodes) => ({ + code: errorCodes[0].code, + locations: errorCodes.map((errorCode) => ({ + lineNo: errorCode.lineNo, + columnNo: errorCode.columnNo, + })), + })) + .value(), + }; + } +} diff --git a/packages/core/src/lib/template-engine/built-in-extensions/custom-error/errorTagRunner.ts b/packages/core/src/lib/template-engine/built-in-extensions/custom-error/errorTagRunner.ts new file mode 100644 index 00000000..e11f9913 --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/custom-error/errorTagRunner.ts @@ -0,0 +1,12 @@ +import { TagRunner, TagRunnerOptions } from '../../extension-loader'; + +export class ErrorTagRunner extends TagRunner { + public tags = ['error']; + + public async run({ args }: TagRunnerOptions) { + const message = args[0]; + const lineno = args[1]; + const colno = args[2]; + throw new Error(`${message} at ${lineno}:${colno}`); + } +} diff --git a/packages/core/src/lib/template-engine/built-in-extensions/custom-error/index.ts b/packages/core/src/lib/template-engine/built-in-extensions/custom-error/index.ts new file mode 100644 index 00000000..99ee5b51 --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/custom-error/index.ts @@ -0,0 +1,4 @@ +import { ErrorTagBuilder } from './errorTagBuilder'; +import { ErrorTagRunner } from './errorTagRunner'; + +export default [ErrorTagBuilder, ErrorTagRunner]; diff --git a/packages/core/src/lib/template-engine/built-in-extensions/index.ts b/packages/core/src/lib/template-engine/built-in-extensions/index.ts new file mode 100644 index 00000000..1449f292 --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/index.ts @@ -0,0 +1,4 @@ +import './query-builder'; +import './custom-error'; +import './sql-helper'; +import './validator'; diff --git a/packages/core/src/lib/template-engine/built-in-extensions/query-builder/constants.ts b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/constants.ts new file mode 100644 index 00000000..b6939c9a --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/constants.ts @@ -0,0 +1,5 @@ +export const FINIAL_BUILDER_NAME = 'FINAL_BUILDER'; +export const METADATA_NAME = 'builder.vulcan.com'; +export const EXECUTE_COMMAND_NAME = 'value'; +export const EXECUTE_FILTER_NAME = 'execute'; +export const REFERENCE_SEARCH_MAX_DEPTH = 100; diff --git a/packages/core/src/lib/template-engine/built-in-extensions/query-builder/executeBuilder.ts b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/executeBuilder.ts new file mode 100644 index 00000000..a05f0877 --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/executeBuilder.ts @@ -0,0 +1,6 @@ +import { FilterBuilder } from '../../extension-loader'; +import { EXECUTE_FILTER_NAME } from './constants'; + +export class ExecutorBuilder extends FilterBuilder { + public filterName = EXECUTE_FILTER_NAME; +} diff --git a/packages/core/src/lib/template-engine/built-in-extensions/query-builder/executorRunner.ts b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/executorRunner.ts new file mode 100644 index 00000000..16e621ae --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/executorRunner.ts @@ -0,0 +1,12 @@ +import { FilterRunner } from '../../extension-loader'; +import { EXECUTE_FILTER_NAME } from './constants'; +import { QueryBuilder } from './reqTagRunner'; + +export class ExecutorRunner extends FilterRunner { + public filterName = EXECUTE_FILTER_NAME; + + public async transform({ value }: { value: any; args: any[] }): Promise { + const builder: QueryBuilder = value; + return builder.value(); + } +} diff --git a/packages/core/src/lib/template-engine/built-in-extensions/query-builder/index.ts b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/index.ts new file mode 100644 index 00000000..7c02c185 --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/index.ts @@ -0,0 +1,6 @@ +import { ReqTagBuilder } from './reqTagBuilder'; +import { ReqTagRunner } from './reqTagRunner'; +import { ExecutorRunner } from './executorRunner'; +import { ExecutorBuilder } from './executeBuilder'; + +export default [ReqTagBuilder, ReqTagRunner, ExecutorRunner, ExecutorBuilder]; diff --git a/packages/core/src/lib/template-engine/built-in-extensions/query-builder/reqTagBuilder.ts b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/reqTagBuilder.ts new file mode 100644 index 00000000..e26c8645 --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/reqTagBuilder.ts @@ -0,0 +1,216 @@ +import { + OnAstVisit, + ProvideMetadata, + ReplaceChildFunc, + TagBuilder, + visitChildren, +} from '../../extension-loader'; +import * as nunjucks from 'nunjucks'; +import { + EXECUTE_COMMAND_NAME, + EXECUTE_FILTER_NAME, + FINIAL_BUILDER_NAME, + METADATA_NAME, + REFERENCE_SEARCH_MAX_DEPTH, +} from './constants'; + +interface DeclarationLocation { + lineNo: number; + colNo: number; +} + +export class ReqTagBuilder + extends TagBuilder + implements OnAstVisit, ProvideMetadata +{ + public tags = ['req']; + public metadataName = METADATA_NAME; + private root?: nunjucks.nodes.Root; + private hasMainBuilder = false; + private variableList = new Map(); + + public parse( + parser: nunjucks.parser.Parser, + nodes: typeof nunjucks.nodes, + lexer: typeof nunjucks.lexer + ) { + // {% req var (main) %} body {% endreq %} + // consume req tag + const reqToken = parser.nextToken(); + // variable + let nextToken = parser.peekToken(); + if (nextToken.type === lexer.TOKEN_BLOCK_END) { + // {% req %} + parser.fail(`Expected a variable`, nextToken.lineno, nextToken.colno); + } + if (nextToken.type !== lexer.TOKEN_SYMBOL) { + parser.fail( + `Expected a symbol, but got ${nextToken.type}`, + nextToken.lineno, + nextToken.colno + ); + } + const variable = parser.parseExpression(); + + // main denotation + nextToken = parser.peekToken(); + let mainBuilder = false; + if (nextToken.type !== lexer.TOKEN_BLOCK_END) { + if (nextToken.type !== lexer.TOKEN_SYMBOL || nextToken.value !== 'main') { + parser.fail( + `Expected a symbol "main"`, + nextToken.lineno, + nextToken.colno + ); + } + mainBuilder = true; + // Consume this token (main) + parser.nextToken(); + } + + const endToken = parser.nextToken(); + if (endToken.type !== lexer.TOKEN_BLOCK_END) { + parser.fail( + `Expected a block end, but got ${endToken.type}`, + endToken.lineno, + endToken.colno + ); + } + + const requestQuery = parser.parseUntilBlocks('endreq'); + parser.advanceAfterBlockEnd(); + + const argsNodeToPass = new nodes.NodeList(reqToken.lineno, reqToken.colno); + // variable name + argsNodeToPass.addChild( + new nodes.Literal( + variable.lineno, + variable.colno, + (variable as nunjucks.nodes.Symbol).value + ) + ); + // is main builder + argsNodeToPass.addChild( + new nodes.Literal(variable.lineNo, variable.colno, String(mainBuilder)) + ); + + return this.createAsyncExtensionNode(argsNodeToPass, [requestQuery]); + } + + public onVisit(node: nunjucks.nodes.Node) { + // save the root + if (node instanceof nunjucks.nodes.Root) { + this.root = node; + } else if ( + node instanceof nunjucks.nodes.CallExtensionAsync && + node.extName === this.getName() + ) { + this.checkBuilder(node); + this.checkMainBuilder(node); + } else { + visitChildren(node, this.replaceExecuteFunction.bind(this)); + } + } + + public finish() { + if (!this.hasMainBuilder) { + this.wrapOutputWithBuilder(); + } + } + + public getMetadata() { + return { + finalBuilderName: FINIAL_BUILDER_NAME, + }; + } + + private checkBuilder(node: nunjucks.nodes.CallExtensionAsync) { + const variable = node.args.children[0] as nunjucks.nodes.Literal; + if (this.variableList.has(variable.value)) { + const previousDeclaration = this.variableList.get(variable.value); + throw new Error( + `We can't declare multiple builder with same name. Duplicated name: ${variable.value} (declared at ${previousDeclaration?.lineNo}:${previousDeclaration?.colNo} and ${variable.lineno}:${variable.colno})` + ); + } + this.variableList.set(variable.value, { + lineNo: variable.lineno, + colNo: variable.colno, + }); + } + + private checkMainBuilder(node: nunjucks.nodes.CallExtensionAsync) { + const isMainBuilder = + node.extName === this.getName() && + (node.args.children[1] as nunjucks.nodes.Literal).value === 'true'; + if (!isMainBuilder) return; + + if (this.hasMainBuilder) { + throw new Error(`Only one main builder is allowed.`); + } + this.hasMainBuilder = true; + } + + private wrapOutputWithBuilder() { + if (!this.root) { + throw new Error('No root node found.'); + } + const originalChildren = this.root.children; + const args = new nunjucks.nodes.NodeList(0, 0); + // variable name + args.addChild(new nunjucks.nodes.Literal(0, 0, '__wrapped__builder')); + // is main builder + args.addChild(new nunjucks.nodes.Literal(0, 0, 'true')); + const builder = this.createAsyncExtensionNode(args, originalChildren); + this.root.children = [builder]; + } + + private replaceExecuteFunction( + node: nunjucks.nodes.Node, + replace: ReplaceChildFunc + ) { + if ( + node instanceof nunjucks.nodes.FunCall && + node.name instanceof nunjucks.nodes.LookupVal && + node.name.val.value === EXECUTE_COMMAND_NAME + ) { + let targetNode: typeof node.name.target | null = node.name.target; + let depth = 0; + while (targetNode) { + depth++; + if (depth > REFERENCE_SEARCH_MAX_DEPTH) { + throw new Error('Max depth reached'); + } + if (targetNode instanceof nunjucks.nodes.LookupVal) { + targetNode = targetNode.target; + } else if (targetNode instanceof nunjucks.nodes.FunCall) { + targetNode = targetNode.name; + } else if (targetNode instanceof nunjucks.nodes.Symbol) { + break; + } else { + throw new Error( + `Unexpected node type: ${ + (targetNode as nunjucks.nodes.Node)?.typename + }` + ); + } + } + + // If the target node is a variable from {% req xxx %}, replace it with execute filter + if (this.variableList.has((targetNode as nunjucks.nodes.Symbol).value)) { + const args = new nunjucks.nodes.NodeList(node.lineno, node.colno); + args.addChild(node.name.target); + const filter = new nunjucks.nodes.Filter( + node.lineno, + node.colno, + new nunjucks.nodes.Symbol( + node.lineno, + node.colno, + EXECUTE_FILTER_NAME + ), + args + ); + replace(filter); + } + } + } +} diff --git a/packages/core/src/lib/template-engine/built-in-extensions/query-builder/reqTagRunner.ts b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/reqTagRunner.ts new file mode 100644 index 00000000..49c3d4ee --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/query-builder/reqTagRunner.ts @@ -0,0 +1,43 @@ +import { TYPES } from '@vulcan/core/containers'; +import { inject } from 'inversify'; +import { TagRunnerOptions, TagRunner } from '../../extension-loader'; +import { FINIAL_BUILDER_NAME } from './constants'; + +// TODO: temporary interface +export interface QueryBuilder { + count(): QueryBuilder; + value(): Promise; +} + +export interface Executor { + createBuilder(query: string): Promise; +} + +export class ReqTagRunner extends TagRunner { + public tags = ['req']; + private executor: Executor; + + constructor(@inject(TYPES.Executor) executor: Executor) { + super(); + this.executor = executor; + } + + public async run({ context, args, contentArgs }: TagRunnerOptions) { + const name = args[0]; + let query = ''; + for (let index = 0; index < contentArgs.length; index++) { + query += await contentArgs[index](); + } + query = query + .split(/\r?\n/) + .filter((line) => line.trim().length > 0) + .join('\n'); + const builder = await this.executor.createBuilder(query); + context.setVariable(name, builder); + + if (args[1] === 'true') { + context.setVariable(FINIAL_BUILDER_NAME, builder); + context.addExport(FINIAL_BUILDER_NAME); + } + } +} diff --git a/packages/core/src/lib/template-engine/built-in-extensions/sql-helper/index.ts b/packages/core/src/lib/template-engine/built-in-extensions/sql-helper/index.ts new file mode 100644 index 00000000..8c9b3d76 --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/sql-helper/index.ts @@ -0,0 +1,4 @@ +import { UniqueFilterBuilder } from './uniqueFilterBuilder'; +import { UniqueFilterRunner } from './uniqueFilterRunner'; + +export default [UniqueFilterBuilder, UniqueFilterRunner]; diff --git a/packages/core/src/lib/template-engine/built-in-extensions/sql-helper/uniqueFilterBuilder.ts b/packages/core/src/lib/template-engine/built-in-extensions/sql-helper/uniqueFilterBuilder.ts new file mode 100644 index 00000000..9af90c8b --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/sql-helper/uniqueFilterBuilder.ts @@ -0,0 +1,5 @@ +import { FilterBuilder } from '../../extension-loader'; + +export class UniqueFilterBuilder extends FilterBuilder { + public filterName = 'unique'; +} diff --git a/packages/core/src/lib/template-engine/extensions/filters/unique.ts b/packages/core/src/lib/template-engine/built-in-extensions/sql-helper/uniqueFilterRunner.ts similarity index 63% rename from packages/core/src/lib/template-engine/extensions/filters/unique.ts rename to packages/core/src/lib/template-engine/built-in-extensions/sql-helper/uniqueFilterRunner.ts index 06637b43..49a2ee7a 100644 --- a/packages/core/src/lib/template-engine/extensions/filters/unique.ts +++ b/packages/core/src/lib/template-engine/built-in-extensions/sql-helper/uniqueFilterRunner.ts @@ -1,10 +1,8 @@ -import { NunjucksFilterExtension } from '../extension'; import { uniq, uniqBy } from 'lodash'; -import { injectable } from 'inversify'; +import { FilterRunner } from '../../extension-loader'; -@injectable() -export class UniqueExtension implements NunjucksFilterExtension { - public name = 'unique'; +export class UniqueFilterRunner extends FilterRunner { + public filterName = 'unique'; public async transform({ value, args, diff --git a/packages/core/src/lib/template-engine/built-in-extensions/validator/constants.ts b/packages/core/src/lib/template-engine/built-in-extensions/validator/constants.ts new file mode 100644 index 00000000..13b87ab7 --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/validator/constants.ts @@ -0,0 +1,4 @@ +export const PARAMETER_METADATA_NAME = 'parameter.vulcan.com'; +export const FILTER_METADATA_NAME = 'filter.vulcan.com'; +export const REFERENCE_SEARCH_MAX_DEPTH = 100; +export const LOOK_UP_PARAMETER = 'params'; diff --git a/packages/core/src/lib/template-engine/built-in-extensions/validator/filterChecker.ts b/packages/core/src/lib/template-engine/built-in-extensions/validator/filterChecker.ts new file mode 100644 index 00000000..e3d64030 --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/validator/filterChecker.ts @@ -0,0 +1,45 @@ +import { TYPES } from '@vulcan/core/containers'; +import { inject, named } from 'inversify'; +import { + CompileTimeExtension, + OnAstVisit, + ProvideMetadata, +} from '../../extension-loader'; +import { FILTER_METADATA_NAME } from './constants'; +import * as nunjucks from 'nunjucks'; + +export class FilterChecker + extends CompileTimeExtension + implements OnAstVisit, ProvideMetadata +{ + public metadataName = FILTER_METADATA_NAME; + private env: nunjucks.Environment; + private filters = new Set(); + + constructor( + @inject(TYPES.CompilerEnvironment) + @named('compileTime') + compileTimeEnv: nunjucks.Environment + ) { + super(); + this.env = compileTimeEnv; + } + + public onVisit(node: nunjucks.nodes.Node) { + if (node instanceof nunjucks.nodes.Filter) { + if ( + node.name instanceof nunjucks.nodes.Symbol || + node.name instanceof nunjucks.nodes.Literal + ) { + // If the node is a filter and has a expected name node, we check whether the filter is loaded. + // If the filter is not loaded, getFilter function will throw an error. + this.env.getFilter(node.name.value); + this.filters.add(node.name.value); + } + } + } + + public getMetadata() { + return Array.from(this.filters); + } +} diff --git a/packages/core/src/lib/template-engine/built-in-extensions/validator/index.ts b/packages/core/src/lib/template-engine/built-in-extensions/validator/index.ts new file mode 100644 index 00000000..18a8056a --- /dev/null +++ b/packages/core/src/lib/template-engine/built-in-extensions/validator/index.ts @@ -0,0 +1,4 @@ +import { ParametersChecker } from './parametersChecker'; +import { FilterChecker } from './filterChecker'; + +export default [ParametersChecker, FilterChecker]; diff --git a/packages/core/src/lib/template-engine/visitors/parametersVisitor.ts b/packages/core/src/lib/template-engine/built-in-extensions/validator/parametersChecker.ts similarity index 60% rename from packages/core/src/lib/template-engine/visitors/parametersVisitor.ts rename to packages/core/src/lib/template-engine/built-in-extensions/validator/parametersChecker.ts index 00ac24b3..9e818b56 100644 --- a/packages/core/src/lib/template-engine/visitors/parametersVisitor.ts +++ b/packages/core/src/lib/template-engine/built-in-extensions/validator/parametersChecker.ts @@ -1,9 +1,15 @@ import { chain } from 'lodash'; import * as nunjucks from 'nunjucks'; -import { TemplateParameterMetadata } from '../compiler'; -import { Visitor } from './visitor'; - -const MAX_DEPTH = 100; +import { + CompileTimeExtension, + OnAstVisit, + ProvideMetadata, +} from '../../extension-loader'; +import { + LOOK_UP_PARAMETER, + PARAMETER_METADATA_NAME, + REFERENCE_SEARCH_MAX_DEPTH, +} from './constants'; interface Parameter { name: string; @@ -11,32 +17,30 @@ interface Parameter { columnNo: number; } -export class ParametersVisitor implements Visitor { +export class ParametersChecker + extends CompileTimeExtension + implements OnAstVisit, ProvideMetadata +{ + public metadataName = PARAMETER_METADATA_NAME; private parameters: Parameter[] = []; - private lookupParameter: string; - - constructor({ - lookupParameter = 'params', - }: { lookupParameter?: string } = {}) { - this.lookupParameter = lookupParameter; - } - public visit(node: nunjucks.nodes.Node) { + public onVisit(node: nunjucks.nodes.Node): void { if (node instanceof nunjucks.nodes.LookupVal) { let name = node.val.value; - let parent: nunjucks.nodes.LookupVal | nunjucks.nodes.Symbol | null = - node.target; + let parent: typeof node.target | null = node.target; let depth = 0; while (parent) { depth++; - if (depth > MAX_DEPTH) { + if (depth > REFERENCE_SEARCH_MAX_DEPTH) { throw new Error('Max depth reached'); } if (parent instanceof nunjucks.nodes.LookupVal) { name = parent.val.value + '.' + name; parent = parent.target; + } else if (parent instanceof nunjucks.nodes.FunCall) { + parent = parent.name; } else { - if (parent.value === this.lookupParameter) { + if (parent.value === LOOK_UP_PARAMETER) { this.parameters.push({ name, lineNo: node.lineno, @@ -49,7 +53,7 @@ export class ParametersVisitor implements Visitor { } } - public getParameters(): TemplateParameterMetadata[] { + public getMetadata() { return chain(this.parameters) .groupBy('name') .values() diff --git a/packages/core/src/lib/template-engine/compiler.ts b/packages/core/src/lib/template-engine/compiler.ts index 60031182..1f79bdc8 100644 --- a/packages/core/src/lib/template-engine/compiler.ts +++ b/packages/core/src/lib/template-engine/compiler.ts @@ -13,11 +13,7 @@ export interface TemplateParameterMetadata { locations: TemplateLocation[]; } -export interface TemplateMetadata { - parameters: TemplateParameterMetadata[]; - errors: TemplateErrorMetadata[]; -} - +export type TemplateMetadata = Record; export interface CompileResult { compiledData: string; metadata: TemplateMetadata; @@ -30,5 +26,5 @@ export interface Compiler { * @param template The path or identifier of a template source */ compile(template: string): CompileResult; - render(templateName: string, data: T): Promise; + execute(template: string, data: T): Promise; } diff --git a/packages/core/src/lib/template-engine/extension-loader/helpers.ts b/packages/core/src/lib/template-engine/extension-loader/helpers.ts new file mode 100644 index 00000000..ad78e1f1 --- /dev/null +++ b/packages/core/src/lib/template-engine/extension-loader/helpers.ts @@ -0,0 +1,104 @@ +import * as nunjucks from 'nunjucks'; +import { OnAstVisit, ProvideMetadata } from './models'; + +export const generateMetadata = (providers: ProvideMetadata[]) => { + const metadata = providers.reduce((currentMetadata, provider) => { + currentMetadata[provider.metadataName] = provider.getMetadata(); + return currentMetadata; + }, {} as Record); + return metadata; +}; + +export const walkAst = ( + root: nunjucks.nodes.Node, + visitors: OnAstVisit[] +): void => { + visitors.forEach((visitor) => visitor.onVisit(root)); + visitChildren(root, (node) => walkAst(node, visitors)); + if (root instanceof nunjucks.nodes.Root) { + visitors.forEach((visitor) => visitor.finish?.()); + } +}; + +export type VisitChildCallback = ( + node: nunjucks.nodes.Node, + replaceFunc: ReplaceChildFunc +) => void; + +export type ReplaceChildFunc = ( + /** Provide the node you want to replace, or null if you want to delete this child */ + replaceNode: + | nunjucks.nodes.NodeList + | nunjucks.nodes.CallExtension + | nunjucks.nodes.Node + | null +) => void; + +export const visitChildren = ( + root: nunjucks.nodes.Node, + callBack: VisitChildCallback +) => { + if (root instanceof nunjucks.nodes.NodeList) { + const indexToRemove: number[] = []; + root.children.forEach((node, index) => { + callBack(node, (replaced) => { + if (replaced) { + root.children[index] = replaced; + } else { + indexToRemove.push(index); + } + }); + }); + // Must delete in reverse order + for (let index = indexToRemove.length - 1; index >= 0; index--) { + root.children.splice(indexToRemove[index], 1); + } + } else if (root instanceof nunjucks.nodes.CallExtension) { + if (root.args) { + callBack(root.args, (replaced) => { + if (!replaced) { + root.args = new nunjucks.nodes.NodeList( + root.args.lineno, + root.args.colno + ); + } else if (replaced instanceof nunjucks.nodes.NodeList) { + root.args = replaced; + } else { + root.args = new nunjucks.nodes.NodeList( + root.args.lineno, + root.args.colno + ); + root.args.addChild(replaced); + } + }); + } + if (root.contentArgs) { + const indexToRemove: number[] = []; + root.contentArgs.forEach((node, index) => { + callBack(node, (replaced) => { + if (replaced && root.contentArgs) { + root.contentArgs[index] = replaced; + } else { + indexToRemove.push(index); + } + }); + }); + // Must delete in reverse order + for (let index = indexToRemove.length - 1; index >= 0; index--) { + root.contentArgs.splice(indexToRemove[index], 1); + } + } + } else { + root.iterFields((node, fieldName) => { + if (node instanceof nunjucks.nodes.Node) { + callBack(node, (replaced) => { + if (replaced) { + (root as any)[fieldName] = replaced; + } else { + delete (root as any)[fieldName]; + } + }); + } + }); + } +}; diff --git a/packages/core/src/lib/template-engine/extension-loader/index.ts b/packages/core/src/lib/template-engine/extension-loader/index.ts new file mode 100644 index 00000000..0d0ec7e6 --- /dev/null +++ b/packages/core/src/lib/template-engine/extension-loader/index.ts @@ -0,0 +1,3 @@ +export * from './models'; +export * from './helpers'; +export * from './loader'; diff --git a/packages/core/src/lib/template-engine/extension-loader/loader.ts b/packages/core/src/lib/template-engine/extension-loader/loader.ts new file mode 100644 index 00000000..b81543d3 --- /dev/null +++ b/packages/core/src/lib/template-engine/extension-loader/loader.ts @@ -0,0 +1,31 @@ +// Import built in extensions to ensure TypeScript compiler includes them. +import '../built-in-extensions'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { flatten } from 'lodash'; +import { interfaces } from 'inversify'; +import { TYPES } from '@vulcan/core/containers'; + +export const importExtensions = async (folder: string) => { + const extensions = await import(folder); + return extensions.default || []; +}; + +export const bindExtensions = async (bind: interfaces.Bind) => { + const builtInExtensionNames = ( + await fs.readdir(path.join(__dirname, '..', 'built-in-extensions')) + ).filter((name) => name !== 'index.ts'); + + const extensions = flatten( + await Promise.all( + builtInExtensionNames.map((name) => + importExtensions( + path.join(__dirname, '..', 'built-in-extensions', name) + ) + ) + ) + ); + extensions.forEach((extension) => { + bind(TYPES.CompilerExtension).to(extension).inSingletonScope(); + }); +}; diff --git a/packages/core/src/lib/template-engine/extension-loader/models.ts b/packages/core/src/lib/template-engine/extension-loader/models.ts new file mode 100644 index 00000000..88157a9c --- /dev/null +++ b/packages/core/src/lib/template-engine/extension-loader/models.ts @@ -0,0 +1,146 @@ +import { sortBy } from 'lodash'; +import * as nunjucks from 'nunjucks'; +import { injectable } from 'inversify'; + +export type TagExtensionContentArgGetter = () => Promise; + +export type TagExtensionArgTypes = string | number | boolean; + +export interface TagRunnerOptions { + context: any; + args: TagExtensionArgTypes[]; + contentArgs: TagExtensionContentArgGetter[]; +} + +export type Extension = RuntimeExtension | CompileTimeExtension; + +@injectable() +export abstract class RuntimeExtension {} + +@injectable() +export abstract class CompileTimeExtension {} + +export abstract class TagBuilder extends CompileTimeExtension { + abstract tags: string[]; + abstract parse( + parser: nunjucks.parser.Parser, + nodes: typeof nunjucks.nodes, + lexer: typeof nunjucks.lexer + ): nunjucks.nodes.Node; + + public set __name(_) { + // ignore it + } + + public get __name() { + return this.getName(); + } + + public getName() { + return sortBy(this.tags).join('_'); + } + + protected createAsyncExtensionNode( + /** + * The arguments of this extension, they'll be rendered and passed to run function. + * It usually contains the configuration of the extension, e.g. {% req variable %} The variable name of req extension. + * Note that these arguments will be pass to run function directly: Literal('123') => "123", so adding Output nodes causes compiling issues. Output("123") => t += "123" + */ + argsNodeList: nunjucks.nodes.NodeList, + /** The content (usually the body) of this extension, they'll be passed to run function as render functions + * It usually contains the Output of your extension, e.g. {% req variable %} select * from user {% endreq %}, the "select * from user" should be put in this field. + * Note that these nodes will be rendered as the output of template: Output("123") => t = ""; t += "123", so adding nodes with no output like Symbol, Literal ... might cause compiling issues. Literal('123') => t = ""; 123 + */ + contentNodes: nunjucks.nodes.Node[] = [] + ) { + return new nunjucks.nodes.CallExtensionAsync( + this.getName(), + '__run', + argsNodeList, + contentNodes + ); + } +} + +export abstract class TagRunner extends RuntimeExtension { + abstract tags: string[]; + abstract run(options: TagRunnerOptions): Promise; + + public __run(...originalArgs: any[]) { + const context = originalArgs[0]; + const callback = originalArgs[originalArgs.length - 1]; + const args = originalArgs + .slice(1, originalArgs.length - 1) + .filter((value) => typeof value !== 'function'); + const contentArgs = originalArgs + .slice(1, originalArgs.length - 1) + .filter((value) => typeof value === 'function') + .map((cb) => () => { + return new Promise((resolve, reject) => { + cb((err: any, result: any) => { + if (err) reject(err); + else resolve(result); + }); + }); + }); + + this.run({ context, args, contentArgs }) + .then((result) => callback(null, result)) + .catch((err) => callback(err, null)); + } + + public set __name(_) { + // ignore it + } + + public get __name() { + return this.getName(); + } + + public getName() { + return sortBy(this.tags).join('_'); + } +} + +export abstract class FilterBuilder extends CompileTimeExtension { + abstract filterName: string; +} + +export abstract class FilterRunner extends RuntimeExtension { + abstract filterName: string; + abstract transform(options: { + value: V; + args: Record; + }): Promise; + + public __transform(value: any, ...args: any[]) { + const callback = args[args.length - 1]; + const otherArgs = args.slice(0, args.length - 1); + this.transform({ + value, + args: otherArgs, + }) + .then((res) => callback(null, res)) + .catch((err) => callback(err, null)); + } +} + +export const implementedOnAstVisit = (source: any): source is OnAstVisit => { + return !!source.onVisit; +}; + +export interface OnAstVisit { + onVisit(node: nunjucks.nodes.Node): void; + finish?: () => void; +} + +export const implementedProvideMetadata = ( + source: any +): source is ProvideMetadata => { + return !!source.metadataName && !!source.getMetadata; +}; + +export interface ProvideMetadata { + metadataName: string; + getMetadata(): any; +} diff --git a/packages/core/src/lib/template-engine/extensions/extension.ts b/packages/core/src/lib/template-engine/extensions/extension.ts deleted file mode 100644 index 6c5600bc..00000000 --- a/packages/core/src/lib/template-engine/extensions/extension.ts +++ /dev/null @@ -1,117 +0,0 @@ -import * as nunjucks from 'nunjucks'; - -export type NunjucksCompilerExtension = - | NunjucksTagExtension - | NunjucksFilterExtension; - -export interface NunjucksTagExtensionParseResult { - /** The arguments if this extension, they'll be render to string and passed to run function */ - argsNodeList: nunjucks.nodes.NodeList; - /** The content (usually the body) of this extension, they'll be passed to run function as render functions */ - contentNodes: nunjucks.nodes.Node[]; -} - -export interface NunjucksTagExtensionRunOptions { - context: any; - args: any[]; -} - -class WrapperTagExtension { - // Nunjucks use this value as the name of this extension - public __name: string; - public tags: string[]; - - constructor(private extension: NunjucksTagExtension) { - this.__name = extension.name; - this.tags = extension.tags; - } - - public parse( - parser: nunjucks.parser.Parser, - nodes: typeof nunjucks.nodes, - lexer: typeof nunjucks.lexer - ) { - const { argsNodeList, contentNodes } = this.extension.parse( - parser, - nodes, - lexer - ); - return new nodes.CallExtensionAsync( - this, - '__run', - argsNodeList, - contentNodes - ); - } - - public async __run(...args: any[]) { - const context = args[0]; - // Nunjucks use the pass the callback function for async extension at the last argument - // https://github.com/mozilla/nunjucks/blob/master/nunjucks/src/compiler.js#L256 - const callback = args[args.length - 1]; - const otherArgs = args.slice(1, args.length - 1); - this.extension - .run({ context, args: otherArgs }) - .then((result) => callback(null, result)) - .catch((err) => callback(err, null)); - } -} - -export const NunjucksTagExtensionWrapper = ( - extension: NunjucksTagExtension -) => ({ - name: extension.name, - transform: new WrapperTagExtension(extension), -}); - -export interface NunjucksTagExtension { - name: string; - tags: string[]; - parse( - parser: nunjucks.parser.Parser, - nodes: typeof nunjucks.nodes, - lexer: typeof nunjucks.lexer - ): NunjucksTagExtensionParseResult; - run(options: NunjucksTagExtensionRunOptions): Promise; -} - -export function isTagExtension( - extension: NunjucksCompilerExtension -): extension is NunjucksTagExtension { - return ( - (extension as any).tags !== undefined && - (extension as any).parse !== undefined - ); -} - -export const NunjucksFilterExtensionWrapper = ( - extension: NunjucksFilterExtension -) => { - return { - name: extension.name, - transform: (value: any, ...args: any[]) => { - // Nunjucks use the pass the callback function for async filter at the last argument - // https://github.com/mozilla/nunjucks/blob/master/nunjucks/src/compiler.js#L514 - const callback = args[args.length - 1]; - const otherArgs = args.slice(0, args.length - 1); - extension - .transform({ - value, - args: otherArgs, - }) - .then((res) => callback(null, res)) - .catch((err) => callback(err, null)); - }, - }; -}; - -export interface NunjucksFilterExtension { - name: string; - transform(options: { value: V; args: Record }): Promise; -} - -export function isFilterExtension( - extension: NunjucksCompilerExtension -): extension is NunjucksFilterExtension { - return (extension as any).transform !== undefined; -} diff --git a/packages/core/src/lib/template-engine/extensions/filters/index.ts b/packages/core/src/lib/template-engine/extensions/filters/index.ts deleted file mode 100644 index 4d39d161..00000000 --- a/packages/core/src/lib/template-engine/extensions/filters/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './unique'; diff --git a/packages/core/src/lib/template-engine/extensions/index.ts b/packages/core/src/lib/template-engine/extensions/index.ts deleted file mode 100644 index 2727ea5b..00000000 --- a/packages/core/src/lib/template-engine/extensions/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './extension'; -export * from './tags'; -export * from './filters'; diff --git a/packages/core/src/lib/template-engine/extensions/tags/error.ts b/packages/core/src/lib/template-engine/extensions/tags/error.ts deleted file mode 100644 index 3d914e19..00000000 --- a/packages/core/src/lib/template-engine/extensions/tags/error.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - NunjucksTagExtension, - NunjucksTagExtensionParseResult, - NunjucksTagExtensionRunOptions, -} from '../extension'; -import * as nunjucks from 'nunjucks'; -import { injectable } from 'inversify'; - -@injectable() -export class ErrorExtension implements NunjucksTagExtension { - public name = 'built-in-error'; - public tags = ['error']; - public parse( - parser: nunjucks.parser.Parser, - nodes: typeof nunjucks.nodes - ): NunjucksTagExtensionParseResult { - // get the tag token - const token = parser.nextToken(); - - const errorMessage = parser.parseSignature(null, true); - parser.advanceAfterBlockEnd(token.value); - - // Add some fake nodes to the AST to indicate error position - errorMessage.addChild( - new nodes.Literal(token.lineno, token.colno, token.lineno) - ); - errorMessage.addChild( - new nodes.Literal(token.lineno, token.colno, token.colno) - ); - - return { - argsNodeList: errorMessage, - contentNodes: [], - }; - } - - public async run({ args }: NunjucksTagExtensionRunOptions) { - const message: string = args[0]; - const lineno: number = args[1]; - const colno: number = args[2]; - throw new Error(`${message} at ${lineno}:${colno}`); - } -} diff --git a/packages/core/src/lib/template-engine/extensions/tags/index.ts b/packages/core/src/lib/template-engine/extensions/tags/index.ts deleted file mode 100644 index 716ec8a1..00000000 --- a/packages/core/src/lib/template-engine/extensions/tags/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './error'; -export * from './req'; diff --git a/packages/core/src/lib/template-engine/extensions/tags/req.ts b/packages/core/src/lib/template-engine/extensions/tags/req.ts deleted file mode 100644 index 466f6a59..00000000 --- a/packages/core/src/lib/template-engine/extensions/tags/req.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - NunjucksTagExtension, - NunjucksTagExtensionParseResult, - NunjucksTagExtensionRunOptions, -} from '../extension'; -import * as nunjucks from 'nunjucks'; -import { injectable, inject } from 'inversify'; -import { TYPES } from '@vulcan/core/containers'; - -// TODO: temporary interface -export interface Executor { - executeQuery(query: string): Promise; -} - -@injectable() -export class ReqExtension implements NunjucksTagExtension { - public name = 'built-in-req'; - public tags = ['req']; - private executor: Executor; - - constructor(@inject(TYPES.Executor) executor: Executor) { - this.executor = executor; - } - - public parse( - parser: nunjucks.parser.Parser, - nodes: typeof nunjucks.nodes - ): NunjucksTagExtensionParseResult { - // get the tag token - const token = parser.nextToken(); - - const args = parser.parseSignature(null, true); - parser.advanceAfterBlockEnd(token.value); - - const requestQuery = parser.parseUntilBlocks('endreq'); - parser.advanceAfterBlockEnd(); - - const variable = args.children[0]; - if (!variable) { - parser.fail(`Expected a variable`, token.lineno, token.colno); - } - if (!(variable instanceof nodes.Symbol)) { - parser.fail( - `Expected a symbol, but got ${variable.typename}`, - variable.lineno, - variable.colno - ); - } - - const variableName = new nodes.Literal( - variable.colno, - variable.lineno, - (variable as nunjucks.nodes.Symbol).value - ); - - const argsNodeToPass = new nodes.NodeList(args.lineno, args.colno); - argsNodeToPass.addChild(variableName); - - return { - argsNodeList: argsNodeToPass, - contentNodes: [requestQuery], - }; - } - - public async run({ context, args }: NunjucksTagExtensionRunOptions) { - const name: string = args[0]; - const requestQuery: () => string = args[1]; - const query = requestQuery() - .split(/\r?\n/) - .filter((line) => line.trim().length > 0) - .join('\n'); - const result = await this.executor.executeQuery(query); - context.setVariable(name, result); - } -} diff --git a/packages/core/src/lib/template-engine/index.ts b/packages/core/src/lib/template-engine/index.ts index 635d48ca..c315bab3 100644 --- a/packages/core/src/lib/template-engine/index.ts +++ b/packages/core/src/lib/template-engine/index.ts @@ -1,7 +1,6 @@ export * from './templateEngine'; export * from './compiler'; export * from './template-providers'; -export * from './extensions'; export * from './inMemoryCodeLoader'; export * from './nunjucksCompiler'; -export * from './visitors'; +export * from './extension-loader'; diff --git a/packages/core/src/lib/template-engine/nunjucksCompiler.ts b/packages/core/src/lib/template-engine/nunjucksCompiler.ts index e41ccd93..e3a495cd 100644 --- a/packages/core/src/lib/template-engine/nunjucksCompiler.ts +++ b/packages/core/src/lib/template-engine/nunjucksCompiler.ts @@ -1,76 +1,92 @@ import { Compiler, CompileResult } from './compiler'; import * as nunjucks from 'nunjucks'; -import { - isFilterExtension, - isTagExtension, - NunjucksCompilerExtension, - NunjucksFilterExtensionWrapper, - NunjucksTagExtensionWrapper, -} from './extensions'; import * as transformer from 'nunjucks/src/transformer'; -import { walkAst } from './visitors/astWalker'; -import { ParametersVisitor, ErrorsVisitor, FiltersVisitor } from './visitors'; -import { inject, injectable, multiInject, optional } from 'inversify'; +import { inject, injectable, multiInject, named, optional } from 'inversify'; import { TYPES } from '@vulcan/core/containers'; +import { + CompileTimeExtension, + Extension, + FilterBuilder, + FilterRunner, + generateMetadata, + implementedOnAstVisit, + implementedProvideMetadata, + OnAstVisit, + ProvideMetadata, + RuntimeExtension, + TagBuilder, + TagRunner, + walkAst, +} from './extension-loader'; +// TODO: Should replace with a real implementation +import { QueryBuilder } from './built-in-extensions/query-builder/reqTagRunner'; @injectable() export class NunjucksCompiler implements Compiler { public name = 'nunjucks'; - private env: nunjucks.Environment; - private extensions: NunjucksCompilerExtension[]; + private runtimeEnv: nunjucks.Environment; + private compileTimeEnv: nunjucks.Environment; + private extensions: Extension[]; + private astVisitors: OnAstVisit[] = []; + private metadataProviders: ProvideMetadata[] = []; constructor( - @inject(TYPES.CompilerLoader) loader: nunjucks.ILoader, @multiInject(TYPES.CompilerExtension) @optional() - extensions: NunjucksCompilerExtension[] = [] + extensions: Extension[] = [], + @inject(TYPES.CompilerEnvironment) + @named('runtime') + runtimeEnv: nunjucks.Environment, + @inject(TYPES.CompilerEnvironment) + @named('compileTime') + compileTimeEnv: nunjucks.Environment ) { - this.env = new nunjucks.Environment(loader); + this.runtimeEnv = runtimeEnv; + this.compileTimeEnv = compileTimeEnv; this.extensions = extensions; this.loadAllExtensions(); } public compile(template: string): CompileResult { - const ast = nunjucks.parser.parse(template, this.env.extensionsList, {}); const compiler = new nunjucks.compiler.Compiler( 'main', - this.env.opts.throwOnUndefined || false + this.compileTimeEnv.opts.throwOnUndefined || false ); - const metadata = this.getMetadata(ast); - const preProcessedAst = this.preProcess(ast); - compiler.compile(preProcessedAst); + const { ast, metadata } = this.generateAst(template); + compiler.compile(ast); const code = compiler.getCode(); return { compiledData: `(() => {${code}})()`, metadata }; } - public async render( + public generateAst(template: string) { + const ast = nunjucks.parser.parse( + template, + this.compileTimeEnv.extensionsList, + {} + ); + this.traverseAst(ast); + const metadata = this.getMetadata(); + const preProcessedAst = this.preProcess(ast); + return { ast: preProcessedAst, metadata }; + } + + public async execute( templateName: string, data: T - ): Promise { - return new Promise((resolve, reject) => { - this.env.render(templateName, data, (err, res) => { - if (err) return reject(err); - if (!res) return resolve(''); - else - return resolve( - res - .split(/\r?\n/) - .filter((line) => line.trim().length > 0) - .join('\n') - ); - }); - }); + ): Promise { + const builder = await this.renderAndGetMainBuilder(templateName, data); + return builder.value(); } - public loadExtension(extension: NunjucksCompilerExtension): void { - if (isTagExtension(extension)) { - const { name, transform } = NunjucksTagExtensionWrapper(extension); - this.env.addExtension(name, transform); - } else if (isFilterExtension(extension)) { - const { name, transform } = NunjucksFilterExtensionWrapper(extension); - this.env.addFilter(name, transform, true); + public loadExtension(extension: Extension): void { + if (extension instanceof RuntimeExtension) { + this.loadRuntimeExtensions(extension); + } else if (extension instanceof CompileTimeExtension) { + this.loadCompileTimeExtensions(extension); } else { - throw new Error('Unsupported extension'); + throw new Error( + `Extension must be of type RuntimeExtension or CompileTimeExtension` + ); } } @@ -78,22 +94,64 @@ export class NunjucksCompiler implements Compiler { this.extensions.forEach((ext) => this.loadExtension(ext)); } + private loadCompileTimeExtensions(extension: CompileTimeExtension): void { + // Extends + if (extension instanceof TagBuilder) { + this.compileTimeEnv.addExtension(extension.getName(), extension); + } else if (extension instanceof FilterBuilder) { + this.compileTimeEnv.addFilter( + extension.filterName, + () => {}, // We don't need to implement transform function in compile time + true + ); + } + // Implement + if (implementedOnAstVisit(extension)) { + this.astVisitors.push(extension); + } + if (implementedProvideMetadata(extension)) { + this.metadataProviders.push(extension); + } + } + + private loadRuntimeExtensions(extension: RuntimeExtension): void { + if (extension instanceof TagRunner) { + this.runtimeEnv.addExtension(extension.getName(), extension); + } else if (extension instanceof FilterRunner) { + this.runtimeEnv.addFilter( + extension.filterName, + extension.__transform.bind(extension), + true + ); + } + } + + private traverseAst(ast: nunjucks.nodes.Node) { + walkAst(ast, this.astVisitors); + } + /** Get some metadata from the AST tree, e.g. the errors defined by templates. * It'll help use to validate templates, validate schema ...etc. */ - private getMetadata(ast: nunjucks.nodes.Node) { - const parameters = new ParametersVisitor(); - const errors = new ErrorsVisitor(); - const filters = new FiltersVisitor({ env: this.env }); - walkAst(ast, [parameters, errors, filters]); - return { - parameters: parameters.getParameters(), - errors: errors.getErrors(), - }; + private getMetadata() { + return generateMetadata(this.metadataProviders); } /** Process the AST tree before compiling */ private preProcess(ast: nunjucks.nodes.Node): nunjucks.nodes.Node { // Nunjucks'll handle the async filter via pre-process functions - return transformer.transform(ast, this.env.asyncFilters); + return transformer.transform(ast, this.compileTimeEnv.asyncFilters); + } + + private renderAndGetMainBuilder(templateName: string, data: any) { + const template = this.runtimeEnv.getTemplate(templateName, true); + return new Promise((resolve, reject) => { + template.getExported<{ FINAL_BUILDER: QueryBuilder }>( + data, + (err, res) => { + if (err) return reject(err); + else resolve(res.FINAL_BUILDER); + } + ); + }); } } diff --git a/packages/core/src/lib/template-engine/templateEngine.ts b/packages/core/src/lib/template-engine/templateEngine.ts index eb6bbc78..e0d1246b 100644 --- a/packages/core/src/lib/template-engine/templateEngine.ts +++ b/packages/core/src/lib/template-engine/templateEngine.ts @@ -46,10 +46,10 @@ export class TemplateEngine { }; } - public async render( + public async execute( templateName: string, data: T - ): Promise { - return this.compiler.render(templateName, data); + ): Promise { + return this.compiler.execute(templateName, data); } } diff --git a/packages/core/src/lib/template-engine/visitors/astWalker.ts b/packages/core/src/lib/template-engine/visitors/astWalker.ts deleted file mode 100644 index 34137b79..00000000 --- a/packages/core/src/lib/template-engine/visitors/astWalker.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as nunjucks from 'nunjucks'; -import { Visitor } from './visitor'; - -export const walkAst = ( - root: nunjucks.nodes.Node, - visitors: Visitor[] -): void => { - visitors.forEach((visitor) => visitor.visit(root)); - - if (root instanceof nunjucks.nodes.NodeList) { - root.children.forEach((node) => { - walkAst(node, visitors); - }); - } else if (root instanceof nunjucks.nodes.CallExtension) { - if (root.args) { - walkAst(root.args, visitors); - } - if (root.contentArgs) { - root.contentArgs.forEach((n) => { - walkAst(n, visitors); - }); - } - } else { - root.iterFields((node) => { - if (node instanceof nunjucks.nodes.Node) { - walkAst(node, visitors); - } - }); - } -}; diff --git a/packages/core/src/lib/template-engine/visitors/errorsVisitor.ts b/packages/core/src/lib/template-engine/visitors/errorsVisitor.ts deleted file mode 100644 index fd3ec0f0..00000000 --- a/packages/core/src/lib/template-engine/visitors/errorsVisitor.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { chain } from 'lodash'; -import * as nunjucks from 'nunjucks'; -import { TemplateErrorMetadata } from '../compiler'; -import { Visitor } from './visitor'; - -interface ErrorCode { - code: string; - lineNo: number; - columnNo: number; -} - -export class ErrorsVisitor implements Visitor { - private extensionName: string; - private errorCodes: ErrorCode[] = []; - - constructor({ - extensionName = 'built-in-error', - }: { extensionName?: string } = {}) { - this.extensionName = extensionName; - } - - public visit(node: nunjucks.nodes.Node) { - if (node instanceof nunjucks.nodes.CallExtension) { - if (node.extName !== this.extensionName) return; - const errorCodeNode = node.args.children[0]; - if (!(errorCodeNode instanceof nunjucks.nodes.Literal)) - throw new Error(`Expected literal, got ${errorCodeNode.typename}`); - this.errorCodes.push({ - code: errorCodeNode.value, - lineNo: errorCodeNode.lineno, - columnNo: errorCodeNode.colno, - }); - } - } - - public getErrors(): TemplateErrorMetadata[] { - return chain(this.errorCodes) - .groupBy('code') - .values() - .map((errorCodes) => ({ - code: errorCodes[0].code, - locations: errorCodes.map((errorCode) => ({ - lineNo: errorCode.lineNo, - columnNo: errorCode.columnNo, - })), - })) - .value(); - } -} diff --git a/packages/core/src/lib/template-engine/visitors/filtersVisitor.ts b/packages/core/src/lib/template-engine/visitors/filtersVisitor.ts deleted file mode 100644 index aa9a0110..00000000 --- a/packages/core/src/lib/template-engine/visitors/filtersVisitor.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as nunjucks from 'nunjucks'; -import { Visitor } from './visitor'; - -export class FiltersVisitor implements Visitor { - private env: nunjucks.Environment; - - constructor({ env }: { env: nunjucks.Environment }) { - this.env = env; - } - - public visit(node: nunjucks.nodes.Node) { - if (node instanceof nunjucks.nodes.Filter) { - if ( - node.name instanceof nunjucks.nodes.Symbol || - node.name instanceof nunjucks.nodes.Literal - ) { - // If the node is a filter and has a expected name node, we check whether the filter is loaded. - // If the filter is not loaded, getFilter function will throw an error. - this.env.getFilter(node.name.value); - } - } - } -} diff --git a/packages/core/src/lib/template-engine/visitors/index.ts b/packages/core/src/lib/template-engine/visitors/index.ts deleted file mode 100644 index c9cfee8d..00000000 --- a/packages/core/src/lib/template-engine/visitors/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './visitor'; -export * from './parametersVisitor'; -export * from './errorsVisitor'; -export * from './filtersVisitor'; -export * from './astWalker'; diff --git a/packages/core/src/lib/template-engine/visitors/visitor.ts b/packages/core/src/lib/template-engine/visitors/visitor.ts deleted file mode 100644 index 575f382a..00000000 --- a/packages/core/src/lib/template-engine/visitors/visitor.ts +++ /dev/null @@ -1,5 +0,0 @@ -import * as nunjucks from 'nunjucks'; - -export interface Visitor { - visit: (node: nunjucks.nodes.Node) => void; -} diff --git a/packages/core/test/containers/continer.spec.ts b/packages/core/test/containers/continer.spec.ts index 61e0f585..5d3b39c2 100644 --- a/packages/core/test/containers/continer.spec.ts +++ b/packages/core/test/containers/continer.spec.ts @@ -16,7 +16,7 @@ it('Container should load options and resolve all dependencies', async () => { fs.unlinkSync(resultPath); } const container = new Container(); - container.load({ + await container.load({ artifact: { provider: PersistentStoreType.LocalFile, filePath: resultPath, diff --git a/packages/core/test/template-engine/built-in-extensions/custom-error/custom-error.spec.ts b/packages/core/test/template-engine/built-in-extensions/custom-error/custom-error.spec.ts new file mode 100644 index 00000000..02b6be1e --- /dev/null +++ b/packages/core/test/template-engine/built-in-extensions/custom-error/custom-error.spec.ts @@ -0,0 +1,61 @@ +import { createTestCompiler } from '../../testCompiler'; + +it('Extension should throw custom error with error code and the position while executing', async () => { + // Arrange + const { compiler, loader } = await createTestCompiler(); + const { compiledData } = compiler.compile(` +{% error "This is an error" %} + `); + // Action, Assert + loader.setSource('test', compiledData); + await expect( + compiler.execute('test', { name: 'World' }) + ).rejects.toThrowError('This is an error at 1:3'); +}); + +it('Extension should provide a correct error list', async () => { + // Arrange + const { compiler } = await createTestCompiler(); + // Act + const { metadata } = compiler.compile(` +{% error "ERROR_CODE" %} +{% error "ERROR_CODE_2" %} +{% error "ERROR_CODE_2" %} + `); + + // Assert + expect(metadata['error.vulcan.com'].errorCodes.length).toBe(2); + expect(metadata['error.vulcan.com'].errorCodes).toContainEqual({ + code: 'ERROR_CODE', + locations: [ + { + lineNo: 1, + columnNo: 9, + }, + ], + }); + expect(metadata['error.vulcan.com'].errorCodes).toContainEqual({ + code: 'ERROR_CODE_2', + locations: [ + { + lineNo: 2, + columnNo: 9, + }, + { + lineNo: 3, + columnNo: 9, + }, + ], + }); +}); + +it('If the arguments of the extension are not the same as expected, the extension should throw error', async () => { + // Arrange + const { compiler } = await createTestCompiler(); + // Act, Assert + expect(() => + compiler.compile(` + {% error QAQ %} + `) + ).toThrow(`Expected literal, got Symbol`); +}); diff --git a/packages/core/test/template-engine/built-in-extensions/query-builder/builder.spec.ts b/packages/core/test/template-engine/built-in-extensions/query-builder/builder.spec.ts new file mode 100644 index 00000000..ea73d959 --- /dev/null +++ b/packages/core/test/template-engine/built-in-extensions/query-builder/builder.spec.ts @@ -0,0 +1,123 @@ +import { createTestCompiler } from '../../testCompiler'; + +it('Extension should execute correct query and set/export the variable', async () => { + // Arrange + const { compiler, loader, builder, executor } = await createTestCompiler(); + const { compiledData } = compiler.compile(` +{% req userCount main %} +select count(*) as count from user where user.id = '{{ params.userId }}'; +{% endreq %} + `); + builder.value.onFirstCall().resolves([{ count: 1 }]); + // Action + loader.setSource('test', compiledData); + const result = await compiler.execute('test', { + params: { userId: 'user-id' }, + }); + // Assert + expect(executor.createBuilder.firstCall.args[0]).toBe( + `select count(*) as count from user where user.id = 'user-id';` + ); + expect(result).toEqual([{ count: 1 }]); +}); + +it('If argument is not a symbol, extension should throw', async () => { + // Arrange + const { compiler } = await createTestCompiler(); + + // Action, Assert + expect(() => + compiler.compile(` +{% req "userCount" %} +select count(*) as count from user where user.id = '{{ params.userId }}'; +{% endreq %} + `) + ).toThrow(`Expected a symbol, but got string`); +}); + +it('If argument is missing, extension should throw', async () => { + // Arrange + const { compiler } = await createTestCompiler(); + + // Action, Assert + expect(() => + compiler.compile(` +{% req %} +select count(*) as count from user where user.id = '{{ params.userId }}'; +{% endreq %} + `) + ).toThrow(`Expected a variable`); +}); + +it('If the main denotation is replaces other keywords than "main", extension should throw an error', async () => { + // Arrange + const { compiler } = await createTestCompiler(); + + // Action, Assert + expect(() => + compiler.compile(` +{% req user super %} +some statement +{% endreq %} + `) + ).toThrow(`Expected a symbol "main"`); +}); + +it('If argument have too many elements, extension should throw an error', async () => { + // Arrange + const { compiler } = await createTestCompiler(); + + // Action, Assert + expect(() => + compiler.compile(` +{% req user main more %} +select count(*) as count from user where user.id = '{{ params.userId }}'; +{% endreq %} + `) + ).toThrow(`Expected a block end, but got symbol`); +}); + +it('The main denotation should be parsed into the second args node', async () => { + // Arrange + const { compiler } = await createTestCompiler(); + + // Action + const { ast: astWithMainBuilder } = compiler.generateAst( + `{% req user main %} some statement {% endreq %}` + ); + + // Assert + expect((astWithMainBuilder as any).children[0].args.children[1].value).toBe( + 'true' + ); +}); + +it('Extension should throw an error if there are tow main builders', async () => { + // Arrange + const { compiler } = await createTestCompiler(); + // Act, Arrange + expect(() => + compiler.compile( + ` + {% req user main %} select * from users; {% endreq %} + {% req user2 main %} select * from users; {% endreq %} + ` + ) + ).toThrowError(`Only one main builder is allowed.`); +}); + +it('Extension should throw an error if there are multiple builders using same name', async () => { + // Arrange + const { compiler } = await createTestCompiler(); + // Act, Arrange + expect(() => + compiler.compile( + ` +{% req user %} select * from users; {% endreq %} +{% req user %} select * from users; {% endreq %} + ` + ) + ).toThrowError( + `We can't declare multiple builder with same name. Duplicated name: user (declared at 1:7 and 2:7)` + ); +}); diff --git a/packages/core/test/template-engine/built-in-extensions/query-builder/execute.spec.ts b/packages/core/test/template-engine/built-in-extensions/query-builder/execute.spec.ts new file mode 100644 index 00000000..5fd46003 --- /dev/null +++ b/packages/core/test/template-engine/built-in-extensions/query-builder/execute.spec.ts @@ -0,0 +1,60 @@ +import { createTestCompiler } from '../../testCompiler'; + +it('Extension should call the .value() function of builder', async () => { + // Arrange + const { compiler, loader, executor, builder } = await createTestCompiler(); + const { compiledData } = compiler.compile( + ` +{% req user %} +select * from users; +{% endreq %} + +select * from group where userId = '{{ user.count(3).value()[0].id }}'; +` + ); + builder.value.onFirstCall().resolves([{ id: 'user-id' }]); + // Action + loader.setSource('test', compiledData); + await compiler.execute('test', {}); + // Assert + expect(executor.createBuilder.secondCall.args[0]).toBe( + `select * from group where userId = 'user-id';` + ); +}); + +it('Extension should throw an error if the main builder failed to execute', async () => { + // Arrange + const { compiler, loader, builder } = await createTestCompiler(); + const { compiledData } = compiler.compile( + ` +{% req user main %} +select * from users; +{% endreq %} +` + ); + builder.value.onFirstCall().rejects(new Error('something went wrong')); + // Action, Assert + loader.setSource('test', compiledData); + await expect(compiler.execute('test', {})).rejects.toThrow( + 'something went wrong' + ); +}); + +it('Extension should throw an error if one of sub builders failed to execute', async () => { + // Arrange + const { compiler, loader, builder } = await createTestCompiler(); + const { compiledData } = compiler.compile( + ` +{% req user %} +select * from users; +{% endreq %} +select * from group where userId = '{{ user.value()[0].id }}'; +` + ); + builder.value.onFirstCall().rejects(new Error('something went wrong')); + // Action, Assert + loader.setSource('test', compiledData); + await expect(compiler.execute('test', {})).rejects.toThrow( + 'something went wrong' + ); +}); diff --git a/packages/core/test/template-engine/built-in-extensions/sql-helper/unique.spec.ts b/packages/core/test/template-engine/built-in-extensions/sql-helper/unique.spec.ts new file mode 100644 index 00000000..4bf181f6 --- /dev/null +++ b/packages/core/test/template-engine/built-in-extensions/sql-helper/unique.spec.ts @@ -0,0 +1,55 @@ +import { createTestCompiler } from '../../testCompiler'; + +it('Extension should return correct values without unique by argument', async () => { + // Arrange + const { compiler, loader, executor } = await createTestCompiler(); + const { compiledData } = compiler.compile( + ` +{% set array = [1,2,3,4,4] %} +{% for item in array | unique %} +{{ item }} +{% endfor %} +` + ); + // Action + loader.setSource('test', compiledData); + await compiler.execute('test', {}); + // Assert + expect(executor.createBuilder.firstCall.args[0]).toBe('1\n2\n3\n4'); +}); + +it('Extension should return correct values with unique by keyword argument', async () => { + // Arrange + const { compiler, loader, executor } = await createTestCompiler(); + const { compiledData } = compiler.compile( + ` +{% set array = [{name: "Tom"}, {name: "Tom"}, {name: "Joy"}] %} +{% for item in array | unique(by="name") %} +{{ item.name }} +{% endfor %} +` + ); + // Action + loader.setSource('test', compiledData); + await compiler.execute('test', {}); + // Assert + expect(executor.createBuilder.firstCall.args[0]).toBe('Tom\nJoy'); +}); + +it('Extension should return correct values with unique by argument', async () => { + // Arrange + const { compiler, loader, executor } = await createTestCompiler(); + const { compiledData } = compiler.compile( + ` +{% set array = [{name: "Tom"}, {name: "Tom"}, {name: "Joy"}] %} +{% for item in array | unique("name") %} +{{ item.name }} +{% endfor %} +` + ); + // Action + loader.setSource('test', compiledData); + await compiler.execute('test', {}); + // Assert + expect(executor.createBuilder.firstCall.args[0]).toBe('Tom\nJoy'); +}); diff --git a/packages/core/test/template-engine/built-in-extensions/validator/filters.spec.ts b/packages/core/test/template-engine/built-in-extensions/validator/filters.spec.ts new file mode 100644 index 00000000..449cc5f5 --- /dev/null +++ b/packages/core/test/template-engine/built-in-extensions/validator/filters.spec.ts @@ -0,0 +1,28 @@ +import { createTestCompiler } from '../../testCompiler'; + +it('If we try to use an unloaded filter, extension should throw error', async () => { + // Arrange + const { compiler } = await createTestCompiler(); + // Act, Assert + expect(() => compiler.compile(`{{ 123 | unloadedFilter }}`)).toThrow( + 'filter not found: unloadedFilter' + ); +}); + +it('If we try to use loaded filter, extension should return the correct list', async () => { + // Arrange + const { compiler } = await createTestCompiler(); + // Act + const { metadata } = compiler.compile(`{{ 123 | unique }}`); + // Act, Assert + expect(metadata['filter.vulcan.com'].length).toBe(1); +}); + +it('If we try to use a built-in filter, extension should return the correct list', async () => { + // Arrange + const { compiler } = await createTestCompiler(); + // Act + const { metadata } = compiler.compile(`{{ 123 | abs }}`); + // Act, Assert + expect(metadata['filter.vulcan.com'].length).toBe(1); +}); diff --git a/packages/core/test/template-engine/visitors/parameters.spec.ts b/packages/core/test/template-engine/built-in-extensions/validator/parameters.spec.ts similarity index 55% rename from packages/core/test/template-engine/visitors/parameters.spec.ts rename to packages/core/test/template-engine/built-in-extensions/validator/parameters.spec.ts index e8a23635..2dddc9a1 100644 --- a/packages/core/test/template-engine/visitors/parameters.spec.ts +++ b/packages/core/test/template-engine/built-in-extensions/validator/parameters.spec.ts @@ -1,22 +1,16 @@ -import * as nunjucks from 'nunjucks'; -import { walkAst, ParametersVisitor } from '@vulcan/core/template-engine'; +import { createTestCompiler } from '../../testCompiler'; -it('Visitor should return correct parameter', async () => { +it('Extension should return correct parameter list', async () => { // Arrange - const ast = nunjucks.parser.parse( - ` + const { compiler } = await createTestCompiler(); + // Act + const { metadata } = compiler.compile(` {{ params.a }}{{ params.a.b }}{{ other.params.a }} {% if params.c and params.d.e %} {{ params.f.g | capitalize }} {% endif %} - `, - [], - {} - ); - const visitor = new ParametersVisitor({ lookupParameter: 'params' }); - // Act - walkAst(ast, [visitor]); - const parameters = visitor.getParameters(); + `); + const parameters = metadata['parameter.vulcan.com']; // Assert expect(parameters.length).toBe(7); expect(parameters).toContainEqual({ @@ -51,15 +45,3 @@ it('Visitor should return correct parameter', async () => { locations: [{ lineNo: 3, columnNo: 13 }], }); }); - -it('Visitor should throw error when max depth (100) is reached', async () => { - // Arrange - let paramString = 'params'; - for (let i = 0; i < 101; i++) { - paramString += '.a'; - } - const ast = nunjucks.parser.parse(`{{ ${paramString} }}`, [], {}); - const visitor = new ParametersVisitor({ lookupParameter: 'params' }); - // Act, Assert - expect(() => walkAst(ast, [visitor])).toThrow('Max depth reached'); -}); diff --git a/packages/core/test/template-engine/commons/edge.spec.ts b/packages/core/test/template-engine/commons/edge.spec.ts new file mode 100644 index 00000000..8419c14b --- /dev/null +++ b/packages/core/test/template-engine/commons/edge.spec.ts @@ -0,0 +1,14 @@ +import { createTestCompiler } from '../testCompiler'; + +it('Compiler should throw an error if max depth of function call lookup is exceeded (100)', async () => { + // Arrange + const { compiler } = await createTestCompiler(); + let queryString = '{{ something.a'; + for (let i = 0; i < 101; i++) { + queryString += '.a'; + } + queryString += '.value() }}'; + + // Action, Assert + expect(() => compiler.compile(queryString)).toThrow('Max depth reached'); +}); diff --git a/packages/core/test/template-engine/extension-loader/helpers.spec.ts b/packages/core/test/template-engine/extension-loader/helpers.spec.ts new file mode 100644 index 00000000..f5fbd76d --- /dev/null +++ b/packages/core/test/template-engine/extension-loader/helpers.spec.ts @@ -0,0 +1,205 @@ +import { visitChildren, walkAst } from '@vulcan/core/template-engine'; +import * as nunjucks from 'nunjucks'; + +it('AST walker should traversal all nodes', async () => { + // Arrange + const root = new nunjucks.nodes.NodeList(0, 0); // 1 + root.addChild( + new nunjucks.nodes.CallExtension( // 2 + {}, + 'run', + new nunjucks.nodes.NodeList(0, 0), // 3 + [new nunjucks.nodes.Literal(0, 0, 'a') /* 4 */] + ) + ); + root.addChild(new nunjucks.nodes.Literal(0, 0, 'b')); // 5 + let visitedNodes = 0; + // Act + walkAst(root, [ + { + onVisit: () => visitedNodes++, + }, + ]); + // Assert + expect(visitedNodes).toBe(5); +}); + +it('AST visit children function should visit all the child node of NodeList', async () => { + // Arrange + const root = new nunjucks.nodes.NodeList(0, 0); + root.addChild( + new nunjucks.nodes.CallExtension( // 1 + {}, + 'run', + new nunjucks.nodes.NodeList(0, 0), + [new nunjucks.nodes.Literal(0, 0, 'a')] + ) + ); + root.addChild(new nunjucks.nodes.Literal(0, 0, 'b')); // 2 + let visitedNodes = 0; + // Act + visitChildren(root, () => visitedNodes++); + // Assert + expect(visitedNodes).toBe(2); +}); + +it('AST visit children function should visit all the child node of CallExtension', async () => { + // Arrange + const root = new nunjucks.nodes.CallExtensionAsync( + 'test', + 'run', + new nunjucks.nodes.NodeList(0, 0) /* 1 */, + [new nunjucks.nodes.Literal(0, 0, 'a') /* 2 */] + ); + + let visitedNodes = 0; + // Act + visitChildren(root, () => visitedNodes++); + // Assert + expect(visitedNodes).toBe(2); +}); + +it('AST visit children function should visit all the child node of LookupVal', async () => { + // Arrange + const root = new nunjucks.nodes.LookupVal( + 0, + 0, + new nunjucks.nodes.Symbol(0, 0, 'a') /* 1 */ + ); + + let visitedNodes = 0; + // Act + visitChildren(root, () => visitedNodes++); + // Assert + expect(visitedNodes).toBe(1); +}); + +it('AST replace function should work with NodeList', async () => { + // Arrange + const root = new nunjucks.nodes.NodeList(0, 0); + root.addChild( + new nunjucks.nodes.CallExtension( // 1 + {}, + 'run', + new nunjucks.nodes.NodeList(0, 0), + [new nunjucks.nodes.Literal(0, 0, 'a')] + ) + ); + root.addChild(new nunjucks.nodes.Literal(0, 0, 'b')); // 2 + root.addChild(new nunjucks.nodes.Symbol(0, 0, 'c')); // 3 + let visitedNodes = 0; + // Act + visitChildren(root, (_node, replace) => { + if (visitedNodes === 0) { + // Replace the first node + replace(new nunjucks.nodes.Literal(0, 0, 'c')); + } else if (visitedNodes === 1) { + // Delete the second node + replace(null); + } else { + // Replace the third node + replace(new nunjucks.nodes.Literal(0, 0, 'd')); + } + visitedNodes++; + }); + // Assert + expect(root.children[0] instanceof nunjucks.nodes.Literal).toBe(true); + expect(root.children[1] instanceof nunjucks.nodes.Literal).toBe(true); + expect(root.children.length).toBe(2); +}); + +it('AST replace function should work with CallExtension', async () => { + // Arrange + const root = new nunjucks.nodes.CallExtensionAsync( + 'test', + 'run', + new nunjucks.nodes.NodeList(0, 0) /* 1 */, + [ + new nunjucks.nodes.Literal(0, 0, 'a') /* 2 */, + new nunjucks.nodes.Literal(0, 0, 'b') /* 3 */, + new nunjucks.nodes.Literal(0, 0, 'c') /* 4 */, + ] + ); + let visitedNodes = 0; + // Act + visitChildren(root, (_node, replace) => { + if (visitedNodes === 0) { + // Replace the first node + replace(new nunjucks.nodes.NodeList(0, 1)); + } else if (visitedNodes === 1) { + // Replace the second node + replace(new nunjucks.nodes.Symbol(0, 0, 'a-r')); + } else if (visitedNodes === 2) { + // Delete the third node + replace(null); + } + visitedNodes++; + }); + // Assert + expect(root.args.colno).toBe(1); + expect(root.contentArgs?.[0] instanceof nunjucks.nodes.Symbol).toBe(true); + expect(root.contentArgs?.[1] instanceof nunjucks.nodes.Literal).toBe(true); + expect(root.contentArgs?.length).toBe(2); +}); + +it('AST replace function should provide fallback for CallExtension if we trying to delete args node', async () => { + // Arrange + const args = new nunjucks.nodes.NodeList(1, 1); + args.addChild(new nunjucks.nodes.Literal(0, 0, 'a')); + const root = new nunjucks.nodes.CallExtensionAsync('test', 'run', args, []); + // Act + visitChildren(root, (node, replace) => { + if (node instanceof nunjucks.nodes.NodeList) { + replace(null); + } + }); + // Assert + expect(root.args.lineno).toBe(1); + expect(root.args.colno).toBe(1); + expect(root.args.children.length).toBe(0); +}); + +it('AST replace function should wrap the result for CallExtension if we trying to replace args with nodes other than NodeList', async () => { + // Arrange + const root = new nunjucks.nodes.CallExtensionAsync( + 'test', + 'run', + new nunjucks.nodes.NodeList(1, 1), + [] + ); + // Act + visitChildren(root, (node, replace) => { + if (node instanceof nunjucks.nodes.NodeList) { + replace(new nunjucks.nodes.Literal(0, 0, 'a')); + } + }); + // Assert + expect(root.args.lineno).toBe(1); + expect(root.args.colno).toBe(1); + expect(root.args.children[0] instanceof nunjucks.nodes.Literal).toBe(true); +}); + +it('AST replace function should work with LookupVal', async () => { + // Arrange + const root = new nunjucks.nodes.LookupVal( + 0, + 0, + new nunjucks.nodes.Literal(0, 0, 'a') /* target 1 */, + new nunjucks.nodes.Value(0, 0, 'b') /* value 2 */ + ); + let visitedNodes = 0; + // Act + visitChildren(root, (_node, replace) => { + if (visitedNodes === 0) { + // Replace the first node + replace(new nunjucks.nodes.Symbol(0, 0, 'c')); + } else if (visitedNodes === 1) { + // Delete the second node + replace(null); + } + visitedNodes++; + }); + // Assert + expect(root.target instanceof nunjucks.nodes.Symbol).toBe(true); + expect(root.val).toBeUndefined(); +}); diff --git a/packages/core/test/template-engine/extensions/filters/unique.spec.ts b/packages/core/test/template-engine/extensions/filters/unique.spec.ts deleted file mode 100644 index 38baec3e..00000000 --- a/packages/core/test/template-engine/extensions/filters/unique.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { TYPES } from '@vulcan/core/containers'; -import { - NunjucksCompiler, - InMemoryCodeLoader, - UniqueExtension, - Compiler, -} from '@vulcan/core/template-engine'; -import { Container } from 'inversify'; - -let container: Container; - -beforeEach(() => { - container = new Container(); - container - .bind(TYPES.CompilerLoader) - .to(InMemoryCodeLoader) - .inSingletonScope(); - container.bind(TYPES.Compiler).to(NunjucksCompiler).inSingletonScope(); - container.bind(TYPES.CompilerExtension).to(UniqueExtension); -}); - -afterEach(() => { - container.unbindAll(); -}); - -it('Extension should return correct values without unique by argument', async () => { - // Arrange - const compiler = container.get(TYPES.Compiler); - const loader = container.get(TYPES.CompilerLoader); - const { compiledData } = compiler.compile( - ` -{% set array = [1,2,3,4,4] %} -{% for item in array | unique %} -{{ item }} -{% endfor %} -` - ); - // Action - loader.setSource('test', compiledData); - const result = await compiler.render('test', {}); - // Assert - expect(result).toBe('1\n2\n3\n4'); -}); - -it('Extension should return correct values with unique by keyword argument', async () => { - // Arrange - const compiler = container.get(TYPES.Compiler); - const loader = container.get(TYPES.CompilerLoader); - const { compiledData } = compiler.compile( - ` -{% set array = [{name: "Tom"}, {name: "Tom"}, {name: "Joy"}] %} -{% for item in array | unique(by="name") %} -{{ item.name }} -{% endfor %} -` - ); - // Action - loader.setSource('test', compiledData); - const result = await compiler.render('test', {}); - // Assert - expect(result).toBe('Tom\nJoy'); -}); - -it('Extension should return correct values with unique by argument', async () => { - // Arrange - const compiler = container.get(TYPES.Compiler); - const loader = container.get(TYPES.CompilerLoader); - const { compiledData } = compiler.compile( - ` -{% set array = [{name: "Tom"}, {name: "Tom"}, {name: "Joy"}] %} -{% for item in array | unique("name") %} -{{ item.name }} -{% endfor %} -` - ); - // Action - loader.setSource('test', compiledData); - const result = await compiler.render('test', {}); - // Assert - expect(result).toBe('Tom\nJoy'); -}); diff --git a/packages/core/test/template-engine/extensions/tags/error.spec.ts b/packages/core/test/template-engine/extensions/tags/error.spec.ts deleted file mode 100644 index 8f2f30e3..00000000 --- a/packages/core/test/template-engine/extensions/tags/error.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { TYPES } from '@vulcan/core/containers'; -import { - NunjucksCompiler, - InMemoryCodeLoader, - ErrorExtension, - Compiler, -} from '@vulcan/core/template-engine'; -import { Container } from 'inversify'; - -let container: Container; - -beforeEach(() => { - container = new Container(); - container - .bind(TYPES.CompilerLoader) - .to(InMemoryCodeLoader) - .inSingletonScope(); - container.bind(TYPES.Compiler).to(NunjucksCompiler).inSingletonScope(); - container.bind(TYPES.CompilerExtension).to(ErrorExtension); -}); - -afterEach(() => { - container.unbindAll(); -}); - -it('Error extension should throw error with error code and the position while rendering', async () => { - // Arrange - const compiler = container.get(TYPES.Compiler); - const loader = container.get(TYPES.CompilerLoader); - const { compiledData } = compiler.compile(` -{% error "This is an error" %} - `); - // Action, Assert - loader.setSource('test', compiledData); - await expect(compiler.render('test', { name: 'World' })).rejects.toThrowError( - 'This is an error at 1:3' - ); -}); diff --git a/packages/core/test/template-engine/extensions/tags/req.spec.ts b/packages/core/test/template-engine/extensions/tags/req.spec.ts deleted file mode 100644 index 0c71a0c0..00000000 --- a/packages/core/test/template-engine/extensions/tags/req.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { TYPES } from '@vulcan/core/containers'; -import { - NunjucksCompiler, - InMemoryCodeLoader, - Executor, - ReqExtension, - Compiler, -} from '@vulcan/core/template-engine'; -import { Container } from 'inversify'; -import * as sinon from 'ts-sinon'; - -let container: Container; -let mockExecutor: sinon.StubbedInstance; - -beforeEach(() => { - container = new Container(); - mockExecutor = sinon.stubInterface(); - container - .bind(TYPES.CompilerLoader) - .to(InMemoryCodeLoader) - .inSingletonScope(); - container.bind(TYPES.Executor).toConstantValue(mockExecutor); - container.bind(TYPES.Compiler).to(NunjucksCompiler).inSingletonScope(); - container.bind(TYPES.CompilerExtension).to(ReqExtension).inSingletonScope(); -}); - -afterEach(() => { - container.unbindAll(); -}); - -it('req extension should execute correct query and set variable', async () => { - // Arrange - const compiler = container.get(TYPES.Compiler); - const loader = container.get(TYPES.CompilerLoader); - const { compiledData } = compiler.compile(` -{% req userCount %} -select count(*) as count from user where user.id = '{{ params.userId }}'; -{% endreq %} -{{ userCount[0].count }} - `); - mockExecutor.executeQuery.resolves([{ count: 1 }]); - - // Action - loader.setSource('test', compiledData); - const query = await compiler.render('test', { - params: { userId: 'user-id' }, - }); - // Assert - expect(mockExecutor.executeQuery.firstCall.args[0]).toBe( - `select count(*) as count from user where user.id = 'user-id';` - ); - expect(query).toBe('1'); -}); - -it('if argument is not a symbol, extension should throw', async () => { - // Arrange - const compiler = container.get(TYPES.Compiler); - - // Action, Assert - expect(() => - compiler.compile(` -{% req "userCount" %} -select count(*) as count from user where user.id = '{{ params.userId }}'; -{% endreq %} - `) - ).toThrow(`Expected a symbol, but got Literal`); -}); - -it('if argument is missing, extension should throw', async () => { - // Arrange - const compiler = container.get(TYPES.Compiler); - - // Action, Assert - expect(() => - compiler.compile(` -{% req %} -select count(*) as count from user where user.id = '{{ params.userId }}'; -{% endreq %} - `) - ).toThrow(`Expected a variable`); -}); diff --git a/packages/core/test/template-engine/nunjuckCompiler.spec.ts b/packages/core/test/template-engine/nunjuckCompiler.spec.ts index 17591007..b0069ce4 100644 --- a/packages/core/test/template-engine/nunjuckCompiler.spec.ts +++ b/packages/core/test/template-engine/nunjuckCompiler.spec.ts @@ -1,30 +1,8 @@ -import { TYPES } from '@vulcan/core/containers'; -import { - NunjucksCompiler, - InMemoryCodeLoader, - NunjucksCompilerExtension, - Compiler, -} from '@vulcan/core/template-engine'; -import { Container } from 'inversify'; - -let container: Container; - -beforeEach(() => { - container = new Container(); - container - .bind(TYPES.CompilerLoader) - .to(InMemoryCodeLoader) - .inSingletonScope(); - container.bind(TYPES.Compiler).to(NunjucksCompiler).inSingletonScope(); -}); - -afterEach(() => { - container.unbindAll(); -}); +import { createTestCompiler } from './testCompiler'; it('Nunjucks compiler should compile template without error.', async () => { // Arrange - const compiler = container.get(TYPES.Compiler); + const { compiler } = await createTestCompiler(); // Action const compilerCode = compiler.compile('Hello {{ name }}'); @@ -33,26 +11,25 @@ it('Nunjucks compiler should compile template without error.', async () => { expect(compilerCode).toBeTruthy(); }); -it('Nunjucks compiler should load compiled code and render template with it', async () => { +it('Nunjucks compiler should load compiled code and execute rendered template with it', async () => { // Arrange - const loader = container.get(TYPES.CompilerLoader); - const compiler = container.get(TYPES.Compiler); + const { compiler, loader, getCreatedQueries } = await createTestCompiler(); const { compiledData } = compiler.compile('Hello {{ name }}!'); // Action loader.setSource('test', compiledData); - const result = await compiler.render('test', { name: 'World' }); + await compiler.execute('test', { name: 'World' }); + const queries = await getCreatedQueries(); // Assert - expect(result).toBe('Hello World!'); + expect(queries[0]).toBe('Hello World!'); }); -it('Nunjucks compiler should reject unsupported extensions', async () => { +it('Nunjucks compiler should reject the extension which has no valid super class', async () => { // Arrange - const compiler = container.get(TYPES.Compiler); + const { compiler } = await createTestCompiler(); // Action, Assert - // extension should have parse and name property - expect(() => - compiler.loadExtension({ tags: ['test'] } as NunjucksCompilerExtension) - ).toThrow('Unsupported extension'); + expect(() => compiler.loadExtension({})).toThrow( + 'Extension must be of type RuntimeExtension or CompileTimeExtension' + ); }); diff --git a/packages/core/test/template-engine/templateEngine.spec.ts b/packages/core/test/template-engine/templateEngine.spec.ts index 5f0fd5b6..40d83949 100644 --- a/packages/core/test/template-engine/templateEngine.spec.ts +++ b/packages/core/test/template-engine/templateEngine.spec.ts @@ -31,7 +31,7 @@ beforeEach(() => { errors: [], }, }); - stubCompiler.render.resolves('sql-statement'); + stubCompiler.execute.resolves('sql-result'); const generator = async function* () { yield { @@ -76,9 +76,9 @@ it('Template engine render function should forward correct data to compiler', as }; // Act - const result = await templateEngine.render('template-name', context); + const result = await templateEngine.execute('template-name', context); // Assert - expect(stubCompiler.render.calledWith('template-name', context)).toBe(true); - expect(result).toBe('sql-statement'); + expect(stubCompiler.execute.calledWith('template-name', context)).toBe(true); + expect(result).toBe('sql-result'); }); diff --git a/packages/core/test/template-engine/testCompiler.ts b/packages/core/test/template-engine/testCompiler.ts new file mode 100644 index 00000000..607907c0 --- /dev/null +++ b/packages/core/test/template-engine/testCompiler.ts @@ -0,0 +1,62 @@ +import { TYPES } from '@vulcan/core/containers'; +import { + InMemoryCodeLoader, + NunjucksCompiler, +} from '@vulcan/core/template-engine'; +import { bindExtensions } from '@vulcan/core/template-engine/extension-loader'; +import { Container } from 'inversify'; +import * as sinon from 'ts-sinon'; +import * as nunjucks from 'nunjucks'; +// TODO: Should replace with a real implementation +import { + QueryBuilder, + Executor, +} from '@vulcan/core/template-engine/built-in-extensions/query-builder/reqTagRunner'; + +export const createTestCompiler = async () => { + const container = new Container(); + const stubBuilder = sinon.stubInterface(); + stubBuilder.count.returns(stubBuilder); + const stubExecutor = sinon.stubInterface(); + stubExecutor.createBuilder.resolves(stubBuilder); + + container + .bind(TYPES.CompilerLoader) + .to(InMemoryCodeLoader) + .inSingletonScope(); + await bindExtensions(container.bind.bind(container)); + container.bind(TYPES.Executor).toConstantValue(stubExecutor); + + container.bind(TYPES.Compiler).to(NunjucksCompiler).inSingletonScope(); + + // Compiler environment + container + .bind(TYPES.CompilerEnvironment) + .toDynamicValue((context) => { + // We only need loader in runtime + const loader = context.container.get( + TYPES.CompilerLoader + ); + return new nunjucks.Environment(loader); + }) + .inSingletonScope() + .whenTargetNamed('runtime'); + container + .bind(TYPES.CompilerEnvironment) + .toDynamicValue(() => { + return new nunjucks.Environment(); + }) + .inSingletonScope() + .whenTargetNamed('compileTime'); + + return { + builder: stubBuilder, + executor: stubExecutor, + compiler: container.get(TYPES.Compiler), + loader: container.get(TYPES.CompilerLoader), + getCreatedQueries: async () => { + const calls = stubExecutor.createBuilder.getCalls(); + return calls.map((call) => call.args[0]); + }, + }; +}; diff --git a/packages/core/test/template-engine/visitors/astWalker.spec.ts b/packages/core/test/template-engine/visitors/astWalker.spec.ts deleted file mode 100644 index 2c2a9df7..00000000 --- a/packages/core/test/template-engine/visitors/astWalker.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { walkAst } from '@vulcan/core/template-engine'; -import * as nunjucks from 'nunjucks'; - -it('AST walker should traversal all nodes', async () => { - // Arrange - const root = new nunjucks.nodes.NodeList(0, 0); // 1 - root.addChild( - new nunjucks.nodes.CallExtension( // 2 - {}, - 'run', - new nunjucks.nodes.NodeList(0, 0), // 3 - [new nunjucks.nodes.Literal(0, 0, 'a') /* 4 */] - ) - ); - root.addChild(new nunjucks.nodes.Literal(0, 0, 'b')); // 5 - let visitedNodes = 0; - // Act - walkAst(root, [ - { - visit: () => visitedNodes++, - }, - ]); - // Assert - expect(visitedNodes).toBe(5); -}); diff --git a/packages/core/test/template-engine/visitors/errors.spec.ts b/packages/core/test/template-engine/visitors/errors.spec.ts deleted file mode 100644 index a2c216bc..00000000 --- a/packages/core/test/template-engine/visitors/errors.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import * as nunjucks from 'nunjucks'; -import { walkAst, ErrorsVisitor } from '@vulcan/core/template-engine'; -import { - ErrorExtension, - NunjucksTagExtensionWrapper, -} from '@vulcan/core/template-engine/extensions'; - -it('Visitor should return correct error list', async () => { - // Arrange - const env = new nunjucks.Environment(); - const { name, transform } = NunjucksTagExtensionWrapper(new ErrorExtension()); - env.addExtension(name, transform); - const ast = nunjucks.parser.parse( - ` -{% error "ERROR_CODE" %} -{% error "ERROR_CODE_2" %} -{% error "ERROR_CODE_2" %} - `, - env.extensionsList, - {} - ); - const visitor = new ErrorsVisitor({ extensionName: name }); - // Act - walkAst(ast, [visitor]); - const errors = visitor.getErrors(); - // Assert - expect(errors.length).toBe(2); - expect(errors).toContainEqual({ - code: 'ERROR_CODE', - locations: [ - { - lineNo: 1, - columnNo: 9, - }, - ], - }); - expect(errors).toContainEqual({ - code: 'ERROR_CODE_2', - locations: [ - { - lineNo: 2, - columnNo: 9, - }, - { - lineNo: 3, - columnNo: 9, - }, - ], - }); -}); - -it('Visitor should ignore the extension calls which are not belong to error extension', async () => { - // Arrange - const ast = new nunjucks.nodes.CallExtension( - { __name: 'other-ext' }, - 'run', - new nunjucks.nodes.NodeList(0, 0), - [new nunjucks.nodes.Literal(0, 0, 'a')] - ); - const visitor = new ErrorsVisitor({ extensionName: 'error-extension' }); - // Act - walkAst(ast, [visitor]); - const errors = visitor.getErrors(); - // Assert - expect(errors.length).toBe(0); -}); - -it('If the arguments of the extension are not the same as expected, visitor should throw error', async () => { - // Arrange - const args = new nunjucks.nodes.NodeList(0, 0); - args.addChild(new nunjucks.nodes.Symbol(0, 0, 'a')); // Should be a literal - - const ast = new nunjucks.nodes.CallExtension( - { __name: 'error-extension' }, - 'run', - args, - [] - ); - const visitor = new ErrorsVisitor({ extensionName: 'error-extension' }); - // Act, Assert - expect(() => walkAst(ast, [visitor])).toThrow(`Expected literal, got Symbol`); -}); diff --git a/packages/core/test/template-engine/visitors/filter.spec.ts b/packages/core/test/template-engine/visitors/filter.spec.ts deleted file mode 100644 index 483ee3de..00000000 --- a/packages/core/test/template-engine/visitors/filter.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import * as nunjucks from 'nunjucks'; -import { walkAst, FiltersVisitor } from '@vulcan/core/template-engine'; - -it('If we try to use an unloaded filter, visitor should throw error', async () => { - // Arrange - const env = new nunjucks.Environment(); - env.addFilter('test', (val) => val); - const ast = nunjucks.parser.parse(`{{ 123 | unloadedFilter }}`, [], {}); - const visitor = new FiltersVisitor({ env }); - // Act, Assert - expect(() => walkAst(ast, [visitor])).toThrow( - 'filter not found: unloadedFilter' - ); -}); - -it('If we try to use loaded filter, visitor should do nothing', async () => { - // Arrange - const env = new nunjucks.Environment(); - env.addFilter('test', (val) => val); - const ast = nunjucks.parser.parse(`{{ 123 | test }}`, [], {}); - const visitor = new FiltersVisitor({ env }); - // Act, Assert - expect(() => walkAst(ast, [visitor])).not.toThrow(); -}); - -it('If we try to visit an unexpected filter node, visitor should do nothing', async () => { - // Arrange - const root = new nunjucks.nodes.Filter( - 0, - 0, - 'test', - new nunjucks.nodes.LookupVal(0, 0, 'a'), // Should be a Symbol or Literal - new nunjucks.nodes.NodeList(0, 0, []) - ); - const env = new nunjucks.Environment(); - const visitor = new FiltersVisitor({ env }); - // Act, Assert - expect(() => walkAst(root, [visitor])).not.toThrow(); -}); diff --git a/types/nunjucks.d.ts b/types/nunjucks.d.ts index d8ca3565..b901d800 100644 --- a/types/nunjucks.d.ts +++ b/types/nunjucks.d.ts @@ -6,6 +6,10 @@ declare module 'nunjucks' { err: lib.TemplateError | null, res: T | null ) => void; + export type TemplateExportCallback = ( + err: lib.TemplateError | null, + res: T + ) => void; export type Callback = (err: E | null, res: T | null) => void; export function render(name: string, context?: object): string; @@ -56,6 +60,16 @@ declare module 'nunjucks' { ); render(context?: object): string; render(context?: object, callback?: TemplateCallback): void; + getExported(callback: TemplateExportCallback): void; + getExported( + context: object, + callback: TemplateExportCallback + ): string; + getExported( + context: object, + parentFrame: any, + callback: TemplateExportCallback + ): string; } export function configure(options: ConfigureOptions): Environment; @@ -150,7 +164,7 @@ declare module 'nunjucks' { export interface Extension { tags: string[]; // Parser API is undocumented it is suggested to check the source: https://github.com/mozilla/nunjucks/blob/master/src/parser.js - parse(parser: any, nodes: any, lexer: any): any; + parse?(parser: any, nodes: any, lexer: any): any; } export function installJinjaCompat(): void; @@ -283,7 +297,8 @@ declare module 'nunjucks' { advanceAfterBlockEnd(name: string): Token; parseUntilBlocks(...blockName: string[]): nodes.NodeList; advanceAfterBlockEnd(): Token; - fail(message: string, lineno?: number, colno?: number): void; + fail(message: string, lineno?: number, colno?: number): never; + parseExpression(): Nodes; } } @@ -304,9 +319,11 @@ declare module 'nunjucks' { addChild(child: Node): void; } + class Root extends NodeList {} + class CallExtension extends Node { constructor( - ext: object, + ext: object | string, prop: string, args: nodes.NodeList | null, contentArgs: nodes.Node[] | null @@ -319,7 +336,10 @@ declare module 'nunjucks' { class CallExtensionAsync extends CallExtension {} class LookupVal extends Node { - target: Literal | Symbol; + target: + | Symbol // a.b + | FunCall // a().b + | LookupVal; // a.b.c val: Value; } @@ -332,7 +352,10 @@ declare module 'nunjucks' { class Symbol extends Value {} class FunCall extends Node { - name: Node; + name: + | Symbol // a() + | LookupVal // a.b() + | FunCall; // a().b() args: NodeList; } @@ -343,10 +366,38 @@ declare module 'nunjucks' { body: Node | null; targets: Node[]; } + + class TemplateData extends Literal {} } namespace lexer { function lexer(src: string, opts: any): any; + const TOKEN_STRING: string; + const TOKEN_WHITESPACE: string; + const TOKEN_DATA: string; + const TOKEN_BLOCK_START: string; + const TOKEN_BLOCK_END: string; + const TOKEN_VARIABLE_START: string; + const TOKEN_VARIABLE_END: string; + const TOKEN_COMMENT: string; + const TOKEN_LEFT_PAREN: string; + const TOKEN_RIGHT_PAREN: string; + const TOKEN_LEFT_BRACKET: string; + const TOKEN_RIGHT_BRACKET: string; + const TOKEN_LEFT_CURLY: string; + const TOKEN_RIGHT_CURLY: string; + const TOKEN_OPERATOR: string; + const TOKEN_COMMA: string; + const TOKEN_COLON: string; + const TOKEN_TILDE: string; + const TOKEN_PIPE: string; + const TOKEN_INT: string; + const TOKEN_FLOAT: string; + const TOKEN_BOOLEAN: string; + const TOKEN_NONE: string; + const TOKEN_SYMBOL: string; + const TOKEN_SPECIAL: string; + const TOKEN_REGEX: string; } interface Token {