series-extractor
Version:
A TypeScript library for extracting data series from nested objects and arrays using a custom syntax.
1,048 lines (905 loc) • 36.7 kB
text/typescript
import {
ObjectNesting,
ArrayNesting,
TokenType,
Token,
Parser,
seriesExtractor,
SeriesSyntaxError,
Value,
Pair,
ExtractableFlags
} from '../src/index';
describe('Parser', () => {
const assertError = (syntax: string, err: string) => {
expect(() => seriesExtractor(syntax)).toThrow(SeriesSyntaxError);
expect(() => seriesExtractor(syntax)).toThrow(new RegExp(err));
};
it('should consume characters as expected', () => {
const p = new Parser(" a\\b \\ \n\t\r ");
expect(p.next()).toBe("a"); // Call public method
p.backtrack();
expect(p.next()).toBe("a");
expect(p.next()).toBe("\\b");
expect(p.next()).toBe("\\ ");
p.backtrack();
expect(p.next()).toBe("\\ ");
expect(p.next()).toBeUndefined();
p.backtrack();
expect(p.next()).toBeUndefined();
const p2 = new Parser("\\");
expect(() => p2.next()).toThrow(/missing subsequent escaped character/);
});
it('should parse syntax 1 correctly', () => {
const res = seriesExtractor("a.b.$c");
const exp = new ObjectNesting().add(
new Token("a", TokenType.STRING),
new ObjectNesting().add(
new Token("b", TokenType.STRING),
new Token("c", TokenType.STRING | TokenType.DIMENSION)
)
);
expect(res.equals(exp)).toBe(true);
});
it('should parse syntax 2 correctly', () => {
const res = seriesExtractor("[#3.$a, 3:$a, #$3.$a, 3.$a, $3.$a, $3:$a]");
const a = new Token("a", TokenType.STRING | TokenType.DIMENSION);
const exp = new ArrayNesting()
.add(
new Token(0, TokenType.IMPLICIT_INTEGER),
new ArrayNesting().add(new Token(3, TokenType.EXPLICIT_INTEGER), a)
)
.add(
new Token(3, TokenType.IMPLICIT_INTEGER), a
)
.add(
new Token(2, TokenType.IMPLICIT_INTEGER),
new ArrayNesting().add(new Token('3', TokenType.EXPLICIT_INTEGER | TokenType.DIMENSION), a)
)
.add(
new Token(3, TokenType.IMPLICIT_INTEGER),
new ObjectNesting().add(new Token("3", TokenType.STRING), a)
)
.add(
new Token(4, TokenType.IMPLICIT_INTEGER),
new ObjectNesting().add(new Token("3", TokenType.STRING | TokenType.DIMENSION), a)
)
.add(
new Token("3", TokenType.IMPLICIT_INTEGER | TokenType.DIMENSION), a
);
expect(res.equals(exp)).toBe(true);
});
it('should parse syntax 3 correctly', () => {
const res = seriesExtractor("#$5.#5.5.$5");
const exp = new ArrayNesting().add(
new Token("5", TokenType.DIMENSION | TokenType.EXPLICIT_INTEGER),
new ArrayNesting().add(
new Token(5, TokenType.EXPLICIT_INTEGER),
new ObjectNesting().add(
new Token("5", TokenType.STRING),
new Token("5", TokenType.STRING | TokenType.DIMENSION)
)
)
);
expect(res.equals(exp)).toBe(true);
});
it('should parse syntax 4 correctly', () => {
const res = seriesExtractor("a.b{$c:[6:$d], e:\\#f.$g}");
const exp = new ObjectNesting().add(
new Token("a", TokenType.STRING),
new ObjectNesting().add(
new Token("b", TokenType.STRING),
new ObjectNesting().add(
new Token("c", TokenType.STRING | TokenType.DIMENSION),
new ArrayNesting().add(
new Token(6, TokenType.IMPLICIT_INTEGER),
new Token("d", TokenType.STRING | TokenType.DIMENSION)
)
)
.add(
new Token("e", TokenType.STRING),
new ObjectNesting().add(
new Token("#f", TokenType.STRING),
new Token("g", TokenType.STRING | TokenType.DIMENSION)
)
)
)
);
expect(res.equals(exp)).toBe(true);
});
it('should parse syntax 5 correctly', () => {
const res = seriesExtractor("[[$a],{b:$c}]");
const exp = new ArrayNesting().add(
new Token(0, TokenType.IMPLICIT_INTEGER),
new ArrayNesting().add(
new Token(0, TokenType.IMPLICIT_INTEGER),
new Token("a", TokenType.STRING | TokenType.DIMENSION)
)
).add(
new Token(1, TokenType.IMPLICIT_INTEGER),
new ObjectNesting().add(
new Token("b", TokenType.STRING),
new Token("c", TokenType.STRING | TokenType.DIMENSION)
)
);
expect(res.equals(exp)).toBe(true);
});
it('should handle invalid syntax', () => {
assertError("[]", "Empty array");
assertError("[a", "Unclosed array");
assertError("[", "Expected array key or value");
assertError("[#2:", "Do not use # for integer keys inside arrays");
assertError("a.b.$c]", "Unexpected character after end of syntax");
});
it('should extract rows correctly - example 1', () => {
const json = [{ 'a': [0, 1], 'b': [2] }, { 'c': [4, 5, 6] }];
const syntax = "#$.$x.#$.$y";
const nesting = seriesExtractor(syntax);
const data = Array.from(nesting.extractRows(json));
const expected = [
{ 'x': 'a', 'y': 0 },
{ 'x': 'a', 'y': 1 },
{ 'x': 'b', 'y': 2 },
{ 'x': 'c', 'y': 4 },
{ 'x': 'c', 'y': 5 },
{ 'x': 'c', 'y': 6 }
];
expect(data).toEqual(expected);
});
it('should extract rows correctly - example 2', () => {
const json = { "a": 3, "b": 4, "c": 5 };
const syntax = "{a:$x, b:$y, c:$z}";
const nesting = seriesExtractor(syntax);
const data = Array.from(nesting.extractRows(json));
const expected = [
{ 'x': 3, 'y': 4, 'z': 5 },
];
expect(data).toEqual(expected);
});
// Test anonymous dimensions (wildcard $)
it('should handle anonymous dimension variables', () => {
const json = { 'a': 1, 'b': 2, 'c': 3 };
const syntax = "{$:$val}";
const nesting = seriesExtractor(syntax);
const data = Array.from(nesting.extractRows(json));
expect(data).toEqual([
{ 'val': 1 },
{ 'val': 2 },
{ 'val': 3 }
]);
});
// Test shortcut with anonymous dimension
it('should parse shortcut with anonymous dimension', () => {
const json = { 'a': { 'x': 1 }, 'b': { 'x': 2 } };
const syntax = "{$:{x:$val}}";
const nesting = seriesExtractor(syntax);
const data = Array.from(nesting.extractRows(json));
expect(data).toEqual([
{ 'val': 1 },
{ 'val': 2 }
]);
});
// Test escaped characters
it('should handle escaped special characters', () => {
const json = { 'a.b': { 'c': 10 } };
const syntax = "a\\.b{c:$val}";
const nesting = seriesExtractor(syntax);
const data = Array.from(nesting.extractRows(json));
expect(data).toEqual([{ 'val': 10 }]);
});
// Test sparse array definitions
it('should handle sparse array definitions', () => {
const json = ['x', 'y', 'z', 'w', 'v'];
const syntax = "[4:$val, $other]";
const nesting = seriesExtractor(syntax);
const data = Array.from(nesting.extractRows(json));
// Index 0 is for implicit (second element in syntax), index 4 is explicit
expect(data).toEqual([
{ 'other': 'y', 'val': 'v' }
]);
});
// Test empty object error
it('should throw error on empty object', () => {
assertError("{}", "Empty object");
});
// Test more error cases
it('should throw error on missing colon in object', () => {
assertError("{a}", "Expected colon");
});
it('should throw error on unexpected character in parseShortcut', () => {
// This would be caught by parser logic
assertError("a,b", "Expected value");
});
// Test Token edge cases
it('should handle Token with explicit integer and dimension', () => {
const token = new Token("5", TokenType.EXPLICIT_INTEGER | TokenType.DIMENSION);
expect(token.name).toBe("5");
expect(token.flags).toBe(TokenType.EXPLICIT_INTEGER | TokenType.DIMENSION);
});
// Test finalized token
it('should check if token is finalized', () => {
const token1 = new Token("a", TokenType.STRING);
expect(token1.finalized).toBe(true);
const token2 = new Token("a", TokenType.STRING | TokenType.IMPLICIT_INTEGER);
expect(token2.finalized).toBe(false);
});
// Test finalizeType errors
it('should throw error when finalizing already finalized token', () => {
const token = new Token("test", TokenType.STRING);
expect(() => token.finalizeType(TokenType.STRING)).toThrow("token type already finalized");
});
it('should throw error on invalid finalizeType argument', () => {
const token = new Token("test", TokenType.STRING | TokenType.IMPLICIT_INTEGER);
expect(() => token.finalizeType(TokenType.EXPLICIT_INTEGER)).toThrow("invalid type argument");
});
// Test Token.fromBuilder errors
it('should throw error on invalid flags in fromBuilder', () => {
expect(() => Token.fromBuilder(["test"], TokenType.IMPLICIT_INTEGER)).toThrow("invalid flags argument");
});
it('should throw error on # without integer key', () => {
expect(() => Token.fromBuilder(["#"], TokenType.STRING | TokenType.EXPLICIT_INTEGER)).toThrow("Expected integer key after #");
});
it('should throw error when explicit integer not allowed', () => {
expect(() => Token.fromBuilder(["#", "5"], TokenType.STRING)).toThrow("Explicit integer key shortcut not allowed here");
});
// Test extraction with different data types
it('should handle extraction with null values', () => {
const json = { 'a': null };
const syntax = "a.$val";
const nesting = seriesExtractor(syntax);
const data = Array.from(nesting.extractRows(json));
expect(data).toEqual([{ 'val': null }]);
});
// Test that leaf values must be dimension variables
it('should throw error when leaf value is not a dimension', () => {
// Test via low-level API
expect(() => {
new ObjectNesting().add(
new Token("key", TokenType.STRING),
new Token("value", TokenType.STRING) // Missing DIMENSION flag
);
}).toThrow("Leaf value must be a dimension variable");
// Test via parser - leaf without $ should be rejected
assertError("{a: b}", "Leaf value must be a dimension variable");
assertError("[a]", "Leaf value must be a dimension variable");
assertError("{a: {b: c}}", "Leaf value must be a dimension variable");
});
// Test that anonymous dimensions ARE allowed as leaf values
it('should allow anonymous dimensions as leaf values', () => {
// Anonymous leaf in object - yields single row with no fields
const extractor1 = seriesExtractor("{a: $}");
const data1 = Array.from(extractor1.extractRows({ a: 42 }));
expect(data1).toEqual([{}]);
// Anonymous leaf in array - only yields one row (anonymous values don't create multiple rows)
const extractor2 = seriesExtractor("[$]");
const data2 = Array.from(extractor2.extractRows([10, 20, 30]));
expect(data2).toEqual([{}]);
// Anonymous leaf in array of objects - still only ONE empty row
const extractor4 = seriesExtractor("[$]");
const data4 = Array.from(extractor4.extractRows([{a: 42}, {a: 43}]));
expect(data4).toEqual([{}]); // One empty row, not two
// With ANONYMOUS flag, extract() returns the anonymous values
const extractor3 = seriesExtractor("{a: $}");
const values = Array.from(extractor3.extract({ a: 42 }, ExtractableFlags.ANONYMOUS));
expect(values.length).toBe(1);
const firstValue = values[0];
if (typeof firstValue !== 'string') { // Not PUSH or POP
expect(firstValue.anonymous).toBe(true);
expect(firstValue.value).toBe(42);
}
});
// Test extract() with and without ANONYMOUS flag for array of values
it('should return values based on ANONYMOUS flag', () => {
const extractor = seriesExtractor("[$]");
const testData = [10, 20, 30];
// WITHOUT ANONYMOUS flag - returns 0 values (anonymous values excluded)
const withoutFlag = Array.from(extractor.extract(testData));
expect(withoutFlag.length).toBe(0);
// WITH ANONYMOUS flag - returns only 1 value (from first/index 0)
// [$] means anonymous key (doesn't iterate) + anonymous value
const withFlag = Array.from(extractor.extract(testData, ExtractableFlags.ANONYMOUS));
expect(withFlag.length).toBe(1);
const val = withFlag[0];
if (typeof val !== 'string') {
expect(val.anonymous).toBe(true);
expect(val.value).toBe(10); // First array element only
}
// Even with STACK flag, still only 1 value because [$] = [0: $]
// To iterate through all elements, need dimension key: [$: ...]
const withBothFlags = Array.from(extractor.extract(testData, ExtractableFlags.ANONYMOUS | ExtractableFlags.STACK));
const values = withBothFlags.filter(v => typeof v !== 'string');
expect(values.length).toBe(1); // Still just index 0
});
// Test that [$: $] iterates through all array elements (dimension key)
it('should iterate all array elements with dimension key [$: $]', () => {
const extractor = seriesExtractor("[$: $]");
const testData = [10, 20, 30];
// WITHOUT ANONYMOUS flag - returns 0 values (both key and value are anonymous)
const withoutFlag = Array.from(extractor.extract(testData));
expect(withoutFlag.length).toBe(0);
// WITH ANONYMOUS flag - returns 6 values: key+value for each element
const withFlag = Array.from(extractor.extract(testData, ExtractableFlags.ANONYMOUS));
expect(withFlag.length).toBe(6); // 2 per element: anonymous key + anonymous value
// Values should be: 0, 10, 1, 20, 2, 30
const vals = withFlag as Value[];
expect(vals[0].value).toBe(0); // First anonymous key (index)
expect(vals[1].value).toBe(10); // First anonymous value
expect(vals[2].value).toBe(1); // Second anonymous key
expect(vals[3].value).toBe(20); // Second anonymous value
expect(vals[4].value).toBe(2); // Third anonymous key
expect(vals[5].value).toBe(30); // Third anonymous value
});
// Test Nesting.has with invalid JSON types
it('should handle has() method with non-object types', () => {
const nesting = new ObjectNesting();
expect(nesting.has(null, "key")).toBe(false);
expect(nesting.has(undefined, "key")).toBe(false);
expect(nesting.has("string", "key")).toBe(false);
});
// Test ArrayNesting.has with invalid array
it('should handle ArrayNesting.has with non-array types', () => {
const nesting = new ArrayNesting();
expect(nesting.has({} as any, 0)).toBe(false);
expect(nesting.has(null as any, 0)).toBe(false);
});
// Test extraction with missing keys
it('should skip missing keys during extraction', () => {
const json = { 'a': 1 };
const syntax = "{a:$x, b:$y}";
const nesting = seriesExtractor(syntax);
const data = Array.from(nesting.extractRows(json));
expect(data).toEqual([{ 'x': 1 }]); // b is missing, so y is not included
});
// Test toString methods
it('should have meaningful toString representations', () => {
const token = new Token("test", TokenType.STRING | TokenType.DIMENSION);
expect(token.toString()).toContain("test");
const nesting = new ObjectNesting();
expect(nesting.toString()).toContain("ObjectNesting");
});
// Test Value class
it('should create and use Value objects correctly', () => {
const token = new Token("myVar", TokenType.STRING | TokenType.DIMENSION);
const value = new Value(token, 42);
expect(value.name).toBe("myVar");
expect(value.value).toBe(42);
expect(value.anonymous).toBe(false);
expect(value.toString()).toContain("myVar=42");
});
// Test anonymous Value
it('should handle anonymous values', () => {
const token = new Token(null, TokenType.STRING | TokenType.DIMENSION);
const value = new Value(token, 100);
expect(value.anonymous).toBe(true);
expect(value.name).toBe(null);
});
// Test Pair equality
it('should compare Pairs correctly', () => {
const key1 = new Token("a", TokenType.STRING);
const val1 = new Token("b", TokenType.STRING | TokenType.DIMENSION);
const pair1 = new Pair(key1, val1);
const key2 = new Token("a", TokenType.STRING);
const val2 = new Token("b", TokenType.STRING | TokenType.DIMENSION);
const pair2 = new Pair(key2, val2);
expect(pair1.equals(pair2)).toBe(true);
const key3 = new Token("c", TokenType.STRING);
const pair3 = new Pair(key3, val1);
expect(pair1.equals(pair3)).toBe(false);
});
// Test Nesting equals with different types
it('should return false when comparing different nesting types', () => {
const obj = new ObjectNesting().add(
new Token("a", TokenType.STRING),
new Token("b", TokenType.STRING | TokenType.DIMENSION)
);
const arr = new ArrayNesting().add(
new Token(0, TokenType.IMPLICIT_INTEGER),
new Token("b", TokenType.STRING | TokenType.DIMENSION)
);
expect(obj.equals(arr)).toBe(false);
});
// Test array with explicit integers in shortcut
it('should parse array shortcut with explicit integer', () => {
const json = [[1, 2], [3, 4]];
const syntax = "#0[$val]";
const nesting = seriesExtractor(syntax);
const data = Array.from(nesting.extractRows(json));
// Extracts from json[0], which is [1, 2]
expect(data).toEqual([
{ 'val': 1 }
]);
});
// Test SeriesSyntaxError structure
it('should provide structured error information', () => {
const syntax = "a.b.$c]";
try {
seriesExtractor(syntax);
fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(SeriesSyntaxError);
if (error instanceof SeriesSyntaxError) {
// Check structured properties
expect(error.line).toBe(1);
expect(error.col).toBe(7);
expect(error.char).toBe(7);
expect(error.syntaxInput).toBe(syntax);
expect(error.originalMessage).toBe("Unexpected character after end of syntax");
// Check that context is formatted
expect(error.context).toContain('a.b.$c]');
expect(error.context).toContain('^');
// Check that full message includes position info
expect(error.message).toContain('line #1');
expect(error.message).toContain('col #7');
expect(error.message).toContain('char #7');
expect(error.message).toContain('a.b.$c]');
}
}
});
// Test SeriesSyntaxError formatting
it('should format error message nicely', () => {
const syntax = "[#2:";
try {
seriesExtractor(syntax);
fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(SeriesSyntaxError);
if (error instanceof SeriesSyntaxError) {
// The message should be human-readable
expect(error.message).toContain('Invalid series syntax');
expect(error.message).toContain('[#2:');
expect(error.originalMessage).toContain('Do not use # for integer keys inside arrays');
}
}
});
// Test mixing dimension keys with constant keys in same object
it('should handle mixing dimension keys with constant keys', () => {
const data = {
key1: { a: 10, b: 20, c: 30 },
key2: { a: 40, b: 50, c: 60 }
};
const extractor = seriesExtractor(`{$keyName: {$a: $x, b: $y}}`);
const result = Array.from(extractor.extractRows(data));
// Expected behavior: for each outer key (key1, key2):
// - Extract constant key 'b' once
// - Iterate through all keys for dimension $a
expect(result).toEqual([
{ keyName: 'key1', a: 'a', x: 10, y: 20 },
{ keyName: 'key1', a: 'b', x: 20, y: 20 },
{ keyName: 'key1', a: 'c', x: 30, y: 20 },
{ keyName: 'key2', a: 'a', x: 40, y: 50 },
{ keyName: 'key2', a: 'b', x: 50, y: 50 },
{ keyName: 'key2', a: 'c', x: 60, y: 50 }
]);
});
// Test nested object with double nested arrays
it('should handle nested object with double nested arrays', () => {
const data = {
outer: {
"key-A": { inner: [[1, 2], [3]] },
"key-B": { inner: [[4]] }
}
};
const extractor = seriesExtractor(`
outer {
$key: inner [$: [$: $val]]
}
`);
const result = Array.from(extractor.extractRows(data));
// Should extract all values from both keys
expect(result).toEqual([
{ key: 'key-A', val: 1 },
{ key: 'key-A', val: 2 },
{ key: 'key-A', val: 3 },
{ key: 'key-B', val: 4 }
]);
});
// Test README example - validates the documentation example works correctly
it('should extract data correctly for README example', () => {
const sensorData = {
buildings: {
"building-A": {
floors: [
[{ temp: 68, humidity: 45 }, { temp: 70, humidity: 48 }],
[{ temp: 72, humidity: 50 }]
],
roof: { temp: 90, humidity: 55 }
},
"building-B": {
floors: [[{ temp: 69, humidity: 46 }]],
roof: { temp: 85, humidity: 52 }
}
}
};
const extractor = seriesExtractor(`
buildings.$building {
floors: #$floor.#$ {
temp: $temp,
humidity: $humidity
},
roof: {
temp: $temp,
humidity: $humidity
}
}
`);
const data = Array.from(extractor.extractRows(sensorData));
expect(data).toEqual([
{ building: 'building-A', floor: 0, temp: 68, humidity: 45 },
{ building: 'building-A', floor: 0, temp: 70, humidity: 48 },
{ building: 'building-A', floor: 1, temp: 72, humidity: 50 },
{ building: 'building-A', temp: 90, humidity: 55 },
{ building: 'building-B', floor: 0, temp: 69, humidity: 46 },
{ building: 'building-B', temp: 85, humidity: 52 }
]);
});
// Test mixing constant key with anonymous dimension key at same level
it('should combine constant and dimension keys into same rows', () => {
const data = {
x: { a: 1, b: 2 },
y: { a: 3, b: 4 },
c: 5
};
const extractor = seriesExtractor("{$: {a: $a, b: $b}, c: $c}");
const result = Array.from(extractor.extractRows(data));
// The constant key 'c' is processed first and adds c=5 to the row context
// Then the anonymous dimension '$:' iterates over ALL keys (x, y, c)
// For x: extracts a=1, b=2, yields row {c: 5, a: 1, b: 2}
// For y: extracts a=3, b=4, yields row {c: 5, a: 3, b: 4}
// For c: value is 5 (not an object), cannot extract a/b, no row generated
expect(result).toEqual([
{ c: 5, a: 1, b: 2 },
{ c: 5, a: 3, b: 4 }
]);
// Note: c appears in both rows because it's a constant key at the same level
// The dimension iteration happens AFTER constant keys are processed
});
// Contrast: same test but with named dimension $nest instead of anonymous $
it('should include named dimension in output rows (contrast with anonymous)', () => {
const data = {
x: { a: 1, b: 2 },
y: { a: 3, b: 4 },
c: 5
};
const extractor = seriesExtractor("{$nest: {a: $a, b: $b}, c: $c}");
const result = Array.from(extractor.extractRows(data));
// Same behavior as anonymous dimension test, BUT now 'nest' appears in output
// The constant key 'c' is processed first and adds c=5 to the row context
// Then the named dimension '$nest:' iterates over ALL keys (x, y, c)
// For x: nest='x', extracts a=1, b=2, yields row {c: 5, nest: 'x', a: 1, b: 2}
// For y: nest='y', extracts a=3, b=4, yields row {c: 5, nest: 'y', a: 3, b: 4}
// For c: value is 5 (not an object), cannot extract a/b, no row generated
expect(result).toEqual([
{ c: 5, nest: 'x', a: 1, b: 2 },
{ c: 5, nest: 'y', a: 3, b: 4 }
]);
// Key difference: 'nest' field appears in output because it's a NAMED dimension
// Anonymous dimensions ($) don't appear in the output, only named ones do
});
// Test mixing constant array indices with dimension iteration (arrays behave like objects)
it('should combine constant array indices with dimension iteration', () => {
const data = [
{ x: 10, d: 100 }, // index 0
{ x: 20, d: 200 }, // index 1
{ x: 30, d: 300 }, // index 2
{ x: 40, d: 400 } // index 3
];
const extractor = seriesExtractor("[0:{x:$a}, 3:{x:$b}, $:{d:$d}]");
const result = Array.from(extractor.extractRows(data));
// Constants 0 and 3 are processed first:
// - index 0: extract x → a=10
// - index 3: extract x → b=40
// Then dimension $: iterates over ALL indices (0, 1, 2, 3):
// - For index 0: extract d=100, yield {a: 10, b: 40, d: 100}
// - For index 1: extract d=200, yield {a: 10, b: 40, d: 200}
// - For index 2: extract d=300, yield {a: 10, b: 40, d: 300}
// - For index 3: extract d=400, yield {a: 10, b: 40, d: 400}
expect(result).toEqual([
{ a: 10, b: 40, d: 100 },
{ a: 10, b: 40, d: 200 },
{ a: 10, b: 40, d: 300 },
{ a: 10, b: 40, d: 400 }
]);
// Constant array indices behave the same as constant object keys:
// they're processed first and persist in all rows from dimension iteration
});
// Test when multiple constants extract to the same field name (last wins)
it('should overwrite field when multiple constants extract to same name', () => {
const data = [
{ x: 10, d: 100 }, // index 0
{ x: 20, d: 200 }, // index 1
{ x: 30, d: 300 }, // index 2
{ x: 40, d: 400 } // index 3
];
const extractor = seriesExtractor("[0:{x:$a}, 3:{x:$a}, $:{d:$d}]");
const result = Array.from(extractor.extractRows(data));
// Constants are processed in order:
// - index 0: extract x → a=10 (sets row.a = 10)
// - index 3: extract x → a=40 (overwrites row.a = 40)
// Then dimension $: iterates, yielding rows with a=40 (last value wins)
expect(result).toEqual([
{ a: 40, d: 100 },
{ a: 40, d: 200 },
{ a: 40, d: 300 },
{ a: 40, d: 400 }
]);
// Last constant wins when multiple constants write to same field
});
// Explore what constitutes a "row" - siblings at same level
it('should group siblings at same level into one row', () => {
const data = { a: 1, b: 2, c: 3 };
const extractor = seriesExtractor("{a: $a, b: $b, c: $c}");
const result = Array.from(extractor.extractRows(data));
// All siblings at same level, no dimension variables (all constants)
// Yields one row with all three values
expect(result).toEqual([{ a: 1, b: 2, c: 3 }]);
});
// Explore what constitutes a "row" - ancestors in a chain
it('should group ancestor chain into rows (one per leaf)', () => {
const data = { a: { b: { c: 1, d: 2 } } };
const extractor = seriesExtractor("a.b{c: $c, d: $d}");
const result = Array.from(extractor.extractRows(data));
// All constants in a chain, leaf values at same level
// One row with both leaf values
expect(result).toEqual([{ c: 1, d: 2 }]);
});
// Explore what constitutes a "row" - metadata + dimension iteration
it('should broadcast constant metadata to all dimension iteration rows', () => {
const data = {
metadata: { station: "Station-A", region: "North" },
readings: [
{ time: 1, temp: 20 },
{ time: 2, temp: 22 }
]
};
const extractor = seriesExtractor(`{
metadata: {station: $station, region: $region},
readings: [$: {time: $time, temp: $temp}]
}`);
const result = Array.from(extractor.extractRows(data));
// Constants (metadata) are broadcast to all rows from dimension iteration
// Each row = one reading with its metadata context
expect(result).toEqual([
{ station: "Station-A", region: "North", time: 1, temp: 20 },
{ station: "Station-A", region: "North", time: 2, temp: 22 }
]);
// Makes sense: each reading row includes its metadata context
});
// Explore what constitutes a "row" - nested constant under dimension
it('should include nested constants within dimension iteration', () => {
const data = {
eng: { budget: 100, employees: [{ name: "Alice" }, { name: "Bob" }] },
sales: { budget: 200, employees: [{ name: "Charlie" }] }
};
const extractor = seriesExtractor(`{
$dept: {
budget: $budget,
employees: [$: {name: $name}]
}
}`);
const result = Array.from(extractor.extractRows(data));
// Each row = one employee with their department and department budget
// budget is a constant under the dimension, gets included in nested iterations
expect(result).toEqual([
{ dept: "eng", budget: 100, name: "Alice" },
{ dept: "eng", budget: 100, name: "Bob" },
{ dept: "sales", budget: 200, name: "Charlie" }
]);
// Makes sense: budget is part of the dept context, shared by all employees
});
// Explore what constitutes a "row" - independent dimension siblings
it('should yield separate rows for independent dimension siblings', () => {
const data = {
users: [{ name: "Alice" }, { name: "Bob" }],
products: [{ name: "Widget" }, { name: "Gadget" }]
};
const extractor = seriesExtractor(`{
users: [$: {name: $userName}],
products: [$: {name: $productName}]
}`);
const result = Array.from(extractor.extractRows(data));
// Two independent dimension iterations
// Each dimension yields its own rows separately
expect(result).toEqual([
{ userName: "Alice" },
{ userName: "Bob" },
{ productName: "Widget" },
{ productName: "Gadget" }
]);
// Makes sense: these are independent dimensions, not related
});
// Complex case: constants with dimension intermixed at different levels
it('should handle dimension intermixed with constants at same nesting', () => {
const data = {
a: { b: { c: 10 } },
b: { c: { d: 20, key1: 100, key2: 200 } },
c: { x: 30 }
};
const extractor = seriesExtractor("{a: b.c.$x, b: c{d:$y, $e:$f}, c: x.$z}");
const result = Array.from(extractor.extractRows(data));
// Actual behavior: constants are processed in order, dimensions yield immediately
// - a.b.c → x=10 (added to row context)
// - b.c.d → y=20 (added to row context)
// - b.c.$e:$f → iterates, yields 3 rows with x,y,e,f (z NOT included yet!)
// - c.x → z=30 (added to row context)
// - End of extraction → yields final row with x,y,z (e,f were popped)
expect(result).toEqual([
{ x: 10, y: 20, e: 'd', f: 20 }, // z missing!
{ x: 10, y: 20, e: 'key1', f: 100 }, // z missing!
{ x: 10, y: 20, e: 'key2', f: 200 }, // z missing!
{ x: 10, y: 20, z: 30 } // e,f missing!
]);
// Problem: constants after a dimension don't appear in that dimension's rows
// This is confusing and probably not what users expect
});
// Test extraction order with dimensions before/between/after constants
it('should demonstrate extraction order with mixed constants and dimensions', () => {
const data = {
dimBefore: [{ val: 1 }, { val: 2 }],
constA: 100,
dimMiddle: [{ val: 3 }, { val: 4 }],
constB: 200,
dimAfter: [{ val: 5 }, { val: 6 }],
constC: 300
};
const extractor = seriesExtractor(`{
dimBefore: [$idx: {val: $before}],
constA: $a,
dimMiddle: [$idx: {val: $middle}],
constB: $b,
dimAfter: [$idx: {val: $after}],
constC: $c
}`);
const result = Array.from(extractor.extractRows(data));
// ACTUAL traversal order (all keys are constants, dimensions are in the values):
// 1. dimBefore (constant key) → ArrayNesting with $idx dimension → yields 2 rows
// - No other constants processed yet, so rows have only: idx, before
// 2. constA (constant key) → $a value → adds a=100 to row context
// 3. dimMiddle (constant key) → ArrayNesting with $idx dimension → yields 2 rows
// - constA already processed, so rows have: a, idx, middle (but NOT b or c!)
// 4. constB (constant key) → $b value → adds b=200 to row context
// 5. dimAfter (constant key) → ArrayNesting with $idx dimension → yields 2 rows
// - constA and constB processed, so rows have: a, b, idx, after (but NOT c!)
// 6. constC (constant key) → $c value → adds c=300 to row context
// 7. End of extraction → yields final row with all constants (a, b, c)
expect(result).toEqual([
{ idx: 0, before: 1 }, // Only dimBefore's values
{ idx: 1, before: 2 },
{ a: 100, idx: 0, middle: 3 }, // constA + dimMiddle
{ a: 100, idx: 1, middle: 4 },
{ a: 100, b: 200, idx: 0, after: 5 }, // constA, constB + dimAfter
{ a: 100, b: 200, idx: 1, after: 6 },
{ a: 100, b: 200, c: 300 } // Final row at end with all constants
]);
// Key insight: Constant keys are processed in order, and nested dimensions
// yield rows immediately with only the constants processed BEFORE them
});
// Test extraction order with nested structures
it('should demonstrate extraction order with nested constants and dimensions', () => {
const data = {
obj: {
const1: 10,
arr: [{ x: 1 }, { x: 2 }],
const2: 20
}
};
const extractor = seriesExtractor(`{
obj: {
const1: $c1,
arr: [$: {x: $x}],
const2: $c2
}
}`);
const result = Array.from(extractor.extractRows(data));
console.log("Nested extraction order:", JSON.stringify(result, null, 2));
// Within obj nesting:
// - const1 processed first → c1=10
// - arr dimension iterates → yields 2 rows with c1, x (c2 NOT yet processed!)
// - const2 processed after → c2=20
// - End of extraction → yields final row with c1, c2 (x popped)
expect(result).toEqual([
{ c1: 10, x: 1 }, // c2 not included yet!
{ c1: 10, x: 2 }, // c2 not included yet!
{ c1: 10, c2: 20 } // Final row with both constants, x popped
]);
// Same issue: constants AFTER a dimension don't appear in that dimension's rows
// They only appear in the final row (or subsequent dimensions' rows)
});
// Test: constant keys vs dimension keys at same level
it('should process constant keys before dimension keys', () => {
const data = {
constKey1: 100,
dimKey1: { a: 1, b: 2 },
constKey2: 200,
dimKey2: { a: 3, b: 4 }
};
// $dim1 and $dim2 are dimension KEYS (not values)
const extractor = seriesExtractor(`{
constKey1: $c1,
$dim1: {a: $x, b: $y},
constKey2: $c2,
$dim2: {a: $x, b: $y}
}`);
const result = Array.from(extractor.extractRows(data));
// Per user's docs: "constant keys are traversed before variable keys"
// 1. ALL constant keys processed first: constKey1 → c1=100, constKey2 → c2=200
// 2. THEN dimension keys iterate over DATA keys (not syntax keys!)
// - $dim1 and $dim2 both iterate over: dimKey1, dimKey2
// - This creates cartesian product: $dim1 × $dim2
// 3. Each row generated after visiting each dimension iteration
expect(result).toEqual([
{ c1: 100, c2: 200, dim1: 'dimKey1', x: 1, y: 2 }, // $dim1=dimKey1, $dim2 hasn't iterated yet
{ c1: 100, c2: 200, dim2: 'dimKey1', x: 1, y: 2 }, // $dim2=dimKey1, $dim1 was reset
{ c1: 100, c2: 200, dim1: 'dimKey2', x: 3, y: 4 }, // $dim1=dimKey2, $dim2 hasn't iterated yet
{ c1: 100, c2: 200, dim2: 'dimKey2', x: 3, y: 4 } // $dim2=dimKey2, $dim1 was reset
]);
// Confirms: ALL constant keys appear in all rows because they're processed first
});
// Test: verify dimension keys iterate over data keys, not syntax order
it('should iterate dimension keys in data order, not syntax order', () => {
const data = {
zebra: { val: 1 },
apple: { val: 2 },
monkey: { val: 3 }
};
// Dimension key $name will iterate over data keys (zebra, apple, monkey)
// in the order they appear in the data object
const extractor = seriesExtractor(`{$name: {val: $val}}`);
const result = Array.from(extractor.extractRows(data));
console.log("Data order iteration:", JSON.stringify(result, null, 2));
// JavaScript object iteration order for string keys: insertion order
// So should be: zebra, apple, monkey (in the order defined in data)
expect(result).toEqual([
{ name: 'zebra', val: 1 },
{ name: 'apple', val: 2 },
{ name: 'monkey', val: 3 }
]);
});
// Test: verify rows are generated after visiting dimension key
it('should generate row after visiting each dimension key iteration', () => {
const data = {
outer1: { inner1: { x: 1 }, inner2: { x: 2 } },
outer2: { inner1: { x: 3 }, inner2: { x: 4 } }
};
// Two levels of dimension keys: $outer iterates outer1/outer2,
// then $inner iterates inner1/inner2 within each
const extractor = seriesExtractor(`{$outer: {$inner: {x: $x}}}`);
const result = Array.from(extractor.extractRows(data));
console.log("Nested dimension keys:", JSON.stringify(result, null, 2));
// After visiting each $inner dimension, a row is generated
// Nested variables ($x) are reset after each $inner
expect(result).toEqual([
{ outer: 'outer1', inner: 'inner1', x: 1 },
{ outer: 'outer1', inner: 'inner2', x: 2 },
{ outer: 'outer2', inner: 'inner1', x: 3 },
{ outer: 'outer2', inner: 'inner2', x: 4 }
]);
});
// Test: emission order for sparse array with dimensions
it('should emit rows in syntax order for sparse array [4:$.$a, $.$b]', () => {
const data = [
[100, 101], // index 0
[200, 201], // index 1
[300, 301], // index 2
[400, 401], // index 3
[500, 501] // index 4
];
// [4:[$:$a], [$:$b]] means:
// - Index 4 (constant): iterate with anonymous dimension key, extract $a
// - Implicit index (constant): iterate with anonymous dimension key, extract $b
const extractor = seriesExtractor("[4:[$:$a], [$:$b]]");
const result = Array.from(extractor.extractRows(data));
console.log("Sparse array order:", JSON.stringify(result, null, 2));
// Both index 4 and implicit index (which would be 1) are constant keys
// They should be processed in syntax order:
// 1. Index 4 first → data[4] = [500, 501] → yields {a: 500}, {a: 501}
// 2. Implicit index 1 → data[1] = [200, 201] → yields {b: 200}, {b: 201}
expect(result).toEqual([
{ a: 500 }, // data[4][0]
{ a: 501 }, // data[4][1]
{ b: 200 }, // data[1][0]
{ b: 201 } // data[1][1]
]);
// Confirms: constant keys (including sparse array indices) are processed
// in the order they appear in the syntax
});
});