@garysui/json-ops
Version:
TypeScript utilities for JSON operations: flatten, diff, apply, and more
339 lines • 12.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.replaceUndefined = replaceUndefined;
exports.restoreUndefined = restoreUndefined;
exports.flat = flat;
exports.unflat = unflat;
exports.sortKeys = sortKeys;
exports.diff = diff;
exports.apply = apply;
const UNDEF_MARKER = "__UNDEFINED__";
function replaceUndefined(input) {
if (input === undefined)
return UNDEF_MARKER;
if (Array.isArray(input)) {
return input.map(replaceUndefined);
}
else if (typeof input === "object" && input !== null) {
const result = {};
for (const [k, v] of Object.entries(input)) {
result[k] = v === undefined ? UNDEF_MARKER : replaceUndefined(v);
}
return result;
}
return input;
}
function restoreUndefined(input) {
if (input === UNDEF_MARKER)
return undefined;
if (Array.isArray(input)) {
return input.map(restoreUndefined);
}
else if (typeof input === "object" && input !== null) {
const result = {};
for (const [k, v] of Object.entries(input)) {
result[k] = v === UNDEF_MARKER ? undefined : restoreUndefined(v);
}
return result;
}
return input;
}
function flat(obj) {
if (obj === undefined) {
throw new Error("Input to flat must not contain undefined. Use replaceUndefined beforehand.");
}
if (typeof obj !== 'object' || obj === null) {
return [{ "": obj }];
}
if (Array.isArray(obj)) {
if (obj.length === 0)
return [{ "[]": 0 }];
return obj.flatMap((val, i) => {
if (Array.isArray(val) && val.length === 0) {
return [{ [`[${i}][]`]: 0 }];
}
return flat(val).map(e => {
const [k, v] = Object.entries(e)[0];
return { [`[${i}]${k}`]: v };
});
});
}
if (typeof obj === 'object') {
if (Object.keys(obj).length === 0)
return [{ ".": 0 }];
return Object.entries(obj).flatMap(([k, v]) => flat(v).map(e => {
const [subk, subv] = Object.entries(e)[0];
return { [`.${k}${subk}`]: subv };
}));
}
throw new Error("Unhandled input in flat function.");
}
function unflat(entries) {
if (entries.length === 1 && entries[0][""] !== undefined)
return entries[0][""];
if (entries.length === 1 && "." in entries[0])
return {};
if (entries.length === 1 && "[]" in entries[0])
return [];
// Group entries by their root segment
const groups = new Map();
for (const entry of entries) {
const [path, value] = Object.entries(entry)[0];
// Extract the root segment
let rootSegment;
let remainingPath;
if (path.startsWith('.')) {
// Property access: .prop or .prop.more or .prop[0] etc
const match = path.match(/^(\.[^.\[]+)(.*)/);
if (match) {
rootSegment = match[1];
remainingPath = match[2];
}
else {
// Handle standalone '.'
rootSegment = '.';
remainingPath = '';
}
}
else if (path.startsWith('[')) {
// Array access: [0] or [0].more etc
const match = path.match(/^(\[\d+\])(.*)/);
if (match) {
rootSegment = match[1];
remainingPath = match[2];
}
else {
// Handle standalone '[]'
rootSegment = '[]';
remainingPath = '';
}
}
else if (path === '') {
// Direct value assignment with empty string
rootSegment = '';
remainingPath = '';
}
else {
// Other cases
rootSegment = path;
remainingPath = '';
}
if (!groups.has(rootSegment)) {
groups.set(rootSegment, []);
}
// Create new entry with remaining path
if (remainingPath === '') {
groups.get(rootSegment).push({ "": value });
}
else {
groups.get(rootSegment).push({ [remainingPath]: value });
}
}
// Determine if result should be an array or object
const hasArrayIndices = Array.from(groups.keys()).some(key => key.startsWith('[') && key !== '[]');
const hasProperties = Array.from(groups.keys()).some(key => key.startsWith('.') && key !== '.');
if (hasArrayIndices && !hasProperties) {
// Pure array
const result = [];
for (const [rootSegment, subEntries] of groups) {
if (rootSegment.startsWith('[') && rootSegment !== '[]') {
const index = parseInt(rootSegment.slice(1, -1), 10);
result[index] = unflat(subEntries);
}
else if (rootSegment === '[]') {
// This shouldn't happen in a pure array context
result.push([]);
}
}
return result;
}
else {
// Object (or mixed, treat as object)
const result = {};
for (const [rootSegment, subEntries] of groups) {
if (rootSegment.startsWith('.') && rootSegment !== '.') {
const key = rootSegment.slice(1);
result[key] = unflat(subEntries);
}
else if (rootSegment === '.') {
// Empty object marker - should be handled by early return
}
}
return result;
}
}
function sortKeys(obj) {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(item => sortKeys(item));
}
const result = {};
const keys = Object.keys(obj).sort();
for (const key of keys) {
result[key] = sortKeys(obj[key]);
}
return result;
}
function diff(a, b) {
// Step 1: Sort keys for both objects
const sortedA = sortKeys(a);
const sortedB = sortKeys(b);
// Step 2: Flatten both sorted objects
const flatA = flat(replaceUndefined(sortedA));
const flatB = flat(replaceUndefined(sortedB));
// Step 3: Convert to maps for easier comparison
const mapA = new Map();
const mapB = new Map();
for (const entry of flatA) {
const [path, value] = Object.entries(entry)[0];
mapA.set(path, value);
}
for (const entry of flatB) {
const [path, value] = Object.entries(entry)[0];
mapB.set(path, value);
}
// Step 4: Compare and generate operations with optimizations
const operations = [];
const allPaths = new Set([...mapA.keys(), ...mapB.keys()]);
// Detect transitions to optimize operations
const emptyToFilledOptimizations = new Set();
const filledToEmptyOptimizations = new Set();
for (const path of allPaths) {
// Check if this is an empty object/array marker being removed while properties are added
if (path.endsWith('.') || path.endsWith('[]')) {
const prefix = path.slice(0, -1); // Remove the trailing marker
const hasRemovedEmpty = mapA.has(path) && !mapB.has(path);
const hasAddedEmpty = !mapA.has(path) && mapB.has(path);
const hasAddedProperties = Array.from(mapB.keys()).some(p => p !== path && p.startsWith(prefix) && !mapA.has(p));
const hasRemovedProperties = Array.from(mapA.keys()).some(p => p !== path && p.startsWith(prefix) && !mapB.has(p));
// Empty-to-filled: skip removing empty marker when adding properties
if (hasRemovedEmpty && hasAddedProperties) {
emptyToFilledOptimizations.add(path);
}
// Filled-to-empty: skip adding empty marker when removing all properties
if (hasAddedEmpty && hasRemovedProperties && !hasAddedProperties) {
filledToEmptyOptimizations.add(path);
}
}
}
for (const path of Array.from(allPaths).sort()) {
// Skip empty markers that are being optimized away
if (emptyToFilledOptimizations.has(path) || filledToEmptyOptimizations.has(path)) {
continue;
}
const valueA = mapA.get(path);
const valueB = mapB.get(path);
if (valueA === undefined && valueB !== undefined) {
// Path exists in B but not in A - ADD
operations.push({
type: 'add',
path,
value: restoreUndefined(valueB)
});
}
else if (valueA !== undefined && valueB === undefined) {
// Path exists in A but not in B - REMOVE
operations.push({
type: 'remove',
path
});
}
else if (valueA !== undefined && valueB !== undefined && valueA !== valueB) {
// Path exists in both but with different values - SET
operations.push({
type: 'set',
path,
value: restoreUndefined(valueB)
});
}
// If valueA === valueB, no operation needed
}
return operations;
}
function apply(input, operations) {
// Step 1: Sort keys and flatten the input object
const sorted = sortKeys(input);
const flattened = flat(replaceUndefined(sorted));
// Step 2: Convert to map for easier manipulation
const pathMap = new Map();
for (const entry of flattened) {
const [path, value] = Object.entries(entry)[0];
pathMap.set(path, value);
}
// Step 3: Apply each operation in sequence
for (const operation of operations) {
switch (operation.type) {
case 'add':
pathMap.set(operation.path, replaceUndefined(operation.value));
break;
case 'set':
pathMap.set(operation.path, replaceUndefined(operation.value));
break;
case 'remove':
pathMap.delete(operation.path);
break;
}
}
// Step 4: Preserve empty structures from the original input
// Collect all structure paths from the original input
const originalStructures = new Set();
for (const entry of flattened) {
const [path] = Object.entries(entry)[0];
// If this is already an empty structure marker, add it
if (path.endsWith('.') || path.endsWith('[]')) {
originalStructures.add(path);
}
// For non-empty paths, extract all possible parent structure paths
let currentPath = '';
let i = 0;
while (i < path.length) {
const char = path[i];
if (char === '.') {
// Found an object property separator
if (currentPath !== '') {
originalStructures.add(currentPath + '.');
}
currentPath += char;
}
else if (char === '[') {
// Found an array index start
if (currentPath !== '') {
originalStructures.add(currentPath + '[]');
}
// Skip to the closing bracket
while (i < path.length && path[i] !== ']') {
currentPath += path[i];
i++;
}
if (i < path.length) {
currentPath += path[i]; // Add the closing ]
}
}
else {
currentPath += char;
}
i++;
}
}
// Add empty markers for structures that existed in input but have no properties after operations
for (const structurePath of originalStructures) {
if (structurePath.endsWith('.') || structurePath.endsWith('[]')) {
const prefix = structurePath.slice(0, -1); // Remove the trailing marker
const hasProperties = Array.from(pathMap.keys()).some(p => p !== structurePath && p.startsWith(prefix) && (p.includes('.') || p.includes('[')));
if (!hasProperties && !pathMap.has(structurePath)) {
pathMap.set(structurePath, 0);
}
}
}
// Step 5: Convert back to object format and unflatten
const resultEntries = [];
for (const [path, value] of pathMap.entries()) {
resultEntries.push({ [path]: value });
}
// Step 6: Unflatten and restore undefined values
const unflattened = unflat(resultEntries);
return restoreUndefined(unflattened);
}
//# sourceMappingURL=index.js.map