diff --git a/src/decorators.ts b/src/decorators.ts new file mode 100644 index 0000000000..3db033ece3 --- /dev/null +++ b/src/decorators.ts @@ -0,0 +1,219 @@ +import { NoTransformConfigurationError } from "./transformers/NoTransformConfigurationError"; + +import { IValidation } from "./IValidation"; +import { TypeGuardError } from "./TypeGuardError"; + +/* =========================================================== + DECORATORS + - ASSERT + - IS + - VALIDATE +============================================================== + ASSERT +----------------------------------------------------------- */ +/** + * Asserts a method with its parameters. + * + * Asserts a method, by wrapping the method and checking its parameters through + * {@link assert} function. If some parameter does not match the expected type, it + * throws an {@link TypeGuardError} or a custom error generated by the *errorFactory* + * parameter. + * + * For reference, {@link TypeGuardError.path} would be a little bit different with + * individual {@link assert} function. If the {@link TypeGuardError} occurs from + * some parameter, the path would start from `$input.parameters[number]`. + * + * This decorator is equivalent to using {@link functional.assertParameters} but + * works as a TypeScript method decorator for class methods. + * + * @param errorFactory Custom error factory. Default is `TypeGuardError` + * @returns Method decorator + * @throws A {@link TypeGuardError} or a custom error generated by *errorFactory* + * + * @author Jeongho Nam - https://github.com/samchon + */ +export function assert( + errorFactory?: undefined | ((props: TypeGuardError.IProps) => Error), +): MethodDecorator; + +/** + * @internal + */ +export function assert(): never { + NoTransformConfigurationError("decorators.assert"); +} + +/** + * Asserts a method with strict equality of its parameters. + * + * Asserts a method, by wrapping the method and checking its parameters through + * {@link assertEquals} function. If some parameter does not match the expected type, + * it throws an {@link TypeGuardError} or a custom error generated by the *errorFactory* + * parameter. + * + * For reference, {@link TypeGuardError.path} would be a little bit different with + * individual {@link assertEquals} function. If the {@link TypeGuardError} occurs from + * some parameter, the path would start from `$input.parameters[number]`. + * + * This decorator is equivalent to using {@link functional.assertEqualsParameters} but + * works as a TypeScript method decorator for class methods. + * + * On the other hand, if you want to allow superfluous properties that are not enrolled + * to the parameter types, you can use {@link assert} decorator instead. + * + * @param errorFactory Custom error factory. Default is `TypeGuardError` + * @returns Method decorator + * @throws A {@link TypeGuardError} or a custom error generated by *errorFactory* + * + * @author Jeongho Nam - https://github.com/samchon + */ +export function assertEquals( + errorFactory?: undefined | ((props: TypeGuardError.IProps) => Error), +): MethodDecorator; + +/** + * @internal + */ +export function assertEquals(): never { + NoTransformConfigurationError("decorators.assertEquals"); +} + +/* ----------------------------------------------------------- + IS +----------------------------------------------------------- */ +/** + * Tests a method's parameters. + * + * Tests a method, by wrapping the method and checking its parameters through + * {@link is} function. If some parameter does not match the expected type, it + * returns `null`. Otherwise there's no type error, it returns the result of the + * method. + * + * This decorator is equivalent to using {@link functional.isParameters} but + * works as a TypeScript method decorator for class methods. + * + * By the way, if you want is not just testing type checking, but also finding + * detailed type error reason(s), then use {@link assert} or {@link validate} + * decorators instead. + * + * On the other hand, if you don't want to allow any superfluous properties, + * utilize {@link equals} decorator instead. + * + * @returns Method decorator + * + * @author Jeongho Nam - https://github.com/samchon + */ +export function is(): MethodDecorator; + +/** + * @internal + */ +export function is(): never { + NoTransformConfigurationError("decorators.is"); +} + +/** + * Tests a method's parameters with strict equality. + * + * Tests a method, by wrapping the method and checking its parameters through + * {@link equals} function. If some parameter does not match the expected type, it + * returns `null`. Otherwise there's no type error, it returns the result of the + * method. + * + * This decorator is equivalent to using {@link functional.equalsParameters} but + * works as a TypeScript method decorator for class methods. + * + * By the way, if you want is not just testing type checking, but also finding + * detailed type error reason(s), then use {@link assertEquals} or {@link validateEquals} + * decorators instead. + * + * On the other hand, if you want to allow superfluous properties that are not enrolled + * to the parameter types, you can use {@link is} decorator instead. + * + * @returns Method decorator + * + * @author Jeongho Nam - https://github.com/samchon + */ +export function equals(): MethodDecorator; + +/** + * @internal + */ +export function equals(): never { + NoTransformConfigurationError("decorators.equals"); +} + +/* ----------------------------------------------------------- + VALIDATE +----------------------------------------------------------- */ +/** + * Validates a method's parameters. + * + * Validates a method, by wrapping the method and checking its parameters through + * {@link validate} function. If some parameter does not match the expected type, it + * returns {@link IValidation.IError} typed object. Otherwise there's no type error, it + * returns {@link IValidation.ISuccess} typed object instead. + * + * For reference, {@link IValidation.IError.path} would be a little bit different with + * individual {@link validate} function. If the {@link IValidation.IError} occurs from + * some parameter, the path would start from `$input.parameters[number]`. + * + * This decorator is equivalent to using {@link functional.validateParameters} but + * works as a TypeScript method decorator for class methods. + * + * By the way, if what you want is not finding every type errors, but just finding + * the 1st type error, then use {@link assert} decorator instead. Otherwise, if you + * just want to know whether the parameters are matched with their types, {@link is} + * decorator is the way to go. + * + * On the other hand, if you don't want to allow any superfluous properties, utilize + * {@link validateEquals} decorator instead. + * + * @returns Method decorator + * + * @author Jeongho Nam - https://github.com/samchon + */ +export function validate(): MethodDecorator; + +/** + * @internal + */ +export function validate(): never { + NoTransformConfigurationError("decorators.validate"); +} + +/** + * Validates a method's parameters with strict equality. + * + * Validates a method, by wrapping the method and checking its parameters through + * {@link validateEquals} function. If some parameter does not match the expected type, it + * returns {@link IValidation.IError} typed object. Otherwise there's no type error, it + * returns {@link IValidation.ISuccess} typed object instead. + * + * For reference, {@link IValidation.IError.path} would be a little bit different with + * individual {@link validateEquals} function. If the {@link IValidation.IError} occurs from + * some parameter, the path would start from `$input.parameters[number]`. + * + * This decorator is equivalent to using {@link functional.validateEqualsParameters} but + * works as a TypeScript method decorator for class methods. + * + * By the way, if what you want is not finding every type errors, but just finding + * the 1st type error, then use {@link assertEquals} decorator instead. Otherwise, if you + * just want to know whether the parameters are matched with their types, {@link equals} + * decorator is the way to go. + * + * On the other hand, if you want to allow superfluous properties that are not enrolled + * to the parameter types, you can use {@link validate} decorator instead. + * + * @returns Method decorator + * + * @author Jeongho Nam - https://github.com/samchon + */ +export function validateEquals(): MethodDecorator; + +/** + * @internal + */ +export function validateEquals(): never { + NoTransformConfigurationError("decorators.validateEquals"); +} \ No newline at end of file diff --git a/src/module.ts b/src/module.ts index 679ad5a842..7216bd620c 100644 --- a/src/module.ts +++ b/src/module.ts @@ -8,6 +8,7 @@ import { IValidation } from "./IValidation"; import { Resolved } from "./Resolved"; import { TypeGuardError } from "./TypeGuardError"; +export * as decorators from "./decorators"; export * as functional from "./functional"; export * as http from "./http"; export * as llm from "./llm"; diff --git a/src/programmers/decorators/DecoratorAssertParametersProgrammer.ts b/src/programmers/decorators/DecoratorAssertParametersProgrammer.ts new file mode 100644 index 0000000000..8cfb019447 --- /dev/null +++ b/src/programmers/decorators/DecoratorAssertParametersProgrammer.ts @@ -0,0 +1,50 @@ +import ts from "typescript"; + +import { FunctionalAssertParametersProgrammer } from "../functional/FunctionalAssertParametersProgrammer"; + +import { ITypiaContext } from "../../transformers/ITypiaContext"; + +export namespace DecoratorAssertParametersProgrammer { + export interface IConfig { + equals: boolean; + } + + export interface IProps { + context: ITypiaContext; + modulo: ts.LeftHandSideExpression; + config: IConfig; + method: ts.MethodDeclaration; + expression: ts.Expression; + init?: ts.Expression | undefined; + } + + export const write = (props: IProps): ts.Expression => { + // Convert the method to a function declaration for the functional programmer + const functionDeclaration = createFunctionDeclarationFromMethod(props.method); + + // Reuse the functional programmer logic + return FunctionalAssertParametersProgrammer.write({ + context: props.context, + modulo: props.modulo, + config: props.config, + declaration: functionDeclaration, + expression: props.expression, + init: props.init, + }); + }; + + const createFunctionDeclarationFromMethod = ( + method: ts.MethodDeclaration, + ): ts.FunctionDeclaration => { + // Create a synthetic function declaration that matches the method signature + return ts.factory.createFunctionDeclaration( + method.modifiers?.filter(ts.isModifier), + method.asteriskToken, + ts.factory.createIdentifier("__method"), + method.typeParameters, + method.parameters, + method.type, + method.body || ts.factory.createBlock([]), + ); + }; +} \ No newline at end of file diff --git a/src/transformers/CallExpressionTransformer.ts b/src/transformers/CallExpressionTransformer.ts index dc7fbd5929..b96160edbf 100644 --- a/src/transformers/CallExpressionTransformer.ts +++ b/src/transformers/CallExpressionTransformer.ts @@ -12,6 +12,8 @@ import { FunctionalValidateParametersProgrammer } from "../programmers/functiona import { FunctionalValidateReturnProgrammer } from "../programmers/functional/FunctionalValidateReturnProgrammer"; import { FunctionalGenericTransformer } from "./features/functional/FunctionalGenericTransformer"; +import { DecoratorTransformer } from "./features/decorators/DecoratorTransformer"; + import { NamingConvention } from "../utils/NamingConvention"; import { ITransformProps } from "./ITransformProps"; @@ -551,4 +553,55 @@ const FUNCTORS: Record Task>> = { NamingConvention.snake, ), }, + decorators: { + // ASSERTIONS + assert: () => + DecoratorTransformer.transform({ + method: "assert", + config: { + equals: false, + }, + programmer: () => ts.factory.createIdentifier("undefined"), // placeholder + }), + assertEquals: () => + DecoratorTransformer.transform({ + method: "assertEquals", + config: { + equals: true, + }, + programmer: () => ts.factory.createIdentifier("undefined"), // placeholder + }), + is: () => + DecoratorTransformer.transform({ + method: "is", + config: { + equals: false, + }, + programmer: () => ts.factory.createIdentifier("undefined"), // placeholder + }), + equals: () => + DecoratorTransformer.transform({ + method: "equals", + config: { + equals: true, + }, + programmer: () => ts.factory.createIdentifier("undefined"), // placeholder + }), + validate: () => + DecoratorTransformer.transform({ + method: "validate", + config: { + equals: false, + }, + programmer: () => ts.factory.createIdentifier("undefined"), // placeholder + }), + validateEquals: () => + DecoratorTransformer.transform({ + method: "validateEquals", + config: { + equals: true, + }, + programmer: () => ts.factory.createIdentifier("undefined"), // placeholder + }), + }, }; diff --git a/src/transformers/DecoratorTransformer.ts b/src/transformers/DecoratorTransformer.ts new file mode 100644 index 0000000000..5f3de0c489 --- /dev/null +++ b/src/transformers/DecoratorTransformer.ts @@ -0,0 +1,125 @@ +import ts from "typescript"; + +import { ITypiaContext } from "./ITypiaContext"; +import { DecoratorGenericTransformer } from "./features/decorators/DecoratorGenericTransformer"; + +export namespace DecoratorTransformer { + export interface IProps { + context: ITypiaContext; + method: ts.MethodDeclaration; + } + + export const transformMethod = (props: IProps): ts.MethodDeclaration => { + const decorators = (props.method as any).decorators as ts.Decorator[] | undefined; + if (!decorators) { + return props.method; + } + + let transformedMethod = props.method; + + // Process each decorator + for (const decorator of decorators) { + const result = transform({ + context: props.context, + decorator, + method: transformedMethod, + }); + + if (result) { + transformedMethod = result; + } + } + + return transformedMethod; + }; + + export const transform = (props: { + context: ITypiaContext; + decorator: ts.Decorator; + method: ts.MethodDeclaration; + }): ts.MethodDeclaration | null => { + // Check if this is a typia decorator + if (!ts.isCallExpression(props.decorator.expression)) { + return null; + } + + const callExpression = props.decorator.expression; + if (!ts.isPropertyAccessExpression(callExpression.expression)) { + return null; + } + + const propertyAccess = callExpression.expression; + if (!ts.isPropertyAccessExpression(propertyAccess.expression)) { + return null; + } + + const typia = propertyAccess.expression; + if (!ts.isIdentifier(typia.expression) || typia.expression.text !== "typia") { + return null; + } + + if (!ts.isIdentifier(typia.name) || typia.name.text !== "decorators") { + return null; + } + + const decoratorName = propertyAccess.name.text; + + // Map decorator names to configurations + const config = getDecoratorConfig(decoratorName); + if (!config) { + return null; + } + + // Use the generic transformer + return DecoratorGenericTransformer.transform({ + method: decoratorName, + config: config.config, + programmer: config.programmer, + })({ + context: props.context, + decorator: props.decorator, + method: props.method, + expression: callExpression, + }); + }; + + const getDecoratorConfig = (decoratorName: string) => { + // Import the programmers dynamically to avoid circular dependencies + const { DecoratorAssertParametersProgrammer } = require("../programmers/decorators/DecoratorAssertParametersProgrammer"); + + const configs: Record = { + assert: { + method: "assert", + config: { equals: false }, + programmer: DecoratorAssertParametersProgrammer.write, + }, + assertEquals: { + method: "assertEquals", + config: { equals: true }, + programmer: DecoratorAssertParametersProgrammer.write, + }, + is: { + method: "is", + config: { equals: false }, + programmer: DecoratorAssertParametersProgrammer.write, // TODO: Create DecoratorIsParametersProgrammer + }, + equals: { + method: "equals", + config: { equals: true }, + programmer: DecoratorAssertParametersProgrammer.write, // TODO: Create DecoratorIsParametersProgrammer + }, + validate: { + method: "validate", + config: { equals: false }, + programmer: DecoratorAssertParametersProgrammer.write, // TODO: Create DecoratorValidateParametersProgrammer + }, + validateEquals: { + method: "validateEquals", + config: { equals: true }, + programmer: DecoratorAssertParametersProgrammer.write, // TODO: Create DecoratorValidateParametersProgrammer + }, + }; + + return configs[decoratorName]; + }; +} \ No newline at end of file diff --git a/src/transformers/NodeTransformer.ts b/src/transformers/NodeTransformer.ts index ffce0d80b2..a82b8d4a65 100644 --- a/src/transformers/NodeTransformer.ts +++ b/src/transformers/NodeTransformer.ts @@ -1,17 +1,30 @@ import ts from "typescript"; import { CallExpressionTransformer } from "./CallExpressionTransformer"; +import { DecoratorTransformer } from "./DecoratorTransformer"; import { ITypiaContext } from "./ITypiaContext"; export namespace NodeTransformer { export const transform = (props: { context: ITypiaContext; node: ts.Node; - }): ts.Node | null => - ts.isCallExpression(props.node) && props.node.parent - ? CallExpressionTransformer.transform({ - context: props.context, - expression: props.node, - }) - : props.node; + }): ts.Node | null => { + // Handle call expressions + if (ts.isCallExpression(props.node) && props.node.parent) { + return CallExpressionTransformer.transform({ + context: props.context, + expression: props.node, + }); + } + + // Handle method declarations with decorators + if (ts.isMethodDeclaration(props.node) && (props.node as any).decorators) { + return DecoratorTransformer.transformMethod({ + context: props.context, + method: props.node, + }); + } + + return props.node; + }; } diff --git a/src/transformers/features/decorators/DecoratorGenericTransformer.ts b/src/transformers/features/decorators/DecoratorGenericTransformer.ts new file mode 100644 index 0000000000..d59a287c42 --- /dev/null +++ b/src/transformers/features/decorators/DecoratorGenericTransformer.ts @@ -0,0 +1,103 @@ +import ts from "typescript"; + +import { ITypiaContext } from "../../ITypiaContext"; + +export namespace DecoratorGenericTransformer { + export interface IConfig { + equals: boolean; + } + + export interface ISpecification { + method: string; + config: IConfig; + programmer: (p: { + context: ITypiaContext; + modulo: ts.LeftHandSideExpression; + expression: ts.Expression; + method: ts.MethodDeclaration; + config: IConfig; + init?: ts.Expression; + }) => ts.Expression; + } + + export const transform = + (spec: ISpecification) => + (props: { + context: ITypiaContext; + decorator: ts.Decorator; + method: ts.MethodDeclaration; + expression: ts.CallExpression; + }): ts.MethodDeclaration => { + // Get the error factory from decorator arguments (if provided) + const init = props.expression.arguments[0]; + + // Create a module reference for the generated code + const modulo = ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier("typia"), + "functional", + ); + + // Generate the wrapper function using the programmer + const wrapperExpression = spec.programmer({ + context: props.context, + modulo, + expression: createMethodReference(props.method), + method: props.method, + config: spec.config, + init, + }); + + // Create the transformed method body that calls the wrapper + const transformedBody = createTransformedMethodBody({ + method: props.method, + wrapperExpression, + }); + + // Return the method with transformed body and removed decorators + const decorators = (props.method as any).decorators as ts.Decorator[] | undefined; + const otherDecorators = decorators?.filter( + (d: ts.Decorator) => d !== props.decorator, + ); + + // Use object spread to create a new method with updated properties + const newMethod = Object.assign(Object.create(Object.getPrototypeOf(props.method)), props.method, { + decorators: otherDecorators, + body: transformedBody, + }); + + return newMethod as ts.MethodDeclaration; + }; + + const createMethodReference = (method: ts.MethodDeclaration): ts.Expression => { + // Create a function expression that represents the original method + return ts.factory.createArrowFunction( + method.modifiers?.filter(ts.isModifier), + method.typeParameters, + method.parameters, + method.type, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + method.body || ts.factory.createBlock([]), + ); + }; + + const createTransformedMethodBody = (props: { + method: ts.MethodDeclaration; + wrapperExpression: ts.Expression; + }): ts.Block => { + // Create parameter references for the wrapper call + const parameterNames = props.method.parameters.map((param) => + ts.isIdentifier(param.name) ? param.name : ts.factory.createIdentifier("param"), + ); + + // Call the generated wrapper function with the parameters + const wrapperCall = ts.factory.createCallExpression( + props.wrapperExpression, + undefined, + parameterNames, + ); + + return ts.factory.createBlock([ + ts.factory.createReturnStatement(wrapperCall), + ]); + }; +} \ No newline at end of file diff --git a/src/transformers/features/decorators/DecoratorTransformer.ts b/src/transformers/features/decorators/DecoratorTransformer.ts new file mode 100644 index 0000000000..ca22491cea --- /dev/null +++ b/src/transformers/features/decorators/DecoratorTransformer.ts @@ -0,0 +1,74 @@ +import ts from "typescript"; + +import { ITransformProps } from "../../ITransformProps"; + +export namespace DecoratorTransformer { + export interface IConfig { + equals: boolean; + } + + export interface ISpecification { + method: string; + config: IConfig; + programmer: (p: { + context: any; + modulo: ts.LeftHandSideExpression; + config: IConfig; + expression?: ts.Expression; + init?: ts.Expression; + }) => ts.Expression; + } + + export const transform = + (_spec: ISpecification) => + (_props: ITransformProps): ts.Expression => { + // For decorators, we need to return a decorator function + // that validates the method parameters + + // Create a simple decorator function that validates parameters + const decoratorFunction = ts.factory.createArrowFunction( + undefined, // modifiers + undefined, // type parameters + [ + // target parameter + ts.factory.createParameterDeclaration( + undefined, + undefined, + ts.factory.createIdentifier("target"), + undefined, + undefined, + undefined, + ), + // propertyKey parameter + ts.factory.createParameterDeclaration( + undefined, + undefined, + ts.factory.createIdentifier("propertyKey"), + undefined, + undefined, + undefined, + ), + // descriptor parameter + ts.factory.createParameterDeclaration( + undefined, + undefined, + ts.factory.createIdentifier("descriptor"), + undefined, + undefined, + undefined, + ), + ], + undefined, // return type + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock([ + // For now, return the original descriptor + // TODO: Add validation logic + ts.factory.createReturnStatement( + ts.factory.createIdentifier("descriptor") + ), + ]) + ); + + return decoratorFunction; + }; +} \ No newline at end of file diff --git a/test/src/features/decorators/test_decorator_basic.ts b/test/src/features/decorators/test_decorator_basic.ts new file mode 100644 index 0000000000..7b3ebddfb3 --- /dev/null +++ b/test/src/features/decorators/test_decorator_basic.ts @@ -0,0 +1,50 @@ +import typia from "typia"; + +interface UserQuery { + name: string; + age: number; +} + +class UserService { + // Test the decorator - should validate parameters + @typia.decorators.assertEquals() + async findMany(query: UserQuery): Promise { + // This should be validated by the decorator + return [query]; + } + + // Test regular method for comparison + async findManyUnsafe(query: UserQuery): Promise { + return [query]; + } +} + +export const test_decorator_basic = async (): Promise => { + console.log("Testing decorators module..."); + + // Test that we can instantiate the class with decorator + const service = new UserService(); + console.log("Service created successfully with decorator"); + + // Test valid input + const validQuery: UserQuery = { name: "John", age: 30 }; + try { + const result = await service.findMany(validQuery); + console.log("Valid query succeeded:", result); + } catch (error) { + console.error("Valid query failed:", error); + throw error; + } + + // Test invalid input should throw an error + const invalidQuery = { name: "John", age: "thirty" } as any; + try { + const result = await service.findMany(invalidQuery); + console.error("Invalid query should have failed but didn't:", result); + throw new Error("Invalid query should have thrown an error"); + } catch (error) { + console.log("Invalid query correctly failed:", error.message); + } + + console.log("All decorator tests passed!"); +}; \ No newline at end of file