From 0583e581987518443246d5a3d94e6ea98a73a249 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Sun, 9 Feb 2025 03:18:43 +0000 Subject: [PATCH 1/8] Enhance validator domain with advanced rule validation and dot notation support - Add IsNumber validation rule - Refactor AbstractRule to support array-based validation with wildcard paths - Update IRule interface to use setPath/getPath instead of setField - Implement DotNotationParserException for better error handling - Improve DataExtractor and DotNotationParser to handle complex nested data extraction - Add comprehensive type definitions and method implementations for flexible validation --- .../validator/abstract/AbstractRule.ts | 146 ++++++++++++++++-- .../domains/validator/interfaces/IRule.ts | 4 +- src/core/domains/validator/rules/Accepted.ts | 8 +- .../domains/validator/rules/AcceptedIf.ts | 5 +- src/core/domains/validator/rules/Equals.ts | 5 +- src/core/domains/validator/rules/IsArray.ts | 4 +- src/core/domains/validator/rules/IsObject.ts | 4 +- src/core/domains/validator/rules/IsString.ts | 8 +- src/core/domains/validator/rules/Required.ts | 6 +- src/core/domains/validator/rules/isNumber.ts | 24 +++ .../domains/validator/service/Validator.ts | 2 +- .../exceptions/DotNotationParserException.ts | 10 ++ src/core/util/data/DataExtractor.ts | 78 ++++++---- src/core/util/data/DotNotationParser.ts | 59 +++++-- 14 files changed, 283 insertions(+), 80 deletions(-) create mode 100644 src/core/domains/validator/rules/isNumber.ts create mode 100644 src/core/exceptions/DotNotationParserException.ts diff --git a/src/core/domains/validator/abstract/AbstractRule.ts b/src/core/domains/validator/abstract/AbstractRule.ts index b77e911a0..792fcc226 100644 --- a/src/core/domains/validator/abstract/AbstractRule.ts +++ b/src/core/domains/validator/abstract/AbstractRule.ts @@ -1,72 +1,200 @@ +import DotNotationParser from "@src/core/util/data/DotNotationParser"; import forceString from "@src/core/util/str/forceString"; import { logger } from "../../logger/services/LoggerService"; import { IRuleError } from "../interfaces/IRule"; +/** + * Abstract base class for validation rules. + * Provides common functionality for implementing validation rules with customizable error messages and options. + * + * @template TOptions - Type of options object that can be passed to configure the rule + */ abstract class AbstractRule { + /** Name of the validation rule */ protected abstract name: string; + /** Template string for error messages. Use :attribute for the field name and :key for option values */ protected abstract errorTemplate: string; + /** Default error message if error template processing fails */ protected defaultError: string = 'This field is invalid.' + /** Configuration options for the rule */ protected options: TOptions = {} as TOptions + /** The value to validate */ protected data: unknown = undefined + /** All attributes/fields being validated */ protected attributes: unknown = undefined - protected field!: string; - + /** Dot notation path to the field being validated (e.g. "users.*.name") */ + protected path!: string; + /** + * Tests if the current data value passes the validation rule + * @returns True if validation passes, false if it fails + */ + public abstract test(): boolean; + /** + * Gets the validation error details if validation fails + * @returns Object containing error information + */ public abstract getError(): IRuleError; + + /** + * Validates the data against the rule + * If the last part of the path contains a wildcard (*), validates each item in the array + * Otherwise validates the single value + * + * For example: + * - For path "users.*.name", validates name field for each user + * - For path "email", validates single email value + * + + * @returns True if validation passes, false if it fails + */ + public validate(): boolean { + if(this.validatableAsArray()) { + return this.arrayTests() + } + + return this.test() + } + + /** + * Validates an array of data by testing each item individually + * @returns True if all items pass validation, false if any fail + */ + protected arrayTests(): boolean { + const data = this.getData() + + if(Array.isArray(data)) { + for(const item of data) { + this.setData(item) + if(!this.test()) { + return false + } + } + return true // Return true if all items passed + } + + return false // Return false for non-array data + } + + /** + * Checks if the rule should be validated as an array + * By checking if the last part of the path contains a wildcard (*) + * @returns True if the rule should be validated as an array, false otherwise + */ + protected validatableAsArray(): boolean { + const parts = DotNotationParser.parse(this.getPath()).getParts() + const secondToLastPart = parts[parts.length - 2] ?? null + return secondToLastPart?.includes('*') + } + + /** + * Sets the configuration options for this validation rule + * @param options - Rule-specific options object + * @returns this - For method chaining + */ public setOptions(options: TOptions): this { this.options = options return this } - + /** + * Sets the value to be validated + * @param data - The value to validate + * @returns this - For method chaining + */ public setData(data: unknown): this { this.data = data return this } + /** + * Gets the current value being validated + * @returns The value being validated + */ public getData(): unknown { return this.data } - public setField(field: string): this { - this.field = field - return this - } - + /** + * Sets all attributes/fields being validated + * @param attributes - Object containing all fields being validated + * @returns this - For method chaining + */ public setAttributes(attributes: unknown): this { this.attributes = attributes return this } + /** + * Gets all attributes/fields being validated + * @returns Object containing all fields being validated + */ public getAttributes(): unknown { return this.attributes } + /** + * Gets a specific option value by key + * @param key - The option key to retrieve + * @returns The option value + */ public getOption(key: string): unknown { return this.options[key] } + /** + * Gets the name of this validation rule + * @returns The rule name + */ public getName(): string { return this.name } + /** + * Gets the error message template + * @returns The error template string + */ protected getErrorTemplate(): string { return this.errorTemplate } + /** + * Sets the dot notation path to the field being validated + * @param path - The field path (e.g. "users.*.name") + * @returns this - For method chaining + */ + public setPath(path: string): this { + this.path = path + return this + } + + /** + * Gets the dot notation path to the field being validated + * @returns The field path + */ + public getPath(): string { + return this.path + } + + /** + * Builds an error message by replacing placeholders in the error template + * @param replace - Object containing key-value pairs to replace in the template + * @returns The formatted error message + */ protected buildError(replace?: Record): string { try { - let error = this.errorTemplate.replace(':attribute', this.field) + + let error = this.errorTemplate.replace(':attribute', this.getPath()) if (!replace) { return error diff --git a/src/core/domains/validator/interfaces/IRule.ts b/src/core/domains/validator/interfaces/IRule.ts index a4c2f9420..1fced8ab6 100644 --- a/src/core/domains/validator/interfaces/IRule.ts +++ b/src/core/domains/validator/interfaces/IRule.ts @@ -14,7 +14,8 @@ export interface IRuleError { export interface IRule { - setField(field: string): this + setPath(field: string): this + getPath(): string setData(data: unknown): this setAttributes(attributes: unknown): this validate(): boolean @@ -23,6 +24,7 @@ export interface IRule { + } diff --git a/src/core/domains/validator/rules/Accepted.ts b/src/core/domains/validator/rules/Accepted.ts index f5b61d75d..fb65afa50 100644 --- a/src/core/domains/validator/rules/Accepted.ts +++ b/src/core/domains/validator/rules/Accepted.ts @@ -5,21 +5,17 @@ import isTruthy from "../utils/isTruthy"; class Accepted extends AbstractRule implements IRule { - protected name: string = 'accepted' protected errorTemplate: string = 'The :attribute field must be accepted.'; - - public validate(): boolean { + public test(): boolean { return isTruthy(this.getData()) } - - public getError(): IRuleError { return { - [this.field]: this.buildError() + [this.getPath()]: this.buildError() } } diff --git a/src/core/domains/validator/rules/AcceptedIf.ts b/src/core/domains/validator/rules/AcceptedIf.ts index 1cf3e42c8..f67e7a036 100644 --- a/src/core/domains/validator/rules/AcceptedIf.ts +++ b/src/core/domains/validator/rules/AcceptedIf.ts @@ -15,14 +15,13 @@ class AcceptedIf extends AbstractRule implements IRule { protected errorTemplate: string = 'The :attribute field must be accepted when :another is :value.'; - constructor(anotherField: string, value: unknown) { super() this.options.anotherField = anotherField this.options.value = value } - public validate(): boolean { + public test(): boolean { const { anotherField, value: expectedValue @@ -42,7 +41,7 @@ class AcceptedIf extends AbstractRule implements IRule { getError(): IRuleError { return { - [this.field]: this.buildError({ + [this.getPath()]: this.buildError({ another: this.options.anotherField, value: this.options.value }) diff --git a/src/core/domains/validator/rules/Equals.ts b/src/core/domains/validator/rules/Equals.ts index 4ea271060..6a8a5ced3 100644 --- a/src/core/domains/validator/rules/Equals.ts +++ b/src/core/domains/validator/rules/Equals.ts @@ -15,14 +15,13 @@ class Equals extends AbstractRule implements IRule { this.matches = matches; } - public validate(): boolean { + public test(): boolean { return this.getData() === this.matches } - public getError(): IRuleError { return { - [this.field]: this.buildError({ + [this.getPath()]: this.buildError({ matches: this.matches }) } diff --git a/src/core/domains/validator/rules/IsArray.ts b/src/core/domains/validator/rules/IsArray.ts index a8743fac5..fe4d336f0 100644 --- a/src/core/domains/validator/rules/IsArray.ts +++ b/src/core/domains/validator/rules/IsArray.ts @@ -8,13 +8,13 @@ class IsArray extends AbstractRule implements IRule { protected errorTemplate: string = 'The :attribute field must be an array.'; - public validate(): boolean { + public test(): boolean { return Array.isArray(this.getData()) } public getError(): IRuleError { return { - [this.field]: this.buildError() + [this.getPath()]: this.buildError() } } diff --git a/src/core/domains/validator/rules/IsObject.ts b/src/core/domains/validator/rules/IsObject.ts index ac7d98883..942c74a7a 100644 --- a/src/core/domains/validator/rules/IsObject.ts +++ b/src/core/domains/validator/rules/IsObject.ts @@ -8,13 +8,13 @@ class IsString extends AbstractRule implements IRule { protected errorTemplate: string = 'The :attribute field must be an object.'; - public validate(): boolean { + public test(): boolean { return typeof this.getData() === 'object' } public getError(): IRuleError { return { - [this.field]: this.buildError() + [this.getPath()]: this.buildError() } } diff --git a/src/core/domains/validator/rules/IsString.ts b/src/core/domains/validator/rules/IsString.ts index 1eaace073..2b3c1bf77 100644 --- a/src/core/domains/validator/rules/IsString.ts +++ b/src/core/domains/validator/rules/IsString.ts @@ -8,18 +8,16 @@ class IsString extends AbstractRule implements IRule { protected errorTemplate: string = 'The :attribute field must be a string.'; - public validate(): boolean { - const value = this.getData() - return typeof value === 'string' + public test(): boolean { + return typeof this.getData() === 'string' } public getError(): IRuleError { return { - [this.field]: this.buildError() + [this.getPath()]: this.buildError() } } - } diff --git a/src/core/domains/validator/rules/Required.ts b/src/core/domains/validator/rules/Required.ts index 05abacc2d..1cc2129fd 100644 --- a/src/core/domains/validator/rules/Required.ts +++ b/src/core/domains/validator/rules/Required.ts @@ -8,16 +8,14 @@ class Required extends AbstractRule implements IRule { protected errorTemplate: string = 'The :attribute field is required.'; - public validate(): boolean { + public test(): boolean { const value = this.getData() return value !== undefined && value !== null && value !== '' } - - public getError(): IRuleError { return { - [this.field]: this.buildError() + [this.getPath()]: this.buildError() } } diff --git a/src/core/domains/validator/rules/isNumber.ts b/src/core/domains/validator/rules/isNumber.ts new file mode 100644 index 000000000..b7aafc4bb --- /dev/null +++ b/src/core/domains/validator/rules/isNumber.ts @@ -0,0 +1,24 @@ + +import AbstractRule from "../abstract/AbstractRule"; +import { IRule, IRuleError } from "../interfaces/IRule"; + +class IsNumber extends AbstractRule implements IRule { + + protected name: string = 'number' + + protected errorTemplate: string = 'The :attribute field must be a number.'; + + public test(): boolean { + return typeof this.getData() === 'number' + } + + public getError(): IRuleError { + return { + [this.getPath()]: this.buildError() + } + } + +} + + +export default IsNumber; diff --git a/src/core/domains/validator/service/Validator.ts b/src/core/domains/validator/service/Validator.ts index 9348f0e80..6a2c11ec2 100644 --- a/src/core/domains/validator/service/Validator.ts +++ b/src/core/domains/validator/service/Validator.ts @@ -94,7 +94,7 @@ class Validator implements IValidator { */ protected validateRule(key: string, rule: IRule, data: unknown, attributes: unknown): IValidatorResult { - rule.setField(key) + rule.setPath(key) rule.setData(data) rule.setAttributes(attributes) const passes = rule.validate(); diff --git a/src/core/exceptions/DotNotationParserException.ts b/src/core/exceptions/DotNotationParserException.ts new file mode 100644 index 000000000..afa46d96f --- /dev/null +++ b/src/core/exceptions/DotNotationParserException.ts @@ -0,0 +1,10 @@ +export default class DotNotationParserException extends Error { + + constructor(message: string = 'Dot Notation Parser Exception') { + super(message); + + this.name = 'DotNotationParserException'; + } + + +} \ No newline at end of file diff --git a/src/core/util/data/DataExtractor.ts b/src/core/util/data/DataExtractor.ts index d584c9db0..7658901b6 100644 --- a/src/core/util/data/DataExtractor.ts +++ b/src/core/util/data/DataExtractor.ts @@ -53,7 +53,7 @@ class DataExtractor { public static reduce(attributes: TData, paths: TPathsObject): TPathsObject { return new DataExtractor().init(paths, attributes) } - + /** * Initializes the extraction process with the given paths and attributes * @param paths - Object containing dot notation paths as keys @@ -91,17 +91,14 @@ class DataExtractor { const containsNestedIndex = parsedPath.isNestedIndex() // If the path contains a nested index, reduce the nested indexes - if(containsNestedIndex) { + if (containsNestedIndex) { acc = this.reduceNestedIndexes(parsedPath, acc, attributes) as object | unknown[] } - // If the path contains a non-nested index, reduce the index - if(containsNestedIndex === false) { + else if (containsNestedIndex === false) { acc = this.reduceIndex(parsedPath, acc, attributes) as object | unknown[] } - - // If the path contains a wildcard, reduce all values - acc = this.reduceAll(parsedPath, acc, attributes) + return acc } @@ -116,7 +113,12 @@ class DataExtractor { protected reduceIndex(parsedRuleKey, acc: object, attributes: object) { const index = parsedRuleKey.getIndex() - if(attributes[index]) { + if (Array.isArray(attributes)) { + return attributes.map(item => { + return item[index] + }) + } + else if (attributes[index]) { return attributes[index] } @@ -151,28 +153,46 @@ class DataExtractor { // Example: attributes = [{ name: "John" }, { name: "Jane" }] // isArray = true // isObject = true - const isArray = Array.isArray(attributes) + const isArray = Array.isArray(attributes) const isObject = !isArray && typeof attributes === 'object' + // This block handles wildcard paths like "users.*.name" + // When a wildcard (*) is encountered: + // 1. Parse the remaining path after the wildcard (e.g. "name") + // 2. Initialize an empty array at the current index (e.g. users: []) + // 3. Recursively process each item in the array at attributes[currentIndex] + // For example, with data {users: [{name: "John"}, {name: "Jane"}]} + // and path "users.*.name", this will extract all user names into an array + if (parsedPath.isAll()) { + const allParsedPath = DotNotationParser.parse(rest) + const allNextPath = allParsedPath.getNextIndex() + + acc = { + ...acc, + [currentIndex]: [] + } + + return this.recursiveReducer(allNextPath.toString(), acc[currentIndex], attributes[currentIndex], attributes[currentIndex]) + } + // If the current index is undefined, return the accumulator as is - if(attributes[currentIndex] === undefined) { + if (attributes[currentIndex] === undefined) { return acc } // This section handles nested data reduction for validation rules // For example, with data like: { users: [{ name: "John" }, { name: "Jane" }] } // And rule key like: "users.0.name" - - const blankObjectOrArray = this.getBlankObjectOrArray(attributes[currentIndex]) - + // // blankObjectOrArray will be either [] or {} depending on the type of the current value // This ensures we maintain the correct data structure when reducing - + const blankObjectOrArray = this.getBlankObjectOrArray(attributes[currentIndex]) + // If the current value is an array, we need to build up a new array // If it's an object, we need to build up a new object with the current index as key // This preserves the structure while only including validated fields - if(isArray) { - if(!Array.isArray(acc)) { + if (isArray) { + if (!Array.isArray(acc)) { acc = [] } acc = [ @@ -180,7 +200,7 @@ class DataExtractor { blankObjectOrArray, ] } - else if(isObject) { + else if (isObject) { acc = { ...acc, [currentIndex]: blankObjectOrArray @@ -189,6 +209,8 @@ class DataExtractor { // Recursively reduce the rest of the path return this.recursiveReducer(rest, acc[currentIndex], attributes[currentIndex][nextIndex], attributes[currentIndex]) + + } /** @@ -204,21 +226,21 @@ class DataExtractor { * @returns The updated accumulator with validated data from the wildcard path */ protected reduceAll(parsedPath: DotNotationParser, acc: object, attributes: object) { - if(!parsedPath.isAll()) { + if (!parsedPath.isAll()) { return acc; - } const index = parsedPath.getIndex() - - if(attributes[index]) { - return { - ...acc, - [index]: attributes[index] - } + const nextIndex = parsedPath.getNextIndexSafe() + const allParsedPath = DotNotationParser.parse(parsedPath.getRest()) + + acc = { + ...acc, + [index]: [] } - return acc + return this.recursiveReducer(allParsedPath.getRest(), acc[index], attributes[index], attributes[index]) + } /** @@ -228,10 +250,10 @@ class DataExtractor { * @returns A blank object or array */ protected getBlankObjectOrArray(value: unknown): object | unknown[] { - if(Array.isArray(value)) { + if (Array.isArray(value)) { return [] } - + return {} } diff --git a/src/core/util/data/DotNotationParser.ts b/src/core/util/data/DotNotationParser.ts index aff9aed2a..adc40afe7 100644 --- a/src/core/util/data/DotNotationParser.ts +++ b/src/core/util/data/DotNotationParser.ts @@ -1,3 +1,5 @@ +import DotNotationParserException from "@src/core/exceptions/DotNotationParserException"; + /** * Options for parsing rule keys * @interface DotNotationParserOptions @@ -15,10 +17,14 @@ export type DotNotationParserOptions = { /** The remaining rule key after parsing */ rest: string; + + /** The parts of the path */ + parts: string[]; } /** * Parser for paths that handles both simple keys and nested paths with wildcards. + * Supports formats like: * - Simple key: "users" or "0" * - Nested path: "users.name" or "users.0" @@ -66,7 +72,8 @@ class DotNotationParser { } current.appendOptions({ - index: this.parseIndex(path) + index: this.parseIndex(path), + parts: [path] }) return current @@ -99,20 +106,17 @@ class DotNotationParser { const pathParts = path.split('.') const pathPart0 = pathParts[0] const pathPart1 = pathParts[1] - const rest = pathParts.splice(1).join('.') - - if(pathPart1 === '*') { - current.appendOptions({ - index: this.parseIndex(pathPart0), - all: true - }) - return current; - } + const rest = [...pathParts].splice(1).join('.') + const index = this.parseIndex(pathPart0) + const nextIndex = this.parseIndex(pathPart1) + const all = pathPart1 === '*' current.appendOptions({ - index: this.parseIndex(pathPart0), - nextIndex: this.parseIndex(pathPart1), - rest: rest + index, + nextIndex, + all, + rest, + parts: pathParts }) return current @@ -138,9 +142,10 @@ class DotNotationParser { */ public getIndex(): string | number { if(typeof this.options.index === 'undefined') { - throw new Error('index is not defined') + throw new DotNotationParserException('index is not defined') } return this.options.index + } /** @@ -150,13 +155,23 @@ class DotNotationParser { */ public getNextIndex(): string | number { if(typeof this.options.nextIndex === 'undefined') { - throw new Error('nextIndex is not defined') + throw new DotNotationParserException('nextIndex is not defined') } return this.options.nextIndex } + /** + * Gets the next index from the parser options + * @returns The next index as a string or number + */ + public getNextIndexSafe(): string | number | undefined { + return this.options.nextIndex + } + + /** * Checks if the current path has a nested index + * @returns True if both index and nextIndex are defined */ public isNestedIndex() { @@ -178,11 +193,23 @@ class DotNotationParser { */ public getRest(): string { if(typeof this.options.rest === 'undefined') { - throw new Error('rest is not defined') + throw new DotNotationParserException('rest is not defined') } return this.options.rest } + /** + * Gets the parts of the path + * @returns The parts of the path + */ + public getParts(): string[] { + return this.options.parts + } + + + + + } export default DotNotationParser \ No newline at end of file From 80c74c0002fafd3d0eb15b30503d1237cfd6bea0 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 10 Feb 2025 03:18:50 +0000 Subject: [PATCH 2/8] DataExtractor Experiments --- .../domains/validator/service/Validator.ts | 4 +- .../{DataExtractor.ts => DataExtractorOne.ts} | 187 +++++++++------ src/core/util/data/DataExtrator.ts | 223 ++++++++++++++++++ src/core/util/data/DotNotationParser.ts | 89 +++---- 4 files changed, 395 insertions(+), 108 deletions(-) rename src/core/util/data/{DataExtractor.ts => DataExtractorOne.ts} (57%) create mode 100644 src/core/util/data/DataExtrator.ts diff --git a/src/core/domains/validator/service/Validator.ts b/src/core/domains/validator/service/Validator.ts index 6a2c11ec2..9a06defa0 100644 --- a/src/core/domains/validator/service/Validator.ts +++ b/src/core/domains/validator/service/Validator.ts @@ -1,4 +1,4 @@ -import DataExtractor from "@src/core/util/data/DataExtractor"; +import DataExtractorOne from "@src/core/util/data/DataExtractorOne"; import ValidatorResult from "../data/ValidatorResult"; import { IRule, IRulesObject } from "../interfaces/IRule"; @@ -40,7 +40,7 @@ class Validator implements IValidator { // Extract only the data fields that have validation rules defined // This ensures we only validate fields that have rules and maintains // the nested structure (e.g. users.0.name) for proper validation - const extractedData = DataExtractor.reduce(attributes, this.rules); + const extractedData = DataExtractorOne.reduce(attributes, this.rules); // Validate each field with its corresponding rule for (const path of Object.keys(this.rules)) { diff --git a/src/core/util/data/DataExtractor.ts b/src/core/util/data/DataExtractorOne.ts similarity index 57% rename from src/core/util/data/DataExtractor.ts rename to src/core/util/data/DataExtractorOne.ts index 7658901b6..44fef60cd 100644 --- a/src/core/util/data/DataExtractor.ts +++ b/src/core/util/data/DataExtractorOne.ts @@ -42,7 +42,7 @@ export type TData = Record * ``` */ -class DataExtractor { +class DataExtractorOne { /** * Static factory method to create and initialize a DataExtractor instance @@ -51,7 +51,7 @@ class DataExtractor { * @returns Extracted data based on the provided rules */ public static reduce(attributes: TData, paths: TPathsObject): TPathsObject { - return new DataExtractor().init(paths, attributes) + return new DataExtractorOne().init(paths, attributes) } /** @@ -62,9 +62,11 @@ class DataExtractor { */ init(paths: TPathsObject, attributes: object): TPathsObject { return Object.keys(paths).reduce((acc, path) => { + const dotPath = DotNotationParser.parse(path) + return { ...acc, - [path]: this.recursiveReducer(path, acc, path, attributes) as object + [path]: this.recursiveReducer(dotPath, acc, attributes) as object } }, {}) as TPathsObject } @@ -85,22 +87,120 @@ class DataExtractor { * @param attributes - Original source data object * @returns Updated accumulator with extracted data */ - protected recursiveReducer(key: string, acc: object | unknown[], curr: unknown, attributes: object): unknown { + protected recursiveReducer(dotPath: DotNotationParser, acc: object | unknown[], attributes: object): unknown { + + const firstIndex = dotPath.getFirst() + const firstIndexValue = attributes[firstIndex] + const firstIndexNotUndefined = typeof firstIndexValue !== 'undefined' + const firstIndexValueIterable = Array.isArray(firstIndexValue) + const firstIndexIsWildcard = dotPath.getFirst() === '*' + + const nextIndex = dotPath.getNext() + const nextIndexValue = nextIndex ? attributes[firstIndex]?.[nextIndex] :undefined + const nextIndexValueNotUndefined = typeof nextIndexValue !== 'undefined' + const nextIndexValueIterable = Array.isArray(nextIndexValue) + const hasNextIndex = typeof dotPath.getNext() !== 'undefined'; + + const rest = dotPath.getRest() + + const attributesisArray = Array.isArray(attributes) + const attributesisObject = !attributesisArray && typeof attributes === 'object' + const attributesArrayOrObject = attributesisArray || attributesisObject + + console.log('[recursiveReducer] debug', { + dotPath: { + path: dotPath.getPath(), + next: dotPath.getNext(), + rest: dotPath.getRest() + }, + first: { + value: firstIndexValue, + iterable: firstIndexValueIterable, + wildcard: firstIndexIsWildcard + }, + next: { + value: nextIndexValue, + iterable: nextIndexValueIterable + }, + attributes: attributes, + acc: acc, + }) + + if(attributesisArray && firstIndexIsWildcard) { + if(hasNextIndex) { + acc = [] as unknown[] + + (attributes as unknown[]).forEach((attributeItem) => { + const reducedAttributeItem = this.recursiveReducer(DotNotationParser.parse(nextIndex as string), attributeItem as object | unknown[], attributeItem as object) + + acc = [ + ...(acc as unknown[]), + reducedAttributeItem + ] + }) + } + + else { + acc = (attributes as unknown[]).map((attributesisArrayItem) => { + return attributesisArrayItem + }) + } - const parsedPath = DotNotationParser.parse(key) - const containsNestedIndex = parsedPath.isNestedIndex() + attributes = [...(acc as unknown[])] + dotPath.forward() - // If the path contains a nested index, reduce the nested indexes - if (containsNestedIndex) { - acc = this.reduceNestedIndexes(parsedPath, acc, attributes) as object | unknown[] + return this.recursiveReducer(dotPath, acc, attributes) } - // If the path contains a non-nested index, reduce the index - else if (containsNestedIndex === false) { - acc = this.reduceIndex(parsedPath, acc, attributes) as object | unknown[] + + + if(typeof firstIndexValue !== 'undefined') { + acc = { + [firstIndex]: firstIndexValue + } + attributes = attributes[firstIndex] } + if(!firstIndexIsWildcard && attributesisArray && hasNextIndex) { + acc = [ + ...(attributes as unknown[]).map((attributesItem) => { + return this.recursiveReducer(DotNotationParser.parse(nextIndex as string), attributesItem as object | unknown[], attributesItem as object) + + }) + ] - return acc + attributes = [...(firstIndexValue as unknown[])] + } + + if(firstIndexIsWildcard) { + acc = firstIndexValue + attributes = firstIndexValue + dotPath.forward() + return this.recursiveReducer(dotPath, acc, attributes) + } + + + if(firstIndexIsWildcard && hasNextIndex && (nextIndexValueNotUndefined || nextIndexValueNotUndefined)) { + if(firstIndexValueIterable) { + acc = firstIndexValue.map((firstIndexValueItem) => { + return firstIndexValueItem?.[nextIndex as string] + }) + attributes = [...(acc as unknown[])] + } + else { + acc = { + [firstIndex]: firstIndexValue + } + attributes = attributes[firstIndex] + } + } + + dotPath.forward() + + if(acc[firstIndex]) { + return acc + } + + return this.recursiveReducer(dotPath, acc[firstIndex], attributes) } /** @@ -146,8 +246,8 @@ class DataExtractor { // currentIndex = "users" // nextIndex = "0" // rest = "name" - const currentIndex = parsedPath.getIndex() - const nextIndex = parsedPath.getNextIndex() + const currentIndex = parsedPath.getFirst() + const nextIndex = parsedPath.getNext() const rest = parsedPath.getRest() // Example: attributes = [{ name: "John" }, { name: "Jane" }] @@ -155,26 +255,8 @@ class DataExtractor { // isObject = true const isArray = Array.isArray(attributes) const isObject = !isArray && typeof attributes === 'object' + const arrayOrObject = isArray || isObject - // This block handles wildcard paths like "users.*.name" - // When a wildcard (*) is encountered: - // 1. Parse the remaining path after the wildcard (e.g. "name") - // 2. Initialize an empty array at the current index (e.g. users: []) - // 3. Recursively process each item in the array at attributes[currentIndex] - // For example, with data {users: [{name: "John"}, {name: "Jane"}]} - // and path "users.*.name", this will extract all user names into an array - if (parsedPath.isAll()) { - const allParsedPath = DotNotationParser.parse(rest) - const allNextPath = allParsedPath.getNextIndex() - - acc = { - ...acc, - [currentIndex]: [] - } - - return this.recursiveReducer(allNextPath.toString(), acc[currentIndex], attributes[currentIndex], attributes[currentIndex]) - } - // If the current index is undefined, return the accumulator as is if (attributes[currentIndex] === undefined) { return acc @@ -207,39 +289,12 @@ class DataExtractor { } } - // Recursively reduce the rest of the path - return this.recursiveReducer(rest, acc[currentIndex], attributes[currentIndex][nextIndex], attributes[currentIndex]) - - - } - - /** - * Handles wildcard paths ("users.*") by extracting all items at the current index - * - * For example, with data like: { users: [{ name: "John" }, { name: "Jane" }] } - * And rule key like: "users.*" - * This method will extract all items at the users index - * - * @param parsedPath - The parsed validation rule key - * @param acc - The accumulator object being built up - * @param attributes - The original data object being validated - * @returns The updated accumulator with validated data from the wildcard path - */ - protected reduceAll(parsedPath: DotNotationParser, acc: object, attributes: object) { - if (!parsedPath.isAll()) { - return acc; - } - - const index = parsedPath.getIndex() - const nextIndex = parsedPath.getNextIndexSafe() - const allParsedPath = DotNotationParser.parse(parsedPath.getRest()) - - acc = { - ...acc, - [index]: [] + if(typeof rest === 'undefined') { + return acc } - return this.recursiveReducer(allParsedPath.getRest(), acc[index], attributes[index], attributes[index]) + // Recursively reduce the rest of the path + return this.recursiveReducer(DotNotationParser.parse(rest), acc[currentIndex], attributes[currentIndex]) } @@ -259,4 +314,4 @@ class DataExtractor { } -export default DataExtractor \ No newline at end of file +export default DataExtractorOne \ No newline at end of file diff --git a/src/core/util/data/DataExtrator.ts b/src/core/util/data/DataExtrator.ts new file mode 100644 index 000000000..baa426239 --- /dev/null +++ b/src/core/util/data/DataExtrator.ts @@ -0,0 +1,223 @@ +import DotNotationParser from "./DotNotationParser" + +type TStateName = 'INDEX' | 'WILDCARD' | 'SKIPPING_WILDCARD' | 'NEXT' | 'EXIT' + +type TState = { + type: TStateName | null, + dotPath: DotNotationParser, + acc: unknown, + attributes: unknown +} + + +const states: Record = { + INDEX: 'INDEX', + WILDCARD: 'WILDCARD', + SKIPPING_WILDCARD: 'SKIPPING_WILDCARD', + NEXT: 'NEXT', + EXIT: 'EXIT' +} + +type TDataExtractorOptions = { + paths: Record + data: unknown; +} + + +class DataExtractor { + + public static reduce(data: TDataExtractorOptions['data'], paths: TDataExtractorOptions['paths']): unknown { + return new DataExtractor().init({ + data, + paths + }) + } + + public init(options: TDataExtractorOptions): unknown { + const { paths, data } = options; + const pathKeys = Object.keys(paths) + + return pathKeys.reduce((acc, path) => { + + return this.reducer(path, acc, data) + + // if(Array.isArray(data)) { + // return (data as unknown[]).map(dataItem => { + // return this.reducer(path, acc, dataItem) + // }) + // } + // else if(typeof data === 'object') { + // return Object.keys(data as object).map((dataKey) => { + // return this.reducer(path, acc, (data as object)[dataKey]) + // }) + // } + + // return acc + }, {}) + } + + reducer(path: string, acc: unknown, attributes: unknown, recursionLevel = 1) { + const state = this.getState(path, acc, attributes) + + console.log('[DataExtractor] reducer path', path) + console.log('[DataExtractor] reducer state', state) + + // PATH steps + // 'users' -> * -> posts -> title + + // CURRENTE EXAMPLE + // { users: [ { name: string, age: number}, ...] } + + // Reducing INDEXES + // Condition: index is string or number, does not equal *, must existpe + // If the attributes is not an object or array, return attribute + // If the attributes is an array of objects, we should iterate through and map + if(state.type === states.INDEX) { + return this.reduceAttributes(state.dotPath.getFirst() as string, state.dotPath.getNext(), attributes, recursionLevel) + } + + // Reducing SKIPPING WILDCARDS + // Condition: index is * + // Nothing to do, return the current attribtues + if(state.type === states.SKIPPING_WILDCARD) { + return attributes + } + + // Reducing WILDCARDS + // Condition: previous INDEX must be a wildcard + // Condition: attributes must be an array + // Possibly an object, with a matching object[index] + if(state.type === states.WILDCARD) { + return this.reduceAttributes(state.dotPath.getNext() as string, state.dotPath.getNext(), attributes, recursionLevel) + } + + + // RECURSIVE + // Condition: Contains next index + // execute forward for dotPath, storing previous index + if(state.type === states.NEXT) { + return this.reducer(state.dotPath.getNext() as string, acc, attributes, recursionLevel + 1) + } + + // EXITING + // Condition: No next index + // Return value + console.log('[DataExtractor] exit') + + return acc + } + + reduceAttributes(target: string, nextTarget: string | number |undefined, attributes: unknown, recursionLevel = 1) { + + if(typeof attributes === 'object' && attributes?.[target] && typeof nextTarget !== 'undefined') { + return this.reduceAttributes(nextTarget as string, attributes[target], recursionLevel + 1) + } + + if(typeof attributes === 'object' && attributes?.[target]) { + return attributes[target] + } + + if(Array.isArray(attributes)) { + return this.reduceArray(target, nextTarget, attributes, recursionLevel) + } + + return attributes + } + + reduceArray(target: string, nextTarget: string | number |undefined, attributes: unknown, recursionLevel = 1) { + let acc: unknown[] | object = [] + + const attributesIsArray = Array.isArray(attributes) + const attributesIsObject = typeof attributes === 'object' && !attributesIsArray + const containsNestedArrays = attributesIsArray && (attributes as unknown[]).every(attr => Array.isArray(attr)) + const containsNestedObjects = attributesIsArray && (attributes as unknown[]).every(attr => typeof attr === 'object') + + if(attributesIsObject && attributes?.[target]) { + return attributes[target] + } + + if(!attributesIsArray) { + return attributes + } + + if(containsNestedArrays) { + acc = [] as unknown[] + + (attributes as unknown[]).forEach(array => { + + (array as unknown[]).forEach(attr => { + acc = [ + ...(acc as unknown[]), + this.reduceArray(target, nextTarget, attr, recursionLevel + 1) + ] + }) + }) + } + if(containsNestedObjects) { + acc = [] as unknown[] + + (attributes as unknown[]).forEach(obj => { + acc = [ + ...(acc as unknown[]), + this.reduceAttributes(target, nextTarget, obj as object, recursionLevel + 1) + ] + }) + + } + + return this.reduceArray(target, nextTarget, acc, recursionLevel + 1) + } + + protected getState(path: string, acc: unknown, attributes: unknown) { + const dotPath = DotNotationParser.parse(path) + const index = dotPath.getFirst() + const nextIndex = dotPath.getNext() + const previousIndex = dotPath.getPrevious() + + const indexIsWildcard = index === '*' + const previousIsWildcard = previousIndex === '*'; + + const attributesStringOrNumber = typeof attributes === 'string' || typeof attributes === 'number'; + const attributesArrayOrObject = Array.isArray(attributes) || typeof attributes === 'object' + const attributesIndexExists = typeof attributes?.[index] !== 'undefined' + const attributesIndexValid = attributesStringOrNumber && attributesIndexExists + + // State object + const state: TState = { + type: states.EXIT, + dotPath, + acc, + attributes + } + + // INDEX state + if(attributesIndexValid || attributesArrayOrObject) { + state.type = states.INDEX + return state + } + + + // SKIPPING WILDCARD state + if(indexIsWildcard) { + state.type = states.SKIPPING_WILDCARD + return state + } + + // WILDCARD state + if(previousIsWildcard && attributesArrayOrObject) { + state.type = states.WILDCARD + return state + } + + // NEXT state + if(nextIndex) { + state.type = states.NEXT + return state + } + + return state + } + +} + +export default DataExtractor \ No newline at end of file diff --git a/src/core/util/data/DotNotationParser.ts b/src/core/util/data/DotNotationParser.ts index adc40afe7..8d164dda4 100644 --- a/src/core/util/data/DotNotationParser.ts +++ b/src/core/util/data/DotNotationParser.ts @@ -9,6 +9,8 @@ export type DotNotationParserOptions = { /** The current index/key being accessed */ index?: string | number; + previousIndex?: string | number; + /** The next index/key in a nested path */ nextIndex?: string | number; @@ -20,6 +22,9 @@ export type DotNotationParserOptions = { /** The parts of the path */ parts: string[]; + + /** The full path */ + path: string; } /** @@ -64,9 +69,14 @@ class DotNotationParser { * @param path - The path to parse * @returns A new DotNotationParser instance with parsed options */ - public parse(path: string): DotNotationParser { + public parse(path: string, previousIndex?: string | number): DotNotationParser { const current = new DotNotationParser() + current.appendOptions({ + previousIndex, + path: path + }) + if(path.includes('.')) { return this.parseNextIndex(path, current) } @@ -103,20 +113,12 @@ class DotNotationParser { throw new Error('path does not have a next index') } - const pathParts = path.split('.') - const pathPart0 = pathParts[0] - const pathPart1 = pathParts[1] - const rest = [...pathParts].splice(1).join('.') - const index = this.parseIndex(pathPart0) - const nextIndex = this.parseIndex(pathPart1) - const all = pathPart1 === '*' + const parts = path.split('.') + const rest = [...parts].splice(1).join('.') current.appendOptions({ - index, - nextIndex, - all, rest, - parts: pathParts + parts }) return current @@ -140,34 +142,29 @@ class DotNotationParser { * @returns The current index as a string or number * @throws Error if index is not defined */ - public getIndex(): string | number { - if(typeof this.options.index === 'undefined') { - throw new DotNotationParserException('index is not defined') + public getFirst(): string | number { + if(typeof this.options?.parts?.[0] === 'undefined') { + throw new DotNotationParserException('first is not defined') } - return this.options.index - + return this.options.parts[0] } - /** - * Gets the next index from the parser options - * @returns The next index as a string or number - * @throws Error if nextIndex is not defined - */ - public getNextIndex(): string | number { - if(typeof this.options.nextIndex === 'undefined') { - throw new DotNotationParserException('nextIndex is not defined') - } - return this.options.nextIndex + + protected getNth(index: number): string | undefined { + return this.options.parts[index] ?? undefined } /** * Gets the next index from the parser options * @returns The next index as a string or number */ - public getNextIndexSafe(): string | number | undefined { - return this.options.nextIndex + public getNext(): string | number | undefined { + return this.options.parts[1] } + public getPrevious(): string | number | undefined { + return this.options.previousIndex + } /** * Checks if the current path has a nested index @@ -178,23 +175,13 @@ class DotNotationParser { return typeof this.options.index !== 'undefined' && typeof this.options.nextIndex !== 'undefined' } - /** - * Checks if the current path uses a wildcard - * @returns True if the all flag is set - */ - public isAll() { - return this.options.all - } /** * Gets the remaining unparsed portion of the path * @returns The remaining path string * @throws Error if rest is not defined */ - public getRest(): string { - if(typeof this.options.rest === 'undefined') { - throw new DotNotationParserException('rest is not defined') - } + public getRest(): string | undefined { return this.options.rest } @@ -206,9 +193,31 @@ class DotNotationParser { return this.options.parts } + public getPath(): string { + if(typeof this.options.path === 'undefined') { + throw new DotNotationParserException('path is not defined') + } + return this.options.path + } + + public getOptions(): DotNotationParserOptions { + return this.options + } + + public forward(steps: number = 1): DotNotationParser { + let current = new DotNotationParser().parse(this.options.path) + for(let i = 0; i < steps; i++) { + if(typeof current.getRest() === 'undefined') { + continue; + } + current = new DotNotationParser().parse(current.getRest() as string) + } + this.options = {...current.getOptions() } + return this + } } From 0869362e0a5a85a6d438fb7b2dd61d9d70fbbb6e Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 10 Feb 2025 17:24:10 +0000 Subject: [PATCH 3/8] Refactor DataExtractor and DotNotationParser for improved data extraction - Enhance DataExtractor with more robust recursive path processing - Improve state management and attribute reduction logic - Add comprehensive documentation and type annotations - Optimize array and object traversal methods - Update DotNotationParser with additional utility methods and error handling --- src/core/util/data/DataExtractorOne.ts | 2 +- src/core/util/data/DataExtrator.ts | 203 ++++++++++++++++++------ src/core/util/data/DotNotationParser.ts | 26 ++- 3 files changed, 179 insertions(+), 52 deletions(-) diff --git a/src/core/util/data/DataExtractorOne.ts b/src/core/util/data/DataExtractorOne.ts index 44fef60cd..a3176f955 100644 --- a/src/core/util/data/DataExtractorOne.ts +++ b/src/core/util/data/DataExtractorOne.ts @@ -109,7 +109,7 @@ class DataExtractorOne { console.log('[recursiveReducer] debug', { dotPath: { - path: dotPath.getPath(), + path: dotPath.getFullPath(), next: dotPath.getNext(), rest: dotPath.getRest() }, diff --git a/src/core/util/data/DataExtrator.ts b/src/core/util/data/DataExtrator.ts index baa426239..ab1080165 100644 --- a/src/core/util/data/DataExtrator.ts +++ b/src/core/util/data/DataExtrator.ts @@ -9,7 +9,6 @@ type TState = { attributes: unknown } - const states: Record = { INDEX: 'INDEX', WILDCARD: 'WILDCARD', @@ -23,80 +22,110 @@ type TDataExtractorOptions = { data: unknown; } - +/** + * DataExtractor provides functionality to extract values from nested data structures using dot notation paths + * + * This class allows you to extract specific values from complex nested objects and arrays using + * dot notation paths with support for wildcards (*). It's particularly useful for: + * - Extracting values from deeply nested objects + * - Processing arrays of objects to get specific fields + * - Handling dynamic paths with wildcards + * + * @example + * const data = { + * users: [ + * { name: 'John', posts: [{ title: 'Post 1' }] }, + * { name: 'Jane', posts: [{ title: 'Post 2' }] } + * ] + * }; + * + * const paths = { + * 'users.*.name' : unknown, // Extract all user names + * 'users.*.posts.*.title' : unknown // Extract all post titles + * }; + * + * const extracted = DataExtractor.reduce(data, paths); + * // Result: + + * // { + * // 'users.*.name': ['John', 'Jane'], + * // 'users.*.posts.*.title': ['Post 1', 'Post 2'] + * // } + */ class DataExtractor { + /** + * Static factory method to create and initialize a DataExtractor instance + * + * @param data - The source data object to extract values from + * @param paths - Object containing dot notation paths as keys + * @returns Extracted values mapped to their corresponding paths + */ public static reduce(data: TDataExtractorOptions['data'], paths: TDataExtractorOptions['paths']): unknown { return new DataExtractor().init({ data, paths }) } - + + /** + * Initializes data extraction by processing multiple dot notation paths against a data object + * + * @param options - Configuration options for data extraction + * @param options.data - The source data object to extract values from + * @param options.paths - Object containing dot notation paths as keys + * @returns An object containing the extracted values mapped to their paths + */ public init(options: TDataExtractorOptions): unknown { const { paths, data } = options; const pathKeys = Object.keys(paths) return pathKeys.reduce((acc, path) => { - return this.reducer(path, acc, data) - - // if(Array.isArray(data)) { - // return (data as unknown[]).map(dataItem => { - // return this.reducer(path, acc, dataItem) - // }) - // } - // else if(typeof data === 'object') { - // return Object.keys(data as object).map((dataKey) => { - // return this.reducer(path, acc, (data as object)[dataKey]) - // }) - // } - - // return acc }, {}) } + /** + * Core recursive function that processes each path segment and extracts values + * + * @param path - Current dot notation path being processed + * @param acc - Accumulator holding processed values + * @param attributes - Current data being processed + * @param recursionLevel - Current depth of recursion (for debugging) + * @returns Processed value(s) for the current path + */ reducer(path: string, acc: unknown, attributes: unknown, recursionLevel = 1) { const state = this.getState(path, acc, attributes) - console.log('[DataExtractor] reducer path', path) - console.log('[DataExtractor] reducer state', state) - - // PATH steps - // 'users' -> * -> posts -> title - - // CURRENTE EXAMPLE - // { users: [ { name: string, age: number}, ...] } - // Reducing INDEXES // Condition: index is string or number, does not equal *, must existpe // If the attributes is not an object or array, return attribute // If the attributes is an array of objects, we should iterate through and map if(state.type === states.INDEX) { - return this.reduceAttributes(state.dotPath.getFirst() as string, state.dotPath.getNext(), attributes, recursionLevel) + return this.reduceAttributes(state, recursionLevel) } // Reducing SKIPPING WILDCARDS // Condition: index is * // Nothing to do, return the current attribtues if(state.type === states.SKIPPING_WILDCARD) { - return attributes + return this.reducer(state.dotPath.getRest() as string, state.acc, state.attributes, recursionLevel + 1) } + // Reducing WILDCARDS // Condition: previous INDEX must be a wildcard // Condition: attributes must be an array // Possibly an object, with a matching object[index] if(state.type === states.WILDCARD) { - return this.reduceAttributes(state.dotPath.getNext() as string, state.dotPath.getNext(), attributes, recursionLevel) + return this.reduceAttributes(state, recursionLevel) } - // RECURSIVE // Condition: Contains next index // execute forward for dotPath, storing previous index if(state.type === states.NEXT) { - return this.reducer(state.dotPath.getNext() as string, acc, attributes, recursionLevel + 1) + return this.reduceAttributes(state, recursionLevel + 1) } // EXITING @@ -107,29 +136,71 @@ class DataExtractor { return acc } - reduceAttributes(target: string, nextTarget: string | number |undefined, attributes: unknown, recursionLevel = 1) { - - if(typeof attributes === 'object' && attributes?.[target] && typeof nextTarget !== 'undefined') { - return this.reduceAttributes(nextTarget as string, attributes[target], recursionLevel + 1) + /** + * Processes attribute values based on the current state and path + * + * @param state - Current state object containing path and data information + * @param recursionLevel - Current depth of recursion + * @returns Processed attribute values + */ + reduceAttributes(state: TState, recursionLevel = 1) { + + const target = state.dotPath.getFirst() + const nextTarget = state.dotPath.getNext() + const attributes = state.attributes + const rest = state.dotPath.getRest() + const nextRecursionLevel = recursionLevel + 1 + + // If the attributes is an object and the target exists and there is a next target, reduce the attributes + if(typeof attributes === 'object' && attributes?.[target] && typeof nextTarget !== 'undefined' && nextTarget !== '*') { + return this.reducer( + state.dotPath.getRest() as string, + state.acc, + attributes[target], + nextRecursionLevel + ) } - if(typeof attributes === 'object' && attributes?.[target]) { + // If the attributes is an object and the target exists and there is no next target, return the target + if(typeof attributes === 'object' && attributes?.[target] && typeof rest === 'undefined') { return attributes[target] + } + + // If the attributes is an object and the target exists and there is a next target, reduce the attributes + if(typeof attributes === 'object' && attributes?.[target] && typeof rest !== 'undefined') { + return this.reducer( + state.dotPath.getRest() as string, + state.acc, + attributes[target], + nextRecursionLevel + ) } + // If the attributes is an array, reduce the array if(Array.isArray(attributes)) { - return this.reduceArray(target, nextTarget, attributes, recursionLevel) + return this.reduceArray(state, recursionLevel) } return attributes } - reduceArray(target: string, nextTarget: string | number |undefined, attributes: unknown, recursionLevel = 1) { + /** + * Processes array values by applying the extraction logic to each element + * + * @param state - Current state object containing path and data information + * @param recursionLevel - Current depth of recursion + * @returns Array of processed values + */ + reduceArray(state: TState, recursionLevel = 1) { let acc: unknown[] | object = [] + const attributes = state.attributes + const target = state.dotPath.getFirst() + const attributesIsArray = Array.isArray(attributes) const attributesIsObject = typeof attributes === 'object' && !attributesIsArray const containsNestedArrays = attributesIsArray && (attributes as unknown[]).every(attr => Array.isArray(attr)) + const containsNestedObjects = attributesIsArray && (attributes as unknown[]).every(attr => typeof attr === 'object') if(attributesIsObject && attributes?.[target]) { @@ -146,28 +217,63 @@ class DataExtractor { (attributes as unknown[]).forEach(array => { (array as unknown[]).forEach(attr => { + // Update the state + state = this.updateState(state, { + attributes: attr as unknown[], + acc + }) + acc = [ ...(acc as unknown[]), - this.reduceArray(target, nextTarget, attr, recursionLevel + 1) + this.reduceArray(state, recursionLevel + 1) ] }) }) } + if(containsNestedObjects) { acc = [] as unknown[] (attributes as unknown[]).forEach(obj => { + + // Update the state + state = this.updateState(state, { + attributes: obj as object, + acc + }) + acc = [ ...(acc as unknown[]), - this.reduceAttributes(target, nextTarget, obj as object, recursionLevel + 1) + this.reduceAttributes(state, recursionLevel + 1) ] }) - } - return this.reduceArray(target, nextTarget, acc, recursionLevel + 1) + return acc + } + + /** + * Updates the current state with new values + * + * @param state - Current state object + * @param update - Partial state updates to apply + * @returns Updated state object + */ + protected updateState(state: TState, update: Partial): TState { + return { + ...state, + ...update + } } + /** + * Determines the current state based on path and attributes + * + * @param path - Current dot notation path + * @param acc - Current accumulator + * @param attributes - Current attributes being processed + * @returns State object with type and processing information + */ protected getState(path: string, acc: unknown, attributes: unknown) { const dotPath = DotNotationParser.parse(path) const index = dotPath.getFirst() @@ -189,6 +295,12 @@ class DataExtractor { acc, attributes } + + // SKIPPING WILDCARD state + if(indexIsWildcard) { + state.type = states.SKIPPING_WILDCARD + return state + } // INDEX state if(attributesIndexValid || attributesArrayOrObject) { @@ -196,13 +308,6 @@ class DataExtractor { return state } - - // SKIPPING WILDCARD state - if(indexIsWildcard) { - state.type = states.SKIPPING_WILDCARD - return state - } - // WILDCARD state if(previousIsWildcard && attributesArrayOrObject) { state.type = states.WILDCARD diff --git a/src/core/util/data/DotNotationParser.ts b/src/core/util/data/DotNotationParser.ts index 8d164dda4..366add4f9 100644 --- a/src/core/util/data/DotNotationParser.ts +++ b/src/core/util/data/DotNotationParser.ts @@ -9,6 +9,7 @@ export type DotNotationParserOptions = { /** The current index/key being accessed */ index?: string | number; + /** The previous index/key in a nested path */ previousIndex?: string | number; /** The next index/key in a nested path */ @@ -83,7 +84,8 @@ class DotNotationParser { current.appendOptions({ index: this.parseIndex(path), - parts: [path] + parts: [path], + rest: undefined }) return current @@ -162,6 +164,10 @@ class DotNotationParser { return this.options.parts[1] } + /** + * Gets the previous index from the parser options + * @returns The previous index as a string or number + */ public getPrevious(): string | number | undefined { return this.options.previousIndex } @@ -193,20 +199,36 @@ class DotNotationParser { return this.options.parts } - public getPath(): string { + /** + * Gets the full path + * @returns The full path + * @throws Error if path is not defined + */ + public getFullPath(): string { if(typeof this.options.path === 'undefined') { throw new DotNotationParserException('path is not defined') } return this.options.path } + /** + * Gets the options + * @returns The options + */ + public getOptions(): DotNotationParserOptions { return this.options } + /** + * Moves the parser forward by a specified number of steps + * @param steps - The number of steps to move forward + * @returns The updated DotNotationParser instance + */ public forward(steps: number = 1): DotNotationParser { let current = new DotNotationParser().parse(this.options.path) + for(let i = 0; i < steps; i++) { if(typeof current.getRest() === 'undefined') { continue; From dd7a32050aa3916b73ce4a41eb23d74d3ac90459 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 10 Feb 2025 17:40:04 +0000 Subject: [PATCH 4/8] Enhance DotNotationParser with improved parsing and testing - Refactor parse method to handle complex dot notation paths - Add support for previous index and next index parsing - Improve data type parsing with parseDataType method - Implement comprehensive test suite for DotNotationParser - Update method signatures and error handling - Add new utility methods for path traversal and extraction --- src/core/util/data/DotNotationParser.ts | 32 ++++----- src/tests/utils/dotNotationParser.test.ts | 88 +++++++++++++++++++++++ 2 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 src/tests/utils/dotNotationParser.test.ts diff --git a/src/core/util/data/DotNotationParser.ts b/src/core/util/data/DotNotationParser.ts index 366add4f9..41c48b7aa 100644 --- a/src/core/util/data/DotNotationParser.ts +++ b/src/core/util/data/DotNotationParser.ts @@ -51,10 +51,11 @@ class DotNotationParser { * @param path - The path to parse * @returns A new DotNotationParser instance with parsed options */ - public static parse(path: string) { - return new DotNotationParser().parse(path) + public static parse(path: string, previousIndex?: string | number) { + return new DotNotationParser().parse(path, previousIndex) } + /** * Constructor for DotNotationParser * @param options - The options for the parser @@ -72,20 +73,16 @@ class DotNotationParser { */ public parse(path: string, previousIndex?: string | number): DotNotationParser { const current = new DotNotationParser() + const parts = path.split('.') + const rest = parts.length > 1 ? [...parts].splice(1).join('.') : undefined current.appendOptions({ - previousIndex, - path: path - }) - - if(path.includes('.')) { - return this.parseNextIndex(path, current) - } - - current.appendOptions({ - index: this.parseIndex(path), - parts: [path], - rest: undefined + path, + previousIndex: previousIndex ? this.parseDataType(previousIndex as string) : undefined, + index: this.parseDataType(parts[0]), + nextIndex: parts[1] ? this.parseDataType(parts[1]) : undefined, + parts, + rest }) return current @@ -96,7 +93,7 @@ class DotNotationParser { * @param value - The value to parse * @returns The parsed index as either a number or string */ - protected parseIndex(value: string): string | number { + protected parseDataType(value: string): string | number { if(isNaN(Number(value))) { return value } @@ -145,13 +142,14 @@ class DotNotationParser { * @throws Error if index is not defined */ public getFirst(): string | number { - if(typeof this.options?.parts?.[0] === 'undefined') { + if(typeof this.options.index === 'undefined') { throw new DotNotationParserException('first is not defined') } - return this.options.parts[0] + return this.options.index } + protected getNth(index: number): string | undefined { return this.options.parts[index] ?? undefined } diff --git a/src/tests/utils/dotNotationParser.test.ts b/src/tests/utils/dotNotationParser.test.ts new file mode 100644 index 000000000..2311b1408 --- /dev/null +++ b/src/tests/utils/dotNotationParser.test.ts @@ -0,0 +1,88 @@ + +import { describe, expect, test } from '@jest/globals'; +import DotNotationParser from '@src/core/util/data/DotNotationParser'; + +describe('DotNotationParser', () => { + + test('should parse simple key', () => { + const parser = DotNotationParser.parse('users'); + + expect(parser.getFirst()).toBe('users'); + expect(parser.getNext()).toBeUndefined(); + expect(parser.getRest()).toBeUndefined(); + expect(parser.getParts()).toEqual(['users']); + }); + + test('should parse numeric key', () => { + const parser = DotNotationParser.parse('0'); + + expect(parser.getFirst()).toBe(0); + expect(parser.getNext()).toBeUndefined(); + expect(parser.getRest()).toBeUndefined(); + }); + + test('should parse nested path', () => { + const parser = DotNotationParser.parse('users.name'); + + expect(parser.getFirst()).toBe('users'); + expect(parser.getNext()).toBe('name'); + expect(parser.getRest()).toBe('name'); + expect(parser.getParts()).toEqual(['users', 'name']); + }); + + test('should parse deeply nested path', () => { + const parser = DotNotationParser.parse('users.0.profile.email'); + + expect(parser.getFirst()).toBe('users'); + expect(parser.getNext()).toBe('0'); + expect(parser.getRest()).toBe('0.profile.email'); + expect(parser.getParts()).toEqual(['users', '0', 'profile', 'email']); + }); + + test('should handle wildcard notation', () => { + const parser = DotNotationParser.parse('users.*.name'); + + expect(parser.getFirst()).toBe('users'); + expect(parser.getNext()).toBe('*'); + expect(parser.getRest()).toBe('*.name'); + expect(parser.getParts()).toEqual(['users', '*', 'name']); + }); + + test('should forward parser by steps', () => { + const parser = DotNotationParser.parse('users.profile.email'); + + parser.forward(1); + expect(parser.getFirst()).toBe('profile'); + expect(parser.getRest()).toBe('email'); + + parser.forward(1); + expect(parser.getFirst()).toBe('email'); + expect(parser.getRest()).toBeUndefined(); + }); + + test('should get full path', () => { + const path = 'users.0.profile.email'; + const parser = DotNotationParser.parse(path); + + expect(parser.getFullPath()).toBe(path); + }); + + test('should throw error when getting undefined path', () => { + const parser = new DotNotationParser(); + + expect(() => parser.getFullPath()).toThrow('path is not defined'); + }); + + test('should throw error when getting undefined first element', () => { + const parser = new DotNotationParser(); + + expect(() => parser.getFirst()).toThrow('first is not defined'); + }); + + test('should handle previous index', () => { + const parser = DotNotationParser.parse('name', 'users'); + + expect(parser.getPrevious()).toBe('users'); + expect(parser.getFirst()).toBe('name'); + }); +}); \ No newline at end of file From a75fa236a11542e6c467831c2190401c69ef4bda Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 10 Feb 2025 20:07:01 +0000 Subject: [PATCH 5/8] Add DotNotationDataExtrator for advanced data extraction using dot notation - Implement comprehensive data extraction utility with recursive path processing - Support complex nested data traversal with wildcard and index-based extraction - Add robust state management for handling various data structures - Provide static methods for single and multiple path extraction - Implement detailed logic for processing arrays, objects, and nested paths --- src/core/util/data/DotNotationDataExtrator.ts | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 src/core/util/data/DotNotationDataExtrator.ts diff --git a/src/core/util/data/DotNotationDataExtrator.ts b/src/core/util/data/DotNotationDataExtrator.ts new file mode 100644 index 000000000..04a91417f --- /dev/null +++ b/src/core/util/data/DotNotationDataExtrator.ts @@ -0,0 +1,308 @@ +import DotNotationParser from "./DotNotationParser" + +type TStateName = 'INDEX' | 'WILDCARD' | 'SKIPPING_WILDCARD' | 'NEXT' | 'EXIT' + +type TState = { + type: TStateName | null, + dotPath: DotNotationParser, + acc: unknown, + attributes: unknown +} + +const states: Record = { + INDEX: 'INDEX', + WILDCARD: 'WILDCARD', + SKIPPING_WILDCARD: 'SKIPPING_WILDCARD', + NEXT: 'NEXT', + EXIT: 'EXIT' +} + +type TDotNotationDataExtratorOptions = { + paths: string[] + data: unknown; +} + +type TDotNotationDataExtratorResult = Record + + +class DotNotationDataExtrator { + + /** + * Static factory method to create and initialize a DataExtractor instance + * + * @param data - The source data object to extract values from + * @param path - Object containing dot notation paths as keys + * @returns Extracted values mapped to their corresponding paths + */ + public static reduceOne(data: TDotNotationDataExtratorOptions['data'], path: string): TDotNotationDataExtratorResult { + const result = new DotNotationDataExtrator().init({ + data, + paths: [path] + }) + + return { + [path]: result[path] + } + } + + /** + * Static factory method to create and initialize a DataExtractor instance + * + * @param data - The source data object to extract values from + * @param paths - Object containing dot notation paths as keys + * @returns Extracted values mapped to their corresponding paths + */ + public static reduceMany(data: TDotNotationDataExtratorOptions['data'], paths: TDotNotationDataExtratorOptions['paths']): TDotNotationDataExtratorResult { + return new DotNotationDataExtrator().init({ + data, + paths + }) + } + + /** + * Initializes data extraction by processing multiple dot notation paths against a data object + * + * @param options - Configuration options for data extraction + * @param options.data - The source data object to extract values from + * @param options.paths - Object containing dot notation paths as keys + * @returns An object containing the extracted values mapped to their paths + */ + public init(options: TDotNotationDataExtratorOptions): TDotNotationDataExtratorResult { + const { paths, data } = options; + + return paths.reduce((acc, path) => { + acc[path] = this.reducer(path, acc, data) + return acc + }, {}) + + } + + /** + * Core recursive function that processes each path segment and extracts values + * + * @param path - Current dot notation path being processed + * @param acc - Accumulator holding processed values + * @param attributes - Current data being processed + * @param recursionLevel - Current depth of recursion (for debugging) + * @returns Processed value(s) for the current path + */ + reducer(path: string, acc: unknown, attributes: unknown, recursionLevel = 1) { + const state = this.getState(path, acc, attributes) + + // Reducing INDEXES + // Condition: index is string or number, does not equal *, must existpe + // If the attributes is not an object or array, return attribute + // If the attributes is an array of objects, we should iterate through and map + if(state.type === states.INDEX) { + return this.reduceAttributes(state, recursionLevel) + } + + // Reducing SKIPPING WILDCARDS + // Condition: index is * + // Nothing to do, return the current attribtues + if(state.type === states.SKIPPING_WILDCARD) { + return this.reducer(state.dotPath.getRest() as string, state.acc, state.attributes, recursionLevel + 1) + } + + + // Reducing WILDCARDS + // Condition: previous INDEX must be a wildcard + // Condition: attributes must be an array + // Possibly an object, with a matching object[index] + if(state.type === states.WILDCARD) { + return this.reduceAttributes(state, recursionLevel) + } + + // RECURSIVE + // Condition: Contains next index + // execute forward for dotPath, storing previous index + if(state.type === states.NEXT) { + return this.reduceAttributes(state, recursionLevel + 1) + } + + // EXITING + // Condition: No next index + // Return value + console.log('[DataExtractor] exit') + + return acc + } + + /** + * Processes attribute values based on the current state and path + * + * @param state - Current state object containing path and data information + * @param recursionLevel - Current depth of recursion + * @returns Processed attribute values + */ + reduceAttributes(state: TState, recursionLevel = 1) { + + const target = state.dotPath.getFirst() + const nextTarget = state.dotPath.getNext() + const attributes = state.attributes + const rest = state.dotPath.getRest() + const nextRecursionLevel = recursionLevel + 1 + + // If the attributes is an object and the target exists and there is a next target, reduce the attributes + if(typeof attributes === 'object' && attributes?.[target] && typeof nextTarget !== 'undefined' && nextTarget !== '*') { + return this.reducer( + state.dotPath.getRest() as string, + state.acc, + attributes[target], + nextRecursionLevel + ) + } + + // If the attributes is an object and the target exists and there is no next target, return the target + if(typeof attributes === 'object' && attributes?.[target] && typeof rest === 'undefined') { + return attributes[target] + } + + // If the attributes is an object and the target exists and there is a next target, reduce the attributes + if(typeof attributes === 'object' && attributes?.[target] && typeof rest !== 'undefined') { + return this.reducer( + state.dotPath.getRest() as string, + state.acc, + attributes[target], + nextRecursionLevel + ) + } + + // If the attributes is an array, reduce the array + if(Array.isArray(attributes)) { + return this.reduceArray( + this.updateState(state, { + attributes: attributes as unknown[], + acc: [] + }), + recursionLevel + ) + } + + + return attributes + } + + /** + * Processes array values by applying the extraction logic to each element + * + * @param state - Current state object containing path and data information + * @param recursionLevel - Current depth of recursion + * @returns Array of processed values + */ + reduceArray(state: TState, recursionLevel = 1) { + const acc: unknown[] = []; + const target = state.dotPath.getFirst(); + const attributes = state.attributes; + + const attributesIsArray = Array.isArray(attributes); + const attributesIsObject = typeof attributes === 'object' && !attributesIsArray; + + // Handle object case + if(attributesIsObject && attributes?.[target]) { + return attributes[target]; + } + + // Return early if not array + if(!attributesIsArray) { + return attributes; + } + + // Process each item in the array + (attributes as unknown[]).forEach(attr => { + const result = this.reduceAttributes( + this.updateState(state, { + attributes: attr, + acc + }), + recursionLevel + 1 + ); + + // If result is an array, spread it into acc, otherwise push the single value + if (Array.isArray(result)) { + acc.push(...result); + } + else { + acc.push(result); + } + }); + + return acc; + } + + /** + + * Updates the current state with new values + + * + * @param state - Current state object + * @param update - Partial state updates to apply + * @returns Updated state object + */ + protected updateState(state: TState, update: Partial): TState { + return { + ...state, + ...update + } + } + + /** + * Determines the current state based on path and attributes + * + * @param path - Current dot notation path + * @param acc - Current accumulator + * @param attributes - Current attributes being processed + * @returns State object with type and processing information + */ + protected getState(path: string, acc: unknown, attributes: unknown) { + const dotPath = DotNotationParser.parse(path) + const index = dotPath.getFirst() + const nextIndex = dotPath.getNext() + const previousIndex = dotPath.getPrevious() + + const indexIsWildcard = index === '*' + const previousIsWildcard = previousIndex === '*'; + + const attributesStringOrNumber = typeof attributes === 'string' || typeof attributes === 'number'; + const attributesArrayOrObject = Array.isArray(attributes) || typeof attributes === 'object' + const attributesIndexExists = typeof attributes?.[index] !== 'undefined' + const attributesIndexValid = attributesStringOrNumber && attributesIndexExists + + // State object + const state: TState = { + type: states.EXIT, + dotPath, + acc, + attributes + } + + // SKIPPING WILDCARD state + if(indexIsWildcard) { + state.type = states.SKIPPING_WILDCARD + return state + } + + // INDEX state + if(attributesIndexValid || attributesArrayOrObject) { + state.type = states.INDEX + return state + } + + // WILDCARD state + if(previousIsWildcard && attributesArrayOrObject) { + state.type = states.WILDCARD + return state + } + + // NEXT state + if(nextIndex) { + state.type = states.NEXT + return state + } + + return state + } + +} + +export default DotNotationDataExtrator \ No newline at end of file From 010515bf5f108c95552f7555a54b1df3a7152e9c Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 10 Feb 2025 20:19:59 +0000 Subject: [PATCH 6/8] Refactor DotNotationDataExtrator with simplified state management and improved logic - Remove unnecessary state types (NEXT, EXIT) - Simplify state determination and reduction logic - Improve code readability and reduce complexity - Add comprehensive test suite for data extraction scenarios - Enhance error handling and edge case processing --- src/core/util/data/DotNotationDataExtrator.ts | 146 ++++++------------ .../utils/dotNotationDataExtractor.test.ts | 119 ++++++++++++++ 2 files changed, 169 insertions(+), 96 deletions(-) create mode 100644 src/tests/utils/dotNotationDataExtractor.test.ts diff --git a/src/core/util/data/DotNotationDataExtrator.ts b/src/core/util/data/DotNotationDataExtrator.ts index 04a91417f..dc3533fea 100644 --- a/src/core/util/data/DotNotationDataExtrator.ts +++ b/src/core/util/data/DotNotationDataExtrator.ts @@ -1,6 +1,6 @@ -import DotNotationParser from "./DotNotationParser" +import DotNotationParser from "./DotNotationParser"; -type TStateName = 'INDEX' | 'WILDCARD' | 'SKIPPING_WILDCARD' | 'NEXT' | 'EXIT' +type TStateName = 'INDEX' | 'WILDCARD' | 'SKIPPING_WILDCARD'; type TState = { type: TStateName | null, @@ -13,8 +13,6 @@ const states: Record = { INDEX: 'INDEX', WILDCARD: 'WILDCARD', SKIPPING_WILDCARD: 'SKIPPING_WILDCARD', - NEXT: 'NEXT', - EXIT: 'EXIT' } type TDotNotationDataExtratorOptions = { @@ -79,96 +77,74 @@ class DotNotationDataExtrator { /** * Core recursive function that processes each path segment and extracts values - * - * @param path - Current dot notation path being processed - * @param acc - Accumulator holding processed values - * @param attributes - Current data being processed - * @param recursionLevel - Current depth of recursion (for debugging) - * @returns Processed value(s) for the current path */ reducer(path: string, acc: unknown, attributes: unknown, recursionLevel = 1) { const state = this.getState(path, acc, attributes) - // Reducing INDEXES - // Condition: index is string or number, does not equal *, must existpe - // If the attributes is not an object or array, return attribute - // If the attributes is an array of objects, we should iterate through and map + // Process based on state type: + + // 1. Direct index access - reduce to specific target if(state.type === states.INDEX) { return this.reduceAttributes(state, recursionLevel) } - // Reducing SKIPPING WILDCARDS - // Condition: index is * - // Nothing to do, return the current attribtues + // 2. Skip current wildcard and process next segment if(state.type === states.SKIPPING_WILDCARD) { return this.reducer(state.dotPath.getRest() as string, state.acc, state.attributes, recursionLevel + 1) } - - // Reducing WILDCARDS - // Condition: previous INDEX must be a wildcard - // Condition: attributes must be an array - // Possibly an object, with a matching object[index] + // 3. Process wildcard expansion if(state.type === states.WILDCARD) { return this.reduceAttributes(state, recursionLevel) } - // RECURSIVE - // Condition: Contains next index - // execute forward for dotPath, storing previous index - if(state.type === states.NEXT) { - return this.reduceAttributes(state, recursionLevel + 1) - } - - // EXITING - // Condition: No next index - // Return value - console.log('[DataExtractor] exit') - return acc } /** * Processes attribute values based on the current state and path - * - * @param state - Current state object containing path and data information - * @param recursionLevel - Current depth of recursion - * @returns Processed attribute values */ reduceAttributes(state: TState, recursionLevel = 1) { - const target = state.dotPath.getFirst() const nextTarget = state.dotPath.getNext() const attributes = state.attributes const rest = state.dotPath.getRest() const nextRecursionLevel = recursionLevel + 1 - // If the attributes is an object and the target exists and there is a next target, reduce the attributes - if(typeof attributes === 'object' && attributes?.[target] && typeof nextTarget !== 'undefined' && nextTarget !== '*') { + const isObject = typeof attributes === 'object' + const hasTargetProperty = attributes?.[target] + const hasDefinedNextTarget = typeof nextTarget !== 'undefined' && nextTarget !== '*' + const hasNoRemainingPath = typeof rest === 'undefined' + + // Case 1: Navigate deeper into nested object + const shouldNavigateDeeper = isObject && hasTargetProperty && hasDefinedNextTarget + if(shouldNavigateDeeper) { return this.reducer( - state.dotPath.getRest() as string, + rest as string, state.acc, attributes[target], nextRecursionLevel ) } - // If the attributes is an object and the target exists and there is no next target, return the target - if(typeof attributes === 'object' && attributes?.[target] && typeof rest === 'undefined') { + // Case 2: Return final value when reaching end of path + const shouldReturnFinalValue = isObject && typeof attributes?.[target] !== 'undefined' && hasNoRemainingPath + if(shouldReturnFinalValue) { return attributes[target] } - // If the attributes is an object and the target exists and there is a next target, reduce the attributes - if(typeof attributes === 'object' && attributes?.[target] && typeof rest !== 'undefined') { + // Case 3: Continue traversing object path + const shouldContinueTraversal = isObject && hasTargetProperty && typeof rest !== 'undefined' + if(shouldContinueTraversal) { return this.reducer( - state.dotPath.getRest() as string, - state.acc, - attributes[target], - nextRecursionLevel + rest as string, + state.acc, + attributes[target], + nextRecursionLevel ) } - // If the attributes is an array, reduce the array + // Case 4: Handle array processing if(Array.isArray(attributes)) { return this.reduceArray( this.updateState(state, { @@ -179,16 +155,11 @@ class DotNotationDataExtrator { ) } - - return attributes + return undefined } /** * Processes array values by applying the extraction logic to each element - * - * @param state - Current state object containing path and data information - * @param recursionLevel - Current depth of recursion - * @returns Array of processed values */ reduceArray(state: TState, recursionLevel = 1) { const acc: unknown[] = []; @@ -198,17 +169,18 @@ class DotNotationDataExtrator { const attributesIsArray = Array.isArray(attributes); const attributesIsObject = typeof attributes === 'object' && !attributesIsArray; - // Handle object case - if(attributesIsObject && attributes?.[target]) { + // Handle direct property access on object + const shouldAccessObjectProperty = attributesIsObject && attributes?.[target] + if(shouldAccessObjectProperty) { return attributes[target]; } - // Return early if not array + // Return early if not processing an array if(!attributesIsArray) { return attributes; } - // Process each item in the array + // Process each array element recursively (attributes as unknown[]).forEach(attr => { const result = this.reduceAttributes( this.updateState(state, { @@ -218,7 +190,7 @@ class DotNotationDataExtrator { recursionLevel + 1 ); - // If result is an array, spread it into acc, otherwise push the single value + // Flatten array results or add single values if (Array.isArray(result)) { acc.push(...result); } @@ -231,13 +203,7 @@ class DotNotationDataExtrator { } /** - * Updates the current state with new values - - * - * @param state - Current state object - * @param update - Partial state updates to apply - * @returns Updated state object */ protected updateState(state: TState, update: Partial): TState { return { @@ -248,58 +214,46 @@ class DotNotationDataExtrator { /** * Determines the current state based on path and attributes - * - * @param path - Current dot notation path - * @param acc - Current accumulator - * @param attributes - Current attributes being processed - * @returns State object with type and processing information */ protected getState(path: string, acc: unknown, attributes: unknown) { const dotPath = DotNotationParser.parse(path) - const index = dotPath.getFirst() - const nextIndex = dotPath.getNext() - const previousIndex = dotPath.getPrevious() - const indexIsWildcard = index === '*' - const previousIsWildcard = previousIndex === '*'; + const targetIndex = dotPath.getFirst() + const previousTargetIndex = dotPath.getPrevious() + + // Check target properties + const isWildcardTarget = targetIndex === '*' + const isPreviousWildcard = previousTargetIndex === '*' - const attributesStringOrNumber = typeof attributes === 'string' || typeof attributes === 'number'; - const attributesArrayOrObject = Array.isArray(attributes) || typeof attributes === 'object' - const attributesIndexExists = typeof attributes?.[index] !== 'undefined' - const attributesIndexValid = attributesStringOrNumber && attributesIndexExists + // Check attribute types + const isAttributePrimitive = typeof attributes === 'string' || typeof attributes === 'number' + const isAttributeComplex = Array.isArray(attributes) || typeof attributes === 'object' + const hasTargetIndex = typeof attributes?.[targetIndex] !== 'undefined' + const isValidPrimitiveAccess = isAttributePrimitive && hasTargetIndex - // State object const state: TState = { - type: states.EXIT, + type: states.INDEX, dotPath, acc, attributes } - // SKIPPING WILDCARD state - if(indexIsWildcard) { + // Determine state type based on conditions + if(isWildcardTarget) { state.type = states.SKIPPING_WILDCARD return state } - // INDEX state - if(attributesIndexValid || attributesArrayOrObject) { + if(isValidPrimitiveAccess || isAttributeComplex) { state.type = states.INDEX return state } - // WILDCARD state - if(previousIsWildcard && attributesArrayOrObject) { + if(isPreviousWildcard && isAttributeComplex) { state.type = states.WILDCARD return state } - // NEXT state - if(nextIndex) { - state.type = states.NEXT - return state - } - return state } diff --git a/src/tests/utils/dotNotationDataExtractor.test.ts b/src/tests/utils/dotNotationDataExtractor.test.ts new file mode 100644 index 000000000..3d0420c3c --- /dev/null +++ b/src/tests/utils/dotNotationDataExtractor.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from '@jest/globals'; + +import DotNotationDataExtrator from '../../core/util/data/DotNotationDataExtrator'; + +describe('DotNotationDataExtractor', () => { + test('should extract simple key value', () => { + const data = { name: 'John' }; + const result = DotNotationDataExtrator.reduceOne(data, 'name'); + expect(result['name']).toBe('John'); + }); + + test('should extract nested value', () => { + const data = { + user: { + name: 'John', + email: 'john@example.com' + } + }; + const result = DotNotationDataExtrator.reduceOne(data, 'user.name'); + expect(result['user.name']).toBe('John'); + }); + + test('should extract multiple values', () => { + const data = { + user: { + name: 'John', + email: 'john@example.com' + } + }; + const result = DotNotationDataExtrator.reduceMany(data, ['user.name', 'user.email']); + expect(result).toEqual({ + 'user.name': 'John', + 'user.email': 'john@example.com' + }); + }); + + test('should handle array indexing', () => { + const data = { + users: [ + { name: 'John' }, + { name: 'Jane' } + ] + }; + const result = DotNotationDataExtrator.reduceOne(data, 'users.0.name'); + expect(result['users.0.name']).toBe('John'); + }); + + test('should handle wildcard array extraction', () => { + const data = { + users: [ + { name: 'John' }, + { name: 'Jane' } + ] + }; + const result = DotNotationDataExtrator.reduceOne(data, 'users.*.name'); + expect(result['users.*.name']).toEqual(['John', 'Jane']); + }); + + test('should handle nested arrays with wildcards', () => { + const data = { + departments: [ + { + employees: [ + { name: 'John' }, + { name: 'Jane' } + ] + }, + { + employees: [ + { name: 'Bob' }, + { name: 'Alice' } + ] + } + ] + }; + const result = DotNotationDataExtrator.reduceOne(data, 'departments.*.employees.*.name'); + expect(result['departments.*.employees.*.name']).toEqual(['John', 'Jane', 'Bob', 'Alice']); + }); + + test('should return undefined for non-existent paths', () => { + const data = { name: 'John' }; + const result = DotNotationDataExtrator.reduceOne(data, 'age'); + expect(result['age']).toBeUndefined(); + }); + + test('should handle empty data', () => { + const data = {}; + const result = DotNotationDataExtrator.reduceOne(data, 'name'); + expect(result['name']).toBeUndefined(); + }); + + test('should handle null values', () => { + const data = { + user: { + name: null + } + }; + const result = DotNotationDataExtrator.reduceOne(data, 'user.name'); + expect(result['user.name']).toBeNull(); + }); + + test('should handle mixed array and object paths', () => { + const data = { + teams: [ + { + name: 'Team A', + members: { + active: [ + { name: 'John' }, + { name: 'Jane' } + ] + } + } + ] + }; + const result = DotNotationDataExtrator.reduceOne(data, 'teams.0.members.active.*.name'); + expect(result['teams.0.members.active.*.name']).toEqual(['John', 'Jane']); + }); +}); \ No newline at end of file From c60798eabb7c8be6c1b5abfa1ecf01c8873e1a27 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 10 Feb 2025 22:01:50 +0000 Subject: [PATCH 7/8] Refactor validator domain to support async rule validation and improve type handling - Update AbstractRule to use async test() method for validation - Modify IRule interface to support Promise-based validation - Remove deprecated array validation methods and wildcard path handling - Update validation rules to implement async test() method - Enhance Validator service to handle async rule validation - Remove legacy data extraction utilities --- .../validator/abstract/AbstractRule.ts | 24 +- .../domains/validator/interfaces/IRule.ts | 2 +- src/core/domains/validator/rules/Accepted.ts | 2 +- .../domains/validator/rules/AcceptedIf.ts | 2 +- src/core/domains/validator/rules/IsArray.ts | 4 +- src/core/domains/validator/rules/IsObject.ts | 6 +- src/core/domains/validator/rules/IsString.ts | 8 +- src/core/domains/validator/rules/Required.ts | 2 +- src/core/domains/validator/rules/isNumber.ts | 5 +- .../domains/validator/service/Validator.ts | 28 +- src/core/util/data/DataExtractorOne.ts | 317 ----------------- src/core/util/data/DataExtrator.ts | 328 ------------------ .../DataExtractor}/DotNotationDataExtrator.ts | 3 +- .../Parser}/DotNotationParser.ts | 0 .../dotNotationDataExtractor.test.ts | 2 +- .../dotNotationParser.test.ts | 2 +- 16 files changed, 58 insertions(+), 677 deletions(-) delete mode 100644 src/core/util/data/DataExtractorOne.ts delete mode 100644 src/core/util/data/DataExtrator.ts rename src/core/util/data/{ => DotNotation/DataExtractor}/DotNotationDataExtrator.ts (99%) rename src/core/util/data/{ => DotNotation/Parser}/DotNotationParser.ts (100%) rename src/tests/utils/{ => DotNotation}/dotNotationDataExtractor.test.ts (97%) rename src/tests/utils/{ => DotNotation}/dotNotationParser.test.ts (97%) diff --git a/src/core/domains/validator/abstract/AbstractRule.ts b/src/core/domains/validator/abstract/AbstractRule.ts index 792fcc226..cb2e262ba 100644 --- a/src/core/domains/validator/abstract/AbstractRule.ts +++ b/src/core/domains/validator/abstract/AbstractRule.ts @@ -1,4 +1,3 @@ -import DotNotationParser from "@src/core/util/data/DotNotationParser"; import forceString from "@src/core/util/str/forceString"; import { logger } from "../../logger/services/LoggerService"; @@ -21,6 +20,7 @@ abstract class AbstractRule { /** Default error message if error template processing fails */ protected defaultError: string = 'This field is invalid.' + /** Configuration options for the rule */ protected options: TOptions = {} as TOptions @@ -33,11 +33,13 @@ abstract class AbstractRule { /** Dot notation path to the field being validated (e.g. "users.*.name") */ protected path!: string; + /** * Tests if the current data value passes the validation rule * @returns True if validation passes, false if it fails */ - public abstract test(): boolean; + public abstract test(): Promise; + /** * Gets the validation error details if validation fails @@ -57,26 +59,23 @@ abstract class AbstractRule { * @returns True if validation passes, false if it fails */ - public validate(): boolean { - if(this.validatableAsArray()) { - return this.arrayTests() - } - - return this.test() + public async validate(): Promise { + return await this.test() } /** * Validates an array of data by testing each item individually * @returns True if all items pass validation, false if any fail + * @deprecated Unsure if this is needed */ - protected arrayTests(): boolean { + protected async arrayTests(): Promise { const data = this.getData() if(Array.isArray(data)) { for(const item of data) { this.setData(item) - if(!this.test()) { + if(!await this.test()) { return false } } @@ -90,11 +89,10 @@ abstract class AbstractRule { * Checks if the rule should be validated as an array * By checking if the last part of the path contains a wildcard (*) * @returns True if the rule should be validated as an array, false otherwise + * @deprecated Unsure if this is needed */ protected validatableAsArray(): boolean { - const parts = DotNotationParser.parse(this.getPath()).getParts() - const secondToLastPart = parts[parts.length - 2] ?? null - return secondToLastPart?.includes('*') + return false } /** diff --git a/src/core/domains/validator/interfaces/IRule.ts b/src/core/domains/validator/interfaces/IRule.ts index 1fced8ab6..9c895e52e 100644 --- a/src/core/domains/validator/interfaces/IRule.ts +++ b/src/core/domains/validator/interfaces/IRule.ts @@ -18,7 +18,7 @@ export interface IRule { getPath(): string setData(data: unknown): this setAttributes(attributes: unknown): this - validate(): boolean + validate(): Promise getError(): IRuleError getName(): string diff --git a/src/core/domains/validator/rules/Accepted.ts b/src/core/domains/validator/rules/Accepted.ts index fb65afa50..aab0a7422 100644 --- a/src/core/domains/validator/rules/Accepted.ts +++ b/src/core/domains/validator/rules/Accepted.ts @@ -9,7 +9,7 @@ class Accepted extends AbstractRule implements IRule { protected errorTemplate: string = 'The :attribute field must be accepted.'; - public test(): boolean { + public async test(): Promise { return isTruthy(this.getData()) } diff --git a/src/core/domains/validator/rules/AcceptedIf.ts b/src/core/domains/validator/rules/AcceptedIf.ts index f67e7a036..f046bdbda 100644 --- a/src/core/domains/validator/rules/AcceptedIf.ts +++ b/src/core/domains/validator/rules/AcceptedIf.ts @@ -21,7 +21,7 @@ class AcceptedIf extends AbstractRule implements IRule { this.options.value = value } - public test(): boolean { + public async test(): Promise { const { anotherField, value: expectedValue diff --git a/src/core/domains/validator/rules/IsArray.ts b/src/core/domains/validator/rules/IsArray.ts index fe4d336f0..df1b2071f 100644 --- a/src/core/domains/validator/rules/IsArray.ts +++ b/src/core/domains/validator/rules/IsArray.ts @@ -8,7 +8,9 @@ class IsArray extends AbstractRule implements IRule { protected errorTemplate: string = 'The :attribute field must be an array.'; - public test(): boolean { + protected testArrayItems = false + + public async test(): Promise { return Array.isArray(this.getData()) } diff --git a/src/core/domains/validator/rules/IsObject.ts b/src/core/domains/validator/rules/IsObject.ts index 942c74a7a..85ea3e9c2 100644 --- a/src/core/domains/validator/rules/IsObject.ts +++ b/src/core/domains/validator/rules/IsObject.ts @@ -2,13 +2,13 @@ import AbstractRule from "../abstract/AbstractRule"; import { IRule, IRuleError } from "../interfaces/IRule"; -class IsString extends AbstractRule implements IRule { +class isObject extends AbstractRule implements IRule { protected name: string = 'object' protected errorTemplate: string = 'The :attribute field must be an object.'; - public test(): boolean { + public async test(): Promise { return typeof this.getData() === 'object' } @@ -21,4 +21,4 @@ class IsString extends AbstractRule implements IRule { } -export default IsString; +export default isObject; diff --git a/src/core/domains/validator/rules/IsString.ts b/src/core/domains/validator/rules/IsString.ts index 2b3c1bf77..4f44bb5a0 100644 --- a/src/core/domains/validator/rules/IsString.ts +++ b/src/core/domains/validator/rules/IsString.ts @@ -7,9 +7,15 @@ class IsString extends AbstractRule implements IRule { protected name: string = 'string' protected errorTemplate: string = 'The :attribute field must be a string.'; + + public async test(): Promise { + + if(Array.isArray(this.getData())) { + return (this.getData() as unknown[]).every(item => typeof item === 'string') + } - public test(): boolean { return typeof this.getData() === 'string' + } public getError(): IRuleError { diff --git a/src/core/domains/validator/rules/Required.ts b/src/core/domains/validator/rules/Required.ts index 1cc2129fd..bdc4dc680 100644 --- a/src/core/domains/validator/rules/Required.ts +++ b/src/core/domains/validator/rules/Required.ts @@ -8,7 +8,7 @@ class Required extends AbstractRule implements IRule { protected errorTemplate: string = 'The :attribute field is required.'; - public test(): boolean { + public async test(): Promise { const value = this.getData() return value !== undefined && value !== null && value !== '' } diff --git a/src/core/domains/validator/rules/isNumber.ts b/src/core/domains/validator/rules/isNumber.ts index b7aafc4bb..70830a28e 100644 --- a/src/core/domains/validator/rules/isNumber.ts +++ b/src/core/domains/validator/rules/isNumber.ts @@ -8,10 +8,13 @@ class IsNumber extends AbstractRule implements IRule { protected errorTemplate: string = 'The :attribute field must be a number.'; - public test(): boolean { + protected testArrayItems: boolean = true + + public async test(): Promise { return typeof this.getData() === 'number' } + public getError(): IRuleError { return { [this.getPath()]: this.buildError() diff --git a/src/core/domains/validator/service/Validator.ts b/src/core/domains/validator/service/Validator.ts index 9a06defa0..236e7019f 100644 --- a/src/core/domains/validator/service/Validator.ts +++ b/src/core/domains/validator/service/Validator.ts @@ -1,4 +1,4 @@ -import DataExtractorOne from "@src/core/util/data/DataExtractorOne"; +import DotNotationDataExtrator from "@src/core/util/data/DotNotation/DataExtractor/DotNotationDataExtrator"; import ValidatorResult from "../data/ValidatorResult"; import { IRule, IRulesObject } from "../interfaces/IRule"; @@ -40,7 +40,12 @@ class Validator implements IValidator { // Extract only the data fields that have validation rules defined // This ensures we only validate fields that have rules and maintains // the nested structure (e.g. users.0.name) for proper validation - const extractedData = DataExtractorOne.reduce(attributes, this.rules); + // Example Structure: + // { + // "users.*": [...], + // "users.*.name": ["John", "Jane" ] + // } + const extractedData = this.extractData(attributes); // Validate each field with its corresponding rule for (const path of Object.keys(this.rules)) { @@ -60,17 +65,27 @@ class Validator implements IValidator { return ValidatorResult.passes(); } + protected extractData(attributes: IValidatorAttributes): Record { + const result = DotNotationDataExtrator.reduceMany(attributes, Object.keys(this.rules)); + + return Object.keys(result).reduce((acc, key) => { + acc[key] = result[key]; + return acc; + }, {} as Record); + } + + /** * Validates an array of rules for a given path and attributes * @param path - The path of the field being validated * @param rules - The array of rules to validate * @param attributes - The attributes to validate against + * @returns A promise resolving to the validation result */ protected async validateRulesArray(path: string, rules: IRule[], data: unknown, attributes: unknown): Promise { for (const rule of rules) { - const result = this.validateRule(path, rule, data, attributes); - + const result = await this.validateRule(path, rule, data, attributes); if (result.fails()) { this._errors = { @@ -92,12 +107,13 @@ class Validator implements IValidator { * @param data - The attributes to validate against * @returns A promise resolving to the validation result */ - protected validateRule(key: string, rule: IRule, data: unknown, attributes: unknown): IValidatorResult { + protected async validateRule(key: string, rule: IRule, data: unknown, attributes: unknown): Promise { rule.setPath(key) rule.setData(data) rule.setAttributes(attributes) - const passes = rule.validate(); + const passes = await rule.validate(); + console.log('[Validator] validateRule', { key, diff --git a/src/core/util/data/DataExtractorOne.ts b/src/core/util/data/DataExtractorOne.ts deleted file mode 100644 index a3176f955..000000000 --- a/src/core/util/data/DataExtractorOne.ts +++ /dev/null @@ -1,317 +0,0 @@ -import DotNotationParser from "./DotNotationParser" - -/** - * Represents an object containing dot notation paths as keys - * @template TPathsObject - The type of the paths object - */ -export type TPathsObject = { - [key: string]: unknown -} - -/** - * Represents a data object that can be extracted from a nested structure - * @template TData - The type of the data object - */ -export type TData = Record - -/** - * Utility class for extracting data from nested objects using dot notation paths - * Supports array indexing, nested objects, and wildcard paths - * - * @example - * ```typescript - * const data = { - * users: [ - * { name: 'John', age: 20 }, - * { name: 'Jane', age: 21 } - * ] - * } - * - * const paths = { - * 'users.0.name': true, - * 'users.1.age': true - * } - * - * const result = DataExtractor.reduce(data, paths) - * // Result: - - * // { - * // 'users.0.name': 'John', - * // 'users.1.age': 21 - * // } - * ``` - - */ -class DataExtractorOne { - - /** - * Static factory method to create and initialize a DataExtractor instance - * @param attributes - Source data object to extract from - * @param paths - Object containing path rules for extraction - * @returns Extracted data based on the provided rules - */ - public static reduce(attributes: TData, paths: TPathsObject): TPathsObject { - return new DataExtractorOne().init(paths, attributes) - } - - /** - * Initializes the extraction process with the given paths and attributes - * @param paths - Object containing dot notation paths as keys - * @param attributes - Source data object to extract from - * @returns Object containing extracted data mapped to the original paths - */ - init(paths: TPathsObject, attributes: object): TPathsObject { - return Object.keys(paths).reduce((acc, path) => { - const dotPath = DotNotationParser.parse(path) - - return { - ...acc, - [path]: this.recursiveReducer(dotPath, acc, attributes) as object - } - }, {}) as TPathsObject - } - - /** - * Recursively processes a path to extract data from nested objects/arrays/values - * - * This method: - * 1. Parses the dot notation path (e.g. "users.0.name") using DotNotationParser - * 2. Checks if the path contains nested indexes (e.g. "users.0" vs just "users") - * 3. For nested paths like "users.0.name", calls nestedIndex() to handle the nesting - * 4. For simple paths like "users", calls nonNestedIndex() to extract the value - * 5. Handles wildcard paths ("users.*") via the all() method - * - * @param key - The dot notation path to process (e.g. "users.0.name") - * @param acc - Accumulator object being built up with extracted data - * @param curr - Current value being processed - * @param attributes - Original source data object - * @returns Updated accumulator with extracted data - */ - protected recursiveReducer(dotPath: DotNotationParser, acc: object | unknown[], attributes: object): unknown { - - const firstIndex = dotPath.getFirst() - const firstIndexValue = attributes[firstIndex] - const firstIndexNotUndefined = typeof firstIndexValue !== 'undefined' - const firstIndexValueIterable = Array.isArray(firstIndexValue) - const firstIndexIsWildcard = dotPath.getFirst() === '*' - - const nextIndex = dotPath.getNext() - const nextIndexValue = nextIndex ? attributes[firstIndex]?.[nextIndex] :undefined - const nextIndexValueNotUndefined = typeof nextIndexValue !== 'undefined' - const nextIndexValueIterable = Array.isArray(nextIndexValue) - const hasNextIndex = typeof dotPath.getNext() !== 'undefined'; - - const rest = dotPath.getRest() - - const attributesisArray = Array.isArray(attributes) - const attributesisObject = !attributesisArray && typeof attributes === 'object' - const attributesArrayOrObject = attributesisArray || attributesisObject - - console.log('[recursiveReducer] debug', { - dotPath: { - path: dotPath.getFullPath(), - next: dotPath.getNext(), - rest: dotPath.getRest() - }, - first: { - value: firstIndexValue, - iterable: firstIndexValueIterable, - wildcard: firstIndexIsWildcard - }, - next: { - value: nextIndexValue, - iterable: nextIndexValueIterable - }, - attributes: attributes, - acc: acc, - }) - - if(attributesisArray && firstIndexIsWildcard) { - if(hasNextIndex) { - acc = [] as unknown[] - - (attributes as unknown[]).forEach((attributeItem) => { - const reducedAttributeItem = this.recursiveReducer(DotNotationParser.parse(nextIndex as string), attributeItem as object | unknown[], attributeItem as object) - - acc = [ - ...(acc as unknown[]), - reducedAttributeItem - ] - }) - } - - else { - acc = (attributes as unknown[]).map((attributesisArrayItem) => { - return attributesisArrayItem - }) - } - - attributes = [...(acc as unknown[])] - dotPath.forward() - - return this.recursiveReducer(dotPath, acc, attributes) - } - - - if(typeof firstIndexValue !== 'undefined') { - acc = { - [firstIndex]: firstIndexValue - } - attributes = attributes[firstIndex] - } - - if(!firstIndexIsWildcard && attributesisArray && hasNextIndex) { - acc = [ - ...(attributes as unknown[]).map((attributesItem) => { - return this.recursiveReducer(DotNotationParser.parse(nextIndex as string), attributesItem as object | unknown[], attributesItem as object) - - }) - ] - - attributes = [...(firstIndexValue as unknown[])] - } - - if(firstIndexIsWildcard) { - acc = firstIndexValue - attributes = firstIndexValue - dotPath.forward() - return this.recursiveReducer(dotPath, acc, attributes) - } - - - if(firstIndexIsWildcard && hasNextIndex && (nextIndexValueNotUndefined || nextIndexValueNotUndefined)) { - if(firstIndexValueIterable) { - acc = firstIndexValue.map((firstIndexValueItem) => { - return firstIndexValueItem?.[nextIndex as string] - }) - attributes = [...(acc as unknown[])] - } - else { - acc = { - [firstIndex]: firstIndexValue - } - attributes = attributes[firstIndex] - } - } - - dotPath.forward() - - if(acc[firstIndex]) { - return acc - } - - return this.recursiveReducer(dotPath, acc[firstIndex], attributes) - } - - /** - * Handles non-nested index validation rules by extracting values at a specific index - * @param parsedRuleKey - The parsed validation rule key - * @param acc - The accumulator object being built up - * @param attributes - The original data object being validated - * @returns The value at the specified index or the original accumulator - */ - protected reduceIndex(parsedRuleKey, acc: object, attributes: object) { - const index = parsedRuleKey.getIndex() - - if (Array.isArray(attributes)) { - return attributes.map(item => { - return item[index] - }) - } - else if (attributes[index]) { - return attributes[index] - } - - return acc - } - - /** - * Handles nested index validation rules by recursively extracting values from nested paths - * - * For example, with data like: { users: [{ name: "John" }] } - * And rule key like: "users.0.name" - * This method will: - * 1. Extract the users array - * 2. Get item at index 0 - * 3. Extract the name property - * - * @param parsedRuleKey - The parsed validation rule key containing nested indexes - * @param acc - The accumulator object being built up - * @param attributes - The original data object being validated - * @returns The updated accumulator with validated data from the nested path - */ - protected reduceNestedIndexes(parsedPath: DotNotationParser, acc: object | unknown[], attributes: object) { - - // Example: for rule key "users.0.name" - // currentIndex = "users" - // nextIndex = "0" - // rest = "name" - const currentIndex = parsedPath.getFirst() - const nextIndex = parsedPath.getNext() - const rest = parsedPath.getRest() - - // Example: attributes = [{ name: "John" }, { name: "Jane" }] - // isArray = true - // isObject = true - const isArray = Array.isArray(attributes) - const isObject = !isArray && typeof attributes === 'object' - const arrayOrObject = isArray || isObject - - // If the current index is undefined, return the accumulator as is - if (attributes[currentIndex] === undefined) { - return acc - } - - // This section handles nested data reduction for validation rules - // For example, with data like: { users: [{ name: "John" }, { name: "Jane" }] } - // And rule key like: "users.0.name" - // - // blankObjectOrArray will be either [] or {} depending on the type of the current value - // This ensures we maintain the correct data structure when reducing - const blankObjectOrArray = this.getBlankObjectOrArray(attributes[currentIndex]) - - // If the current value is an array, we need to build up a new array - // If it's an object, we need to build up a new object with the current index as key - // This preserves the structure while only including validated fields - if (isArray) { - if (!Array.isArray(acc)) { - acc = [] - } - acc = [ - ...(acc as unknown[]), - blankObjectOrArray, - ] - } - else if (isObject) { - acc = { - ...acc, - [currentIndex]: blankObjectOrArray - } - } - - if(typeof rest === 'undefined') { - return acc - } - - // Recursively reduce the rest of the path - return this.recursiveReducer(DotNotationParser.parse(rest), acc[currentIndex], attributes[currentIndex]) - - } - - /** - * Returns a blank object or array based on the type of the value - * - * @param value - The value to check - * @returns A blank object or array - */ - protected getBlankObjectOrArray(value: unknown): object | unknown[] { - if (Array.isArray(value)) { - return [] - } - - return {} - } - -} - -export default DataExtractorOne \ No newline at end of file diff --git a/src/core/util/data/DataExtrator.ts b/src/core/util/data/DataExtrator.ts deleted file mode 100644 index ab1080165..000000000 --- a/src/core/util/data/DataExtrator.ts +++ /dev/null @@ -1,328 +0,0 @@ -import DotNotationParser from "./DotNotationParser" - -type TStateName = 'INDEX' | 'WILDCARD' | 'SKIPPING_WILDCARD' | 'NEXT' | 'EXIT' - -type TState = { - type: TStateName | null, - dotPath: DotNotationParser, - acc: unknown, - attributes: unknown -} - -const states: Record = { - INDEX: 'INDEX', - WILDCARD: 'WILDCARD', - SKIPPING_WILDCARD: 'SKIPPING_WILDCARD', - NEXT: 'NEXT', - EXIT: 'EXIT' -} - -type TDataExtractorOptions = { - paths: Record - data: unknown; -} - -/** - * DataExtractor provides functionality to extract values from nested data structures using dot notation paths - * - * This class allows you to extract specific values from complex nested objects and arrays using - * dot notation paths with support for wildcards (*). It's particularly useful for: - * - Extracting values from deeply nested objects - * - Processing arrays of objects to get specific fields - * - Handling dynamic paths with wildcards - * - * @example - * const data = { - * users: [ - * { name: 'John', posts: [{ title: 'Post 1' }] }, - * { name: 'Jane', posts: [{ title: 'Post 2' }] } - * ] - * }; - * - * const paths = { - * 'users.*.name' : unknown, // Extract all user names - * 'users.*.posts.*.title' : unknown // Extract all post titles - * }; - * - * const extracted = DataExtractor.reduce(data, paths); - * // Result: - - * // { - * // 'users.*.name': ['John', 'Jane'], - * // 'users.*.posts.*.title': ['Post 1', 'Post 2'] - * // } - */ -class DataExtractor { - - /** - * Static factory method to create and initialize a DataExtractor instance - * - * @param data - The source data object to extract values from - * @param paths - Object containing dot notation paths as keys - * @returns Extracted values mapped to their corresponding paths - */ - public static reduce(data: TDataExtractorOptions['data'], paths: TDataExtractorOptions['paths']): unknown { - return new DataExtractor().init({ - data, - paths - }) - } - - /** - * Initializes data extraction by processing multiple dot notation paths against a data object - * - * @param options - Configuration options for data extraction - * @param options.data - The source data object to extract values from - * @param options.paths - Object containing dot notation paths as keys - * @returns An object containing the extracted values mapped to their paths - */ - public init(options: TDataExtractorOptions): unknown { - const { paths, data } = options; - const pathKeys = Object.keys(paths) - - return pathKeys.reduce((acc, path) => { - return this.reducer(path, acc, data) - }, {}) - } - - /** - * Core recursive function that processes each path segment and extracts values - * - * @param path - Current dot notation path being processed - * @param acc - Accumulator holding processed values - * @param attributes - Current data being processed - * @param recursionLevel - Current depth of recursion (for debugging) - * @returns Processed value(s) for the current path - */ - reducer(path: string, acc: unknown, attributes: unknown, recursionLevel = 1) { - const state = this.getState(path, acc, attributes) - - // Reducing INDEXES - // Condition: index is string or number, does not equal *, must existpe - // If the attributes is not an object or array, return attribute - // If the attributes is an array of objects, we should iterate through and map - if(state.type === states.INDEX) { - return this.reduceAttributes(state, recursionLevel) - } - - // Reducing SKIPPING WILDCARDS - // Condition: index is * - // Nothing to do, return the current attribtues - if(state.type === states.SKIPPING_WILDCARD) { - return this.reducer(state.dotPath.getRest() as string, state.acc, state.attributes, recursionLevel + 1) - } - - - // Reducing WILDCARDS - // Condition: previous INDEX must be a wildcard - // Condition: attributes must be an array - // Possibly an object, with a matching object[index] - if(state.type === states.WILDCARD) { - return this.reduceAttributes(state, recursionLevel) - } - - // RECURSIVE - // Condition: Contains next index - // execute forward for dotPath, storing previous index - if(state.type === states.NEXT) { - return this.reduceAttributes(state, recursionLevel + 1) - } - - // EXITING - // Condition: No next index - // Return value - console.log('[DataExtractor] exit') - - return acc - } - - /** - * Processes attribute values based on the current state and path - * - * @param state - Current state object containing path and data information - * @param recursionLevel - Current depth of recursion - * @returns Processed attribute values - */ - reduceAttributes(state: TState, recursionLevel = 1) { - - const target = state.dotPath.getFirst() - const nextTarget = state.dotPath.getNext() - const attributes = state.attributes - const rest = state.dotPath.getRest() - const nextRecursionLevel = recursionLevel + 1 - - // If the attributes is an object and the target exists and there is a next target, reduce the attributes - if(typeof attributes === 'object' && attributes?.[target] && typeof nextTarget !== 'undefined' && nextTarget !== '*') { - return this.reducer( - state.dotPath.getRest() as string, - state.acc, - attributes[target], - nextRecursionLevel - ) - } - - // If the attributes is an object and the target exists and there is no next target, return the target - if(typeof attributes === 'object' && attributes?.[target] && typeof rest === 'undefined') { - return attributes[target] - } - - // If the attributes is an object and the target exists and there is a next target, reduce the attributes - if(typeof attributes === 'object' && attributes?.[target] && typeof rest !== 'undefined') { - return this.reducer( - state.dotPath.getRest() as string, - state.acc, - attributes[target], - nextRecursionLevel - ) - } - - // If the attributes is an array, reduce the array - if(Array.isArray(attributes)) { - return this.reduceArray(state, recursionLevel) - } - - return attributes - } - - /** - * Processes array values by applying the extraction logic to each element - * - * @param state - Current state object containing path and data information - * @param recursionLevel - Current depth of recursion - * @returns Array of processed values - */ - reduceArray(state: TState, recursionLevel = 1) { - let acc: unknown[] | object = [] - - const attributes = state.attributes - const target = state.dotPath.getFirst() - - const attributesIsArray = Array.isArray(attributes) - const attributesIsObject = typeof attributes === 'object' && !attributesIsArray - const containsNestedArrays = attributesIsArray && (attributes as unknown[]).every(attr => Array.isArray(attr)) - - const containsNestedObjects = attributesIsArray && (attributes as unknown[]).every(attr => typeof attr === 'object') - - if(attributesIsObject && attributes?.[target]) { - return attributes[target] - } - - if(!attributesIsArray) { - return attributes - } - - if(containsNestedArrays) { - acc = [] as unknown[] - - (attributes as unknown[]).forEach(array => { - - (array as unknown[]).forEach(attr => { - // Update the state - state = this.updateState(state, { - attributes: attr as unknown[], - acc - }) - - acc = [ - ...(acc as unknown[]), - this.reduceArray(state, recursionLevel + 1) - ] - }) - }) - } - - if(containsNestedObjects) { - acc = [] as unknown[] - - (attributes as unknown[]).forEach(obj => { - - // Update the state - state = this.updateState(state, { - attributes: obj as object, - acc - }) - - acc = [ - ...(acc as unknown[]), - this.reduceAttributes(state, recursionLevel + 1) - ] - }) - } - - return acc - } - - /** - * Updates the current state with new values - * - * @param state - Current state object - * @param update - Partial state updates to apply - * @returns Updated state object - */ - protected updateState(state: TState, update: Partial): TState { - return { - ...state, - ...update - } - } - - /** - * Determines the current state based on path and attributes - * - * @param path - Current dot notation path - * @param acc - Current accumulator - * @param attributes - Current attributes being processed - * @returns State object with type and processing information - */ - protected getState(path: string, acc: unknown, attributes: unknown) { - const dotPath = DotNotationParser.parse(path) - const index = dotPath.getFirst() - const nextIndex = dotPath.getNext() - const previousIndex = dotPath.getPrevious() - - const indexIsWildcard = index === '*' - const previousIsWildcard = previousIndex === '*'; - - const attributesStringOrNumber = typeof attributes === 'string' || typeof attributes === 'number'; - const attributesArrayOrObject = Array.isArray(attributes) || typeof attributes === 'object' - const attributesIndexExists = typeof attributes?.[index] !== 'undefined' - const attributesIndexValid = attributesStringOrNumber && attributesIndexExists - - // State object - const state: TState = { - type: states.EXIT, - dotPath, - acc, - attributes - } - - // SKIPPING WILDCARD state - if(indexIsWildcard) { - state.type = states.SKIPPING_WILDCARD - return state - } - - // INDEX state - if(attributesIndexValid || attributesArrayOrObject) { - state.type = states.INDEX - return state - } - - // WILDCARD state - if(previousIsWildcard && attributesArrayOrObject) { - state.type = states.WILDCARD - return state - } - - // NEXT state - if(nextIndex) { - state.type = states.NEXT - return state - } - - return state - } - -} - -export default DataExtractor \ No newline at end of file diff --git a/src/core/util/data/DotNotationDataExtrator.ts b/src/core/util/data/DotNotation/DataExtractor/DotNotationDataExtrator.ts similarity index 99% rename from src/core/util/data/DotNotationDataExtrator.ts rename to src/core/util/data/DotNotation/DataExtractor/DotNotationDataExtrator.ts index dc3533fea..9fc5ffc75 100644 --- a/src/core/util/data/DotNotationDataExtrator.ts +++ b/src/core/util/data/DotNotation/DataExtractor/DotNotationDataExtrator.ts @@ -1,7 +1,8 @@ -import DotNotationParser from "./DotNotationParser"; +import DotNotationParser from "../Parser/DotNotationParser"; type TStateName = 'INDEX' | 'WILDCARD' | 'SKIPPING_WILDCARD'; + type TState = { type: TStateName | null, dotPath: DotNotationParser, diff --git a/src/core/util/data/DotNotationParser.ts b/src/core/util/data/DotNotation/Parser/DotNotationParser.ts similarity index 100% rename from src/core/util/data/DotNotationParser.ts rename to src/core/util/data/DotNotation/Parser/DotNotationParser.ts diff --git a/src/tests/utils/dotNotationDataExtractor.test.ts b/src/tests/utils/DotNotation/dotNotationDataExtractor.test.ts similarity index 97% rename from src/tests/utils/dotNotationDataExtractor.test.ts rename to src/tests/utils/DotNotation/dotNotationDataExtractor.test.ts index 3d0420c3c..4eefbcefd 100644 --- a/src/tests/utils/dotNotationDataExtractor.test.ts +++ b/src/tests/utils/DotNotation/dotNotationDataExtractor.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from '@jest/globals'; -import DotNotationDataExtrator from '../../core/util/data/DotNotationDataExtrator'; +import DotNotationDataExtrator from '../../../core/util/data/DotNotation/DataExtractor/DotNotationDataExtrator'; describe('DotNotationDataExtractor', () => { test('should extract simple key value', () => { diff --git a/src/tests/utils/dotNotationParser.test.ts b/src/tests/utils/DotNotation/dotNotationParser.test.ts similarity index 97% rename from src/tests/utils/dotNotationParser.test.ts rename to src/tests/utils/DotNotation/dotNotationParser.test.ts index 2311b1408..9c9dfa5c2 100644 --- a/src/tests/utils/dotNotationParser.test.ts +++ b/src/tests/utils/DotNotation/dotNotationParser.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from '@jest/globals'; -import DotNotationParser from '@src/core/util/data/DotNotationParser'; +import DotNotationParser from '@src/core/util/data/DotNotation/Parser/DotNotationParser'; describe('DotNotationParser', () => { From 27c8589ad62366c815fae444d66a016f034d53d6 Mon Sep 17 00:00:00 2001 From: "BENJAMIN\\bensh" Date: Mon, 10 Feb 2025 22:02:27 +0000 Subject: [PATCH 8/8] remove unused methods --- .../validator/abstract/AbstractRule.ts | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/src/core/domains/validator/abstract/AbstractRule.ts b/src/core/domains/validator/abstract/AbstractRule.ts index cb2e262ba..f049ed749 100644 --- a/src/core/domains/validator/abstract/AbstractRule.ts +++ b/src/core/domains/validator/abstract/AbstractRule.ts @@ -33,14 +33,12 @@ abstract class AbstractRule { /** Dot notation path to the field being validated (e.g. "users.*.name") */ protected path!: string; - /** * Tests if the current data value passes the validation rule * @returns True if validation passes, false if it fails */ public abstract test(): Promise; - /** * Gets the validation error details if validation fails * @returns Object containing error information @@ -63,38 +61,6 @@ abstract class AbstractRule { return await this.test() } - /** - * Validates an array of data by testing each item individually - * @returns True if all items pass validation, false if any fail - * @deprecated Unsure if this is needed - */ - protected async arrayTests(): Promise { - const data = this.getData() - - if(Array.isArray(data)) { - for(const item of data) { - this.setData(item) - - if(!await this.test()) { - return false - } - } - return true // Return true if all items passed - } - - return false // Return false for non-array data - } - - /** - * Checks if the rule should be validated as an array - * By checking if the last part of the path contains a wildcard (*) - * @returns True if the rule should be validated as an array, false otherwise - * @deprecated Unsure if this is needed - */ - protected validatableAsArray(): boolean { - return false - } - /** * Sets the configuration options for this validation rule * @param options - Rule-specific options object