feat(redis): edit JSON values as source with compact saves
This commit is contained in:
1 parent
deb4cbfdfe
commit
ceadf73e4b
11 files changed
+1181
-210
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers, safeJsonFormat } from "../safeJsonFormat";
|
||||
import { formatJsonSource, isLosslessJsonNumber, parseJsonPreservingLargeNumbers, safeJsonFormat } from "../safeJsonFormat";
|
||||
|
||||
describe("safeJsonFormat", () => {
|
||||
it("preserves large integers exceeding MAX_SAFE_INTEGER", () => {
|
||||
@@ -86,3 +86,63 @@ describe("safeJsonFormat", () => {
|
||||
expect(safeJsonFormat(input, 2)).toContain("1e999");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatJsonSource", () => {
|
||||
it("minifies by removing only insignificant whitespace", () => {
|
||||
const input = `{
|
||||
"name": "Ada",
|
||||
"items": [1, 2, 3]
|
||||
}`;
|
||||
expect(formatJsonSource(input)).toBe('{"name":"Ada","items":[1,2,3]}');
|
||||
});
|
||||
|
||||
it("pretty-prints with the requested indent while keeping source tokens", () => {
|
||||
expect(formatJsonSource('{"role":"reader","role":"writer"}', 2)).toBe(`{
|
||||
"role": "reader",
|
||||
"role": "writer"
|
||||
}`);
|
||||
});
|
||||
|
||||
it("preserves duplicate object members when minifying", () => {
|
||||
const pretty = `{
|
||||
"role": "reader",
|
||||
"role": "writer"
|
||||
}`;
|
||||
expect(formatJsonSource(pretty)).toBe('{"role":"reader","role":"writer"}');
|
||||
});
|
||||
|
||||
it("preserves key order when duplicate members are mixed with other keys", () => {
|
||||
const input = '{"a":1,"role":"reader","b":2,"role":"writer","c":3}';
|
||||
expect(formatJsonSource(input)).toBe(input);
|
||||
expect(formatJsonSource(input, 2)).toBe(`{
|
||||
"a": 1,
|
||||
"role": "reader",
|
||||
"b": 2,
|
||||
"role": "writer",
|
||||
"c": 3
|
||||
}`);
|
||||
});
|
||||
|
||||
it("preserves large and high-precision number literals", () => {
|
||||
const compact = '{"id":87712409002717401,"fraction":0.123456789012345678901234,"scientific":1.234567890123456789e20}';
|
||||
const pretty = `{
|
||||
"id": 87712409002717401,
|
||||
"fraction": 0.123456789012345678901234,
|
||||
"scientific": 1.234567890123456789e20
|
||||
}`;
|
||||
|
||||
expect(formatJsonSource(pretty)).toBe(compact);
|
||||
expect(formatJsonSource(compact, 2)).toBe(pretty);
|
||||
});
|
||||
|
||||
it("preserves string escape sequences and does not rewrite them", () => {
|
||||
const input = '{"path":"C:\\\\Users\\\\path","quote":"say \\"hi\\"","unicode":"\\u4e2d"}';
|
||||
expect(formatJsonSource(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => formatJsonSource('{"name": }')).toThrow(SyntaxError);
|
||||
expect(() => formatJsonSource('{"a":1,}')).toThrow(SyntaxError);
|
||||
expect(() => formatJsonSource('{"a":1} trailing')).toThrow(SyntaxError);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,9 @@ export function parseJsonPreservingLargeNumbers(text: string): unknown {
|
||||
* Parse and re-stringify JSON while preserving numeric literals whose integer
|
||||
* parts exceed Number.MAX_SAFE_INTEGER (2^53 - 1), plus decimal and exponent
|
||||
* forms that JavaScript may round or turn into Infinity.
|
||||
*
|
||||
* Note: this rebuilds a JS object, so duplicate object members are collapsed.
|
||||
* Prefer {@link formatJsonSource} when the source text itself must stay lossless.
|
||||
*/
|
||||
export function safeJsonFormat(text: string, indent?: number): string {
|
||||
const protectedJson = protectLargeJsonNumbers(text);
|
||||
@@ -43,6 +46,20 @@ export function safeJsonFormat(text: string, indent?: number): string {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JSON and re-emit source tokens while only changing insignificant
|
||||
* whitespace. Unlike {@link safeJsonFormat}, this keeps duplicate object
|
||||
* members, key order, string escapes, and number spellings intact.
|
||||
*/
|
||||
export function formatJsonSource(text: string, indent?: number): string {
|
||||
const scanner = new JsonSourceScanner(text);
|
||||
const writer = new JsonSourceWriter(indent);
|
||||
writeJsonValue(scanner, writer, 0);
|
||||
scanner.skipWhitespace();
|
||||
if (!scanner.eof()) throw new SyntaxError(`Unexpected trailing content in JSON at position ${scanner.position}`);
|
||||
return writer.toString();
|
||||
}
|
||||
|
||||
function protectLargeJsonNumbers(text: string): ProtectedJsonNumbers {
|
||||
let placeholderPrefix = "__DBX_LOSSLESS_NUMBER_";
|
||||
while (text.includes(placeholderPrefix)) placeholderPrefix += "_";
|
||||
@@ -120,3 +137,267 @@ function restoreLosslessNumbers(value: unknown, numbers: Map<string, string>): u
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
class JsonSourceScanner {
|
||||
readonly text: string;
|
||||
position = 0;
|
||||
|
||||
constructor(text: string) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
eof(): boolean {
|
||||
return this.position >= this.text.length;
|
||||
}
|
||||
|
||||
peek(): string | undefined {
|
||||
return this.text[this.position];
|
||||
}
|
||||
|
||||
skipWhitespace() {
|
||||
while (this.position < this.text.length) {
|
||||
const character = this.text[this.position];
|
||||
if (character === " " || character === "\t" || character === "\n" || character === "\r") {
|
||||
this.position += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(character: string) {
|
||||
this.skipWhitespace();
|
||||
if (this.text[this.position] !== character) {
|
||||
throw new SyntaxError(`Expected '${character}' at position ${this.position}`);
|
||||
}
|
||||
this.position += 1;
|
||||
}
|
||||
|
||||
readStringToken(): string {
|
||||
this.skipWhitespace();
|
||||
if (this.text[this.position] !== '"') {
|
||||
throw new SyntaxError(`Expected string at position ${this.position}`);
|
||||
}
|
||||
|
||||
const start = this.position;
|
||||
this.position += 1;
|
||||
let escaped = false;
|
||||
while (this.position < this.text.length) {
|
||||
const character = this.text[this.position];
|
||||
if (escaped) {
|
||||
if (character === "u") {
|
||||
const hex = this.text.slice(this.position + 1, this.position + 5);
|
||||
if (!/^[0-9a-fA-F]{4}$/.test(hex)) {
|
||||
throw new SyntaxError(`Invalid unicode escape at position ${this.position}`);
|
||||
}
|
||||
this.position += 5;
|
||||
} else if (!'"\\/bfnrt'.includes(character)) {
|
||||
throw new SyntaxError(`Invalid escape sequence at position ${this.position}`);
|
||||
} else {
|
||||
this.position += 1;
|
||||
}
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
this.position += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') {
|
||||
this.position += 1;
|
||||
return this.text.slice(start, this.position);
|
||||
}
|
||||
// JSON strings cannot contain raw control characters.
|
||||
if (character.charCodeAt(0) < 0x20) {
|
||||
throw new SyntaxError(`Invalid control character in string at position ${this.position}`);
|
||||
}
|
||||
this.position += 1;
|
||||
}
|
||||
throw new SyntaxError(`Unterminated string at position ${start}`);
|
||||
}
|
||||
|
||||
readNumberToken(): string {
|
||||
this.skipWhitespace();
|
||||
const start = this.position;
|
||||
if (this.text[this.position] === "-") this.position += 1;
|
||||
|
||||
if (this.text[this.position] === "0") {
|
||||
this.position += 1;
|
||||
} else if (this.isDigit(this.text[this.position])) {
|
||||
while (this.isDigit(this.text[this.position])) this.position += 1;
|
||||
} else {
|
||||
throw new SyntaxError(`Invalid number at position ${start}`);
|
||||
}
|
||||
|
||||
if (this.text[this.position] === ".") {
|
||||
this.position += 1;
|
||||
if (!this.isDigit(this.text[this.position])) {
|
||||
throw new SyntaxError(`Invalid number at position ${start}`);
|
||||
}
|
||||
while (this.isDigit(this.text[this.position])) this.position += 1;
|
||||
}
|
||||
|
||||
if (this.text[this.position] === "e" || this.text[this.position] === "E") {
|
||||
this.position += 1;
|
||||
if (this.text[this.position] === "+" || this.text[this.position] === "-") this.position += 1;
|
||||
if (!this.isDigit(this.text[this.position])) {
|
||||
throw new SyntaxError(`Invalid number at position ${start}`);
|
||||
}
|
||||
while (this.isDigit(this.text[this.position])) this.position += 1;
|
||||
}
|
||||
|
||||
if (this.position === start || (this.position === start + 1 && this.text[start] === "-")) {
|
||||
throw new SyntaxError(`Invalid number at position ${start}`);
|
||||
}
|
||||
return this.text.slice(start, this.position);
|
||||
}
|
||||
|
||||
readKeywordToken(keyword: "true" | "false" | "null"): string {
|
||||
this.skipWhitespace();
|
||||
if (this.text.slice(this.position, this.position + keyword.length) !== keyword) {
|
||||
throw new SyntaxError(`Expected '${keyword}' at position ${this.position}`);
|
||||
}
|
||||
this.position += keyword.length;
|
||||
return keyword;
|
||||
}
|
||||
|
||||
private isDigit(character: string | undefined): boolean {
|
||||
return character !== undefined && character >= "0" && character <= "9";
|
||||
}
|
||||
}
|
||||
|
||||
class JsonSourceWriter {
|
||||
private readonly chunks: string[] = [];
|
||||
private readonly indentSize: number | undefined;
|
||||
private readonly pretty: boolean;
|
||||
|
||||
constructor(indent?: number) {
|
||||
this.indentSize = indent !== undefined && indent > 0 ? indent : undefined;
|
||||
this.pretty = this.indentSize !== undefined;
|
||||
}
|
||||
|
||||
write(text: string) {
|
||||
this.chunks.push(text);
|
||||
}
|
||||
|
||||
newline(depth: number) {
|
||||
if (!this.pretty || this.indentSize === undefined) return;
|
||||
this.chunks.push("\n" + " ".repeat(this.indentSize * depth));
|
||||
}
|
||||
|
||||
space() {
|
||||
if (this.pretty) this.chunks.push(" ");
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.chunks.join("");
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonValue(scanner: JsonSourceScanner, writer: JsonSourceWriter, depth: number) {
|
||||
scanner.skipWhitespace();
|
||||
const character = scanner.peek();
|
||||
if (character === undefined) throw new SyntaxError("Unexpected end of JSON input");
|
||||
|
||||
if (character === "{") {
|
||||
writeJsonObject(scanner, writer, depth);
|
||||
return;
|
||||
}
|
||||
if (character === "[") {
|
||||
writeJsonArray(scanner, writer, depth);
|
||||
return;
|
||||
}
|
||||
if (character === '"') {
|
||||
writer.write(scanner.readStringToken());
|
||||
return;
|
||||
}
|
||||
if (character === "-" || (character >= "0" && character <= "9")) {
|
||||
writer.write(scanner.readNumberToken());
|
||||
return;
|
||||
}
|
||||
if (character === "t") {
|
||||
writer.write(scanner.readKeywordToken("true"));
|
||||
return;
|
||||
}
|
||||
if (character === "f") {
|
||||
writer.write(scanner.readKeywordToken("false"));
|
||||
return;
|
||||
}
|
||||
if (character === "n") {
|
||||
writer.write(scanner.readKeywordToken("null"));
|
||||
return;
|
||||
}
|
||||
|
||||
throw new SyntaxError(`Unexpected token '${character}' at position ${scanner.position}`);
|
||||
}
|
||||
|
||||
function writeJsonObject(scanner: JsonSourceScanner, writer: JsonSourceWriter, depth: number) {
|
||||
scanner.expect("{");
|
||||
writer.write("{");
|
||||
scanner.skipWhitespace();
|
||||
|
||||
if (scanner.peek() === "}") {
|
||||
scanner.position += 1;
|
||||
writer.write("}");
|
||||
return;
|
||||
}
|
||||
|
||||
let first = true;
|
||||
while (true) {
|
||||
if (!first) {
|
||||
scanner.expect(",");
|
||||
writer.write(",");
|
||||
}
|
||||
first = false;
|
||||
writer.newline(depth + 1);
|
||||
writer.write(scanner.readStringToken());
|
||||
scanner.expect(":");
|
||||
writer.write(":");
|
||||
writer.space();
|
||||
writeJsonValue(scanner, writer, depth + 1);
|
||||
scanner.skipWhitespace();
|
||||
if (scanner.peek() === "}") {
|
||||
scanner.position += 1;
|
||||
writer.newline(depth);
|
||||
writer.write("}");
|
||||
return;
|
||||
}
|
||||
if (scanner.peek() !== ",") {
|
||||
throw new SyntaxError(`Expected ',' or '}' in object at position ${scanner.position}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonArray(scanner: JsonSourceScanner, writer: JsonSourceWriter, depth: number) {
|
||||
scanner.expect("[");
|
||||
writer.write("[");
|
||||
scanner.skipWhitespace();
|
||||
|
||||
if (scanner.peek() === "]") {
|
||||
scanner.position += 1;
|
||||
writer.write("]");
|
||||
return;
|
||||
}
|
||||
|
||||
let first = true;
|
||||
while (true) {
|
||||
if (!first) {
|
||||
scanner.expect(",");
|
||||
writer.write(",");
|
||||
}
|
||||
first = false;
|
||||
writer.newline(depth + 1);
|
||||
writeJsonValue(scanner, writer, depth + 1);
|
||||
scanner.skipWhitespace();
|
||||
if (scanner.peek() === "]") {
|
||||
scanner.position += 1;
|
||||
writer.newline(depth);
|
||||
writer.write("]");
|
||||
return;
|
||||
}
|
||||
if (scanner.peek() !== ",") {
|
||||
throw new SyntaxError(`Expected ',' or ']' in array at position ${scanner.position}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user