Skip to content

Commit 1d7fb14

Browse files
committed
fix: escape dots in key names during flatten/unflatten
Keys containing periods (e.g., "Key 0.002mm") were incorrectly split into nested structures during flattening. Now dots within key names are escaped as \. before joining with the . delimiter, and unescaped after splitting during unflattening. Fixes #1510
1 parent e64b101 commit 1d7fb14

2 files changed

Lines changed: 145 additions & 8 deletions

File tree

packages/core/src/v3/utils/flattenAttributes.ts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,40 @@ export const CIRCULAR_REFERENCE_SENTINEL = "$@circular((";
55

66
const DEFAULT_MAX_DEPTH = 128;
77

8+
/** Escape literal dots in a key segment so they are not confused with the path delimiter. */
9+
function escapeKey(key: string): string {
10+
return key.replace(/\\/g, "\\\\").replace(/\./g, "\\.");
11+
}
12+
13+
/** Unescape a key segment that was escaped by `escapeKey`. */
14+
function unescapeKey(key: string): string {
15+
return key.replace(/\\(.)/g, "$1");
16+
}
17+
18+
/**
19+
* Split a flattened attribute path on unescaped dots.
20+
* Escaped dots (`\\.`) are preserved inside key segments and later unescaped.
21+
*/
22+
function splitKey(key: string): string[] {
23+
const parts: string[] = [];
24+
let current = "";
25+
for (let i = 0; i < key.length; i++) {
26+
const ch = key[i];
27+
if (ch === "\\" && i + 1 < key.length) {
28+
// Keep the escape sequence intact for now; unescapeKey will handle it
29+
current += ch + key[i + 1];
30+
i++;
31+
} else if (ch === ".") {
32+
parts.push(current);
33+
current = "";
34+
} else {
35+
current += ch;
36+
}
37+
}
38+
parts.push(current);
39+
return parts;
40+
}
41+
842
export function flattenAttributes(
943
obj: unknown,
1044
prefix?: string,
@@ -116,7 +150,7 @@ class AttributeFlattener {
116150
for (const [key, value] of obj) {
117151
if (!this.canAddMoreAttributes()) break;
118152
// Use the key directly if it's a string, otherwise convert it
119-
const keyStr = typeof key === "string" ? key : String(key);
153+
const keyStr = typeof key === "string" ? escapeKey(key) : escapeKey(String(key));
120154
this.#processValue(value, `${prefix || "map"}.${keyStr}`, depth);
121155
}
122156
return;
@@ -200,7 +234,8 @@ class AttributeFlattener {
200234
break;
201235
}
202236

203-
const newPrefix = `${prefix ? `${prefix}.` : ""}${Array.isArray(obj) ? `[${key}]` : key}`;
237+
const escapedKey = Array.isArray(obj) ? `[${key}]` : escapeKey(key);
238+
const newPrefix = `${prefix ? `${prefix}.` : ""}${escapedKey}`;
204239

205240
if (Array.isArray(value)) {
206241
for (let i = 0; i < value.length; i++) {
@@ -278,19 +313,20 @@ export function unflattenAttributes(
278313
continue;
279314
}
280315

281-
const parts = key.split(".").reduce(
316+
const parts = splitKey(key).reduce(
282317
(acc, part) => {
283318
if (part.startsWith("[") && part.endsWith("]")) {
284319
// Handle array indices more precisely
285-
const match = part.match(/^\[(\d+)\]$/);
286-
if (match && match[1]) {
287-
acc.push(parseInt(match[1]));
320+
const inner = part.slice(1, -1);
321+
const match = inner.match(/^\d+$/);
322+
if (match) {
323+
acc.push(parseInt(inner));
288324
} else {
289325
// Remove brackets for non-numeric array keys
290-
acc.push(part.slice(1, -1));
326+
acc.push(unescapeKey(inner));
291327
}
292328
} else {
293-
acc.push(part);
329+
acc.push(unescapeKey(part));
294330
}
295331
return acc;
296332
},

packages/core/test/flattenAttributes.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,83 @@ describe("flattenAttributes", () => {
547547
// Should complete without stack overflow
548548
expect(() => flattenAttributes({ arr: deepArray })).not.toThrow();
549549
});
550+
551+
it("handles keys containing periods correctly", () => {
552+
// The exact case from issue #1510
553+
const obj = { "Key 0.002mm": 31.4 };
554+
const flattened = flattenAttributes(obj);
555+
expect(flattened).toEqual({ "Key 0\\.002mm": 31.4 });
556+
557+
const unflattened = unflattenAttributes(flattened);
558+
expect(unflattened).toEqual({ "Key 0.002mm": 31.4 });
559+
});
560+
561+
it("handles nested objects with dotted keys", () => {
562+
const obj = {
563+
measurements: {
564+
"tolerance.min": 0.5,
565+
"tolerance.max": 1.5,
566+
},
567+
};
568+
const flattened = flattenAttributes(obj);
569+
expect(flattened).toEqual({
570+
"measurements.tolerance\\.min": 0.5,
571+
"measurements.tolerance\\.max": 1.5,
572+
});
573+
574+
const unflattened = unflattenAttributes(flattened);
575+
expect(unflattened).toEqual(obj);
576+
});
577+
578+
it("handles keys with multiple periods", () => {
579+
const obj = { "a.b.c": "value" };
580+
const flattened = flattenAttributes(obj);
581+
expect(flattened).toEqual({ "a\\.b\\.c": "value" });
582+
583+
const unflattened = unflattenAttributes(flattened);
584+
expect(unflattened).toEqual({ "a.b.c": "value" });
585+
});
586+
587+
it("handles dotted keys mixed with normal nesting", () => {
588+
const obj = {
589+
parent: {
590+
"key.with.dots": "dotted",
591+
normalKey: "normal",
592+
},
593+
};
594+
const flattened = flattenAttributes(obj);
595+
expect(flattened).toEqual({
596+
"parent.key\\.with\\.dots": "dotted",
597+
"parent.normalKey": "normal",
598+
});
599+
600+
const unflattened = unflattenAttributes(flattened);
601+
expect(unflattened).toEqual(obj);
602+
});
603+
604+
it("handles keys containing backslashes", () => {
605+
const obj = { "back\\slash": "value" };
606+
const flattened = flattenAttributes(obj);
607+
expect(flattened).toEqual({ "back\\\\slash": "value" });
608+
609+
const unflattened = unflattenAttributes(flattened);
610+
expect(unflattened).toEqual({ "back\\slash": "value" });
611+
});
612+
613+
it("round-trips dotted keys with arrays", () => {
614+
const obj = {
615+
"config.v2": [10, 20, 30],
616+
};
617+
const flattened = flattenAttributes(obj);
618+
expect(flattened).toEqual({
619+
"config\\.v2.[0]": 10,
620+
"config\\.v2.[1]": 20,
621+
"config\\.v2.[2]": 30,
622+
});
623+
624+
const unflattened = unflattenAttributes(flattened);
625+
expect(unflattened).toEqual({ "config.v2": [10, 20, 30] });
626+
});
550627
});
551628

552629
describe("unflattenAttributes", () => {
@@ -667,4 +744,28 @@ describe("unflattenAttributes", () => {
667744
}
668745
expect(current).toBeUndefined();
669746
});
747+
748+
it("unflattens keys with escaped dots correctly", () => {
749+
const flattened = {
750+
"parent.dotted\\.key": "value",
751+
};
752+
const result = unflattenAttributes(flattened);
753+
expect(result).toEqual({
754+
parent: {
755+
"dotted.key": "value",
756+
},
757+
});
758+
});
759+
760+
it("unflattens keys with escaped backslashes correctly", () => {
761+
const flattened = {
762+
"parent.back\\\\slash": "value",
763+
};
764+
const result = unflattenAttributes(flattened);
765+
expect(result).toEqual({
766+
parent: {
767+
"back\\slash": "value",
768+
},
769+
});
770+
});
670771
});

0 commit comments

Comments
 (0)