Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions packages/core/src/v3/utils/flattenAttributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,9 @@ class AttributeFlattener {
if (obj instanceof Map) {
for (const [key, value] of obj) {
if (!this.canAddMoreAttributes()) break;
// Use the key directly if it's a string, otherwise convert it
const keyStr = typeof key === "string" ? key : String(key);
// Use the key directly if it's a string, otherwise convert it.
// Escape dots so keys containing "." are not mistaken for path separators.
const keyStr = typeof key === "string" ? key.replace(/\./g, "\\.") : String(key);
this.#processValue(value, `${prefix || "map"}.${keyStr}`, depth);
}
return;
Expand Down Expand Up @@ -204,7 +205,10 @@ class AttributeFlattener {
break;
}

const newPrefix = `${prefix ? `${prefix}.` : ""}${Array.isArray(obj) ? `[${key}]` : key}`;
// Escape dots in string keys so they are not mistaken for path separators
// when unflattening. Array indices use bracket notation and need no escaping.
const escapedKey = Array.isArray(obj) ? `[${key}]` : key.replace(/\./g, "\\.");
const newPrefix = `${prefix ? `${prefix}.` : ""}${escapedKey}`;

if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
Expand Down Expand Up @@ -248,6 +252,31 @@ class AttributeFlattener {
}
}
}
/**
* Splits a flattened attribute key on dots that are NOT escaped with a backslash,
* then unescapes any `\.` sequences in each part back to `.`.
*
* This allows object keys that contain literal dots (e.g. "Key 0.002mm") to
* round-trip through flatten/unflatten without being treated as path separators.
*/
function splitOnUnescapedDots(key: string): string[] {
const parts: string[] = [];
let current = "";
for (let i = 0; i < key.length; i++) {
if (key[i] === "\\" && i + 1 < key.length && key[i + 1] === ".") {
current += ".";
i++; // skip the escaped dot
} else if (key[i] === ".") {
parts.push(current);
current = "";
} else {
current += key[i];
}
}
parts.push(current);
return parts;
}

export function unflattenAttributes(
obj: Attributes,
filteredKeys?: string[],
Expand Down Expand Up @@ -277,7 +306,7 @@ export function unflattenAttributes(
continue;
}

const parts = key.split(".").reduce(
const parts = splitOnUnescapedDots(key).reduce(
(acc, part) => {
if (part.startsWith("[") && part.endsWith("]")) {
// Handle array indices more precisely
Expand Down
15 changes: 15 additions & 0 deletions packages/core/test/flattenAttributes.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import { flattenAttributes, unflattenAttributes } from "../src/v3/utils/flattenAttributes.js";

describe("flattenAttributes", () => {
it("round-trips object keys that contain dots", () => {
// Keys with dots were previously split on the dot, turning "Key 0.002mm" into
// a nested path { "Key 0": { "002mm": ... } } on unflatten.
const input = { "Key 0.002mm": 31.4 };
const flattened = flattenAttributes(input);
expect(flattened).toEqual({ "Key 0\\.002mm": 31.4 });
expect(unflattenAttributes(flattened)).toEqual(input);

// Nested object where an intermediate key contains a dot
const nested = { outer: { "a.b": { inner: 1 } } };
const flatNested = flattenAttributes(nested);
expect(flatNested).toEqual({ "outer.a\\.b.inner": 1 });
expect(unflattenAttributes(flatNested)).toEqual(nested);
});

it("handles number keys correctly", () => {
expect(flattenAttributes({ bar: { "25": "foo" } })).toEqual({ "bar.25": "foo" });
expect(unflattenAttributes({ "bar.25": "foo" })).toEqual({ bar: { "25": "foo" } });
Expand Down