diff --git a/docs/syntax.md b/docs/syntax.md index 85591dc..7190942 100644 --- a/docs/syntax.md +++ b/docs/syntax.md @@ -274,6 +274,44 @@ parser.evaluate('toUpper(trim(left(" hello world ", 10)))'); // "HELLO WOR" > **Note:** All string functions return `undefined` if any of their required arguments are `undefined`, allowing for safe chaining and conditional logic. +## Object Manipulation Functions + +The parser includes functions for working with objects. + +| Function | Description | +|:--------------------- |:----------- | +| merge(obj1, obj2, ...)| Merges two or more objects together. Duplicate keys are overwritten by later arguments. | +| keys(obj) | Returns an array of strings containing the keys of the object. | +| values(obj) | Returns an array containing the values of the object. | +| flatten(obj, sep?) | Flattens a nested object's keys using an optional separator (default: `_`). For example, `{foo: {bar: 1}}` becomes `{foo_bar: 1}`. | + +### Object Function Examples + +```js +const parser = new Parser(); + +// Merge objects +parser.evaluate('merge({a: 1}, {b: 2})'); // {a: 1, b: 2} +parser.evaluate('merge({a: 1, b: 2}, {b: 3, c: 4})'); // {a: 1, b: 3, c: 4} +parser.evaluate('merge({a: 1}, {b: 2}, {c: 3})'); // {a: 1, b: 2, c: 3} + +// Get keys +parser.evaluate('keys({a: 1, b: 2, c: 3})'); // ["a", "b", "c"] + +// Get values +parser.evaluate('values({a: 1, b: 2, c: 3})'); // [1, 2, 3] + +// Flatten nested objects +parser.evaluate('flatten(obj)', { obj: { foo: { bar: 1 } } }); // {foo_bar: 1} +parser.evaluate('flatten(obj)', { obj: { a: { b: { c: 1 } } } }); // {a_b_c: 1} +parser.evaluate('flatten(obj, ".")', { obj: { foo: { bar: 1 } } }); // {"foo.bar": 1} + +// Mixed nested and flat keys +parser.evaluate('flatten(obj)', { obj: { a: 1, b: { c: 2 } } }); // {a: 1, b_c: 2} +``` + +> **Note:** All object functions return `undefined` if any of their required arguments are `undefined`, allowing for safe chaining and conditional logic. + ## Array Literals Arrays can be created by including the elements inside square `[]` brackets, separated by commas. For example: diff --git a/src/functions/index.ts b/src/functions/index.ts index 4f6a8fd..a9f8510 100644 --- a/src/functions/index.ts +++ b/src/functions/index.ts @@ -7,3 +7,4 @@ export * from './math'; export * from './array'; export * from './utility'; export * from './string'; +export * from './object'; diff --git a/src/functions/object/index.ts b/src/functions/object/index.ts new file mode 100644 index 0000000..26d57dd --- /dev/null +++ b/src/functions/object/index.ts @@ -0,0 +1,6 @@ +/** + * Object functions exports + * Re-exports all object manipulation functions + */ + +export * from './operations.js'; diff --git a/src/functions/object/operations.ts b/src/functions/object/operations.ts new file mode 100644 index 0000000..bdf2bbc --- /dev/null +++ b/src/functions/object/operations.ts @@ -0,0 +1,102 @@ +/** + * Object manipulation functions + * Provides comprehensive object operations for the expression parser + */ + +import { Value, ValueObject } from '../../types/values.js'; + +/** + * Merges two or more objects together. + * Duplicate keys will be overwritten by later arguments. + * @param objects - Objects to merge + * @returns Merged object or undefined if any argument is undefined + */ +export function merge(...objects: (ValueObject | undefined)[]): ValueObject | undefined { + if (objects.length === 0) { + return {}; + } + + for (const obj of objects) { + if (obj === undefined) { + return undefined; + } + if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) { + throw new Error('Arguments to merge must be objects'); + } + } + + return Object.assign({}, ...objects); +} + +/** + * Returns an array of the keys of an object. + * @param obj - The object to get keys from + * @returns Array of string keys or undefined if input is undefined + */ +export function keys(obj: ValueObject | undefined): string[] | undefined { + if (obj === undefined) { + return undefined; + } + if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) { + throw new Error('Argument to keys must be an object'); + } + return Object.keys(obj); +} + +/** + * Returns an array of the values of an object. + * @param obj - The object to get values from + * @returns Array of values or undefined if input is undefined + */ +export function values(obj: ValueObject | undefined): Value[] | undefined { + if (obj === undefined) { + return undefined; + } + if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) { + throw new Error('Argument to values must be an object'); + } + return Object.values(obj); +} + +/** + * Flattens a nested object's keys using underscore as separator. + * For example: { foo: { bar: 1 } } becomes { foo_bar: 1 } + * @param obj - The object to flatten + * @param separator - The separator to use (default: '_') + * @returns Flattened object or undefined if input is undefined + */ +export function flatten( + obj: ValueObject | undefined, + separator: string = '_' +): ValueObject | undefined { + if (obj === undefined) { + return undefined; + } + if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) { + throw new Error('First argument to flatten must be an object'); + } + if (typeof separator !== 'string') { + throw new Error('Second argument to flatten must be a string'); + } + + const result: ValueObject = {}; + + function flattenHelper(current: Value, prefix: string): void { + if ( + current !== null && + typeof current === 'object' && + !Array.isArray(current) + ) { + for (const key of Object.keys(current as ValueObject)) { + const newKey = prefix ? `${prefix}${separator}${key}` : key; + flattenHelper((current as ValueObject)[key], newKey); + } + } else { + result[prefix] = current; + } + } + + flattenHelper(obj, ''); + + return result; +} diff --git a/src/language-service/language-service.documentation.ts b/src/language-service/language-service.documentation.ts index dc5eb3b..fd457a9 100644 --- a/src/language-service/language-service.documentation.ts +++ b/src/language-service/language-service.documentation.ts @@ -262,6 +262,38 @@ export const BUILTIN_FUNCTION_DOCS: Record = { params: [ { name: 'values', description: 'Values to check.', isVariadic: true } ] + }, + /** + * Object functions + */ + merge: { + name: 'merge', + description: 'Merge two or more objects together. Duplicate keys are overwritten by later arguments.', + params: [ + { name: 'objects', description: 'Objects to merge.', isVariadic: true } + ] + }, + keys: { + name: 'keys', + description: 'Return an array of strings containing the keys of the object.', + params: [ + { name: 'obj', description: 'Input object.' } + ] + }, + values: { + name: 'values', + description: 'Return an array containing the values of the object.', + params: [ + { name: 'obj', description: 'Input object.' } + ] + }, + flatten: { + name: 'flatten', + description: 'Flatten a nested object\'s keys using an optional separator (default: _). For example, {foo: {bar: 1}} becomes {foo_bar: 1}.', + params: [ + { name: 'obj', description: 'Input object.' }, + { name: 'separator', description: 'Key separator (default: _).', optional: true } + ] } }; diff --git a/src/parsing/parser.ts b/src/parsing/parser.ts index f629af2..6007043 100644 --- a/src/parsing/parser.ts +++ b/src/parsing/parser.ts @@ -6,7 +6,7 @@ import { Expression } from '../core/expression.js'; import type { Value, VariableResolveResult, Values } from '../types/values.js'; import type { Instruction } from './instruction.js'; import type { OperatorFunction } from '../types/parser.js'; -import { atan2, condition, fac, filter, fold, gamma, hypot, indexOf, join, map, max, min, random, roundTo, sum, json, stringLength, isEmpty, stringContains, startsWith, endsWith, searchCount, trim, toUpper, toLower, toTitle, split, repeat, reverse, left, right, replace, replaceFirst, naturalSort, toNumber, toBoolean, padLeft, padRight, padBoth, slice, urlEncode, base64Encode, base64Decode, coalesceString } from '../functions/index.js'; +import { atan2, condition, fac, filter, fold, gamma, hypot, indexOf, join, map, max, min, random, roundTo, sum, json, stringLength, isEmpty, stringContains, startsWith, endsWith, searchCount, trim, toUpper, toLower, toTitle, split, repeat, reverse, left, right, replace, replaceFirst, naturalSort, toNumber, toBoolean, padLeft, padRight, padBoth, slice, urlEncode, base64Encode, base64Decode, coalesceString, merge, keys, values, flatten } from '../functions/index.js'; import { add, sub, @@ -224,7 +224,12 @@ export class Parser { urlEncode: urlEncode, base64Encode: base64Encode, base64Decode: base64Decode, - coalesce: coalesceString + coalesce: coalesceString, + // Object manipulation functions + merge: merge, + keys: keys, + values: values, + flatten: flatten }; this.numericConstants = { diff --git a/test/functions/functions-object.ts b/test/functions/functions-object.ts new file mode 100644 index 0000000..4681da3 --- /dev/null +++ b/test/functions/functions-object.ts @@ -0,0 +1,166 @@ +/* global describe, it */ + +import assert from 'assert'; +import { Parser } from '../../index'; + +describe('Object Functions TypeScript Test', function () { + describe('merge(obj1, obj2, ...)', function () { + it('should return empty object when called with no arguments', function () { + const parser = new Parser(); + assert.deepStrictEqual(parser.evaluate('merge()'), {}); + }); + + it('should return the object when called with one argument', function () { + const parser = new Parser(); + assert.deepStrictEqual(parser.evaluate('merge({a: 1})'), { a: 1 }); + }); + + it('should merge two objects', function () { + const parser = new Parser(); + assert.deepStrictEqual(parser.evaluate('merge({a: 1}, {b: 2})'), { a: 1, b: 2 }); + }); + + it('should override duplicate keys with later arguments', function () { + const parser = new Parser(); + assert.deepStrictEqual(parser.evaluate('merge({a: 1, b: 2}, {b: 3, c: 4})'), { a: 1, b: 3, c: 4 }); + }); + + it('should merge three or more objects', function () { + const parser = new Parser(); + assert.deepStrictEqual( + parser.evaluate('merge({a: 1}, {b: 2}, {c: 3})'), + { a: 1, b: 2, c: 3 } + ); + }); + + it('should return undefined if any argument is undefined', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('merge({a: 1}, undefined)'), undefined); + assert.strictEqual(parser.evaluate('merge(undefined, {a: 1})'), undefined); + }); + + it('should work with variables', function () { + const parser = new Parser(); + assert.deepStrictEqual( + parser.evaluate('merge(obj1, obj2)', { obj1: { x: 10 }, obj2: { y: 20 } }), + { x: 10, y: 20 } + ); + }); + }); + + describe('keys(obj)', function () { + it('should return empty array for empty object', function () { + const parser = new Parser(); + assert.deepStrictEqual(parser.evaluate('keys({})'), []); + }); + + it('should return keys of an object', function () { + const parser = new Parser(); + const result = parser.evaluate('keys({a: 1, b: 2, c: 3})') as string[]; + assert.strictEqual(result.length, 3); + assert(result.includes('a')); + assert(result.includes('b')); + assert(result.includes('c')); + }); + + it('should return undefined if argument is undefined', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('keys(undefined)'), undefined); + }); + + it('should work with variables', function () { + const parser = new Parser(); + const result = parser.evaluate('keys(obj)', { obj: { foo: 'bar', baz: 42 } }) as string[]; + assert.strictEqual(result.length, 2); + assert(result.includes('foo')); + assert(result.includes('baz')); + }); + }); + + describe('values(obj)', function () { + it('should return empty array for empty object', function () { + const parser = new Parser(); + assert.deepStrictEqual(parser.evaluate('values({})'), []); + }); + + it('should return values of an object', function () { + const parser = new Parser(); + const result = parser.evaluate('values({a: 1, b: 2, c: 3})') as number[]; + assert.strictEqual(result.length, 3); + assert(result.includes(1)); + assert(result.includes(2)); + assert(result.includes(3)); + }); + + it('should return undefined if argument is undefined', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('values(undefined)'), undefined); + }); + + it('should work with variables', function () { + const parser = new Parser(); + const result = parser.evaluate('values(obj)', { obj: { foo: 'bar', baz: 42 } }); + assert(Array.isArray(result)); + assert.strictEqual((result as any[]).length, 2); + assert((result as any[]).includes('bar')); + assert((result as any[]).includes(42)); + }); + }); + + describe('flatten(obj)', function () { + it('should return empty object for empty object', function () { + const parser = new Parser(); + assert.deepStrictEqual(parser.evaluate('flatten({})'), {}); + }); + + it('should return same object for flat object', function () { + const parser = new Parser(); + assert.deepStrictEqual(parser.evaluate('flatten({a: 1, b: 2})'), { a: 1, b: 2 }); + }); + + it('should flatten nested object', function () { + const parser = new Parser(); + assert.deepStrictEqual( + parser.evaluate('flatten(obj)', { obj: { foo: { bar: 1 } } }), + { foo_bar: 1 } + ); + }); + + it('should flatten deeply nested object', function () { + const parser = new Parser(); + assert.deepStrictEqual( + parser.evaluate('flatten(obj)', { obj: { a: { b: { c: 1 } } } }), + { a_b_c: 1 } + ); + }); + + it('should handle mixed nested and flat keys', function () { + const parser = new Parser(); + assert.deepStrictEqual( + parser.evaluate('flatten(obj)', { obj: { a: 1, b: { c: 2, d: 3 }, e: 4 } }), + { a: 1, b_c: 2, b_d: 3, e: 4 } + ); + }); + + it('should preserve arrays as values', function () { + const parser = new Parser(); + assert.deepStrictEqual( + parser.evaluate('flatten(obj)', { obj: { a: { b: [1, 2, 3] } } }), + { a_b: [1, 2, 3] } + ); + }); + + it('should return undefined if argument is undefined', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('flatten(undefined)'), undefined); + }); + + it('should use custom separator when provided', function () { + const parser = new Parser(); + assert.deepStrictEqual( + parser.evaluate('flatten(obj, ".")', { obj: { foo: { bar: 1 } } }), + { 'foo.bar': 1 } + ); + }); + }); +});