Release query Tools v0.1.0
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
parseCsv,
|
||||
safeJsonParse,
|
||||
type JsonValue,
|
||||
} from "@add-ideas/toolbox-helpers";
|
||||
import { DOMParser, type Element as XmlElement } from "@xmldom/xmldom";
|
||||
|
||||
export type DataFormat = "json" | "csv" | "ndjson" | "xml";
|
||||
export interface DataDocument {
|
||||
format: DataFormat;
|
||||
root: JsonValue;
|
||||
rows: JsonValue[];
|
||||
fields: string[];
|
||||
}
|
||||
const MAX_CHARS = 2 * 1024 * 1024,
|
||||
MAX_ROWS = 10_000,
|
||||
MAX_FIELDS = 200;
|
||||
|
||||
export function parseData(source: string, format: DataFormat): DataDocument {
|
||||
if (source.length > MAX_CHARS)
|
||||
throw new Error("Input exceeds the 2 MiB limit.");
|
||||
let root: JsonValue;
|
||||
if (format === "json")
|
||||
root = safeJsonParse(source, {
|
||||
maxTextChars: MAX_CHARS,
|
||||
maxDepth: 32,
|
||||
maxNodes: 200_000,
|
||||
});
|
||||
else if (format === "ndjson") root = parseNdjson(source);
|
||||
else if (format === "csv") root = parseCsvData(source);
|
||||
else root = parseXml(source);
|
||||
const rows = normalizeRows(root);
|
||||
if (rows.length > MAX_ROWS)
|
||||
throw new Error("Dataset exceeds 10,000 query rows.");
|
||||
const fields = [
|
||||
...new Set(
|
||||
rows.flatMap((row) => (isObject(row) ? Object.keys(row) : ["value"])),
|
||||
),
|
||||
];
|
||||
if (fields.length > MAX_FIELDS)
|
||||
throw new Error("Dataset exposes more than 200 top-level fields.");
|
||||
return { format, root, rows, fields: fields.sort() };
|
||||
}
|
||||
|
||||
function parseCsvData(source: string): JsonValue {
|
||||
const table = parseCsv(source, {
|
||||
maxRows: MAX_ROWS + 1,
|
||||
maxColumns: MAX_FIELDS,
|
||||
maxFieldChars: 100_000,
|
||||
});
|
||||
if (!table.length) return [];
|
||||
const headers = table[0]!.map(
|
||||
(value, index) => value.trim() || `column_${index + 1}`,
|
||||
);
|
||||
if (new Set(headers).size !== headers.length)
|
||||
throw new Error("CSV headers must be unique.");
|
||||
return table
|
||||
.slice(1)
|
||||
.filter((row) => row.some(Boolean))
|
||||
.map((row) =>
|
||||
Object.fromEntries(
|
||||
headers.map((header, index) => [header, inferScalar(row[index] ?? "")]),
|
||||
),
|
||||
) as JsonValue;
|
||||
}
|
||||
|
||||
function parseNdjson(source: string): JsonValue {
|
||||
const lines = source.split(/\r?\n/u).filter((line) => line.trim());
|
||||
if (lines.length > MAX_ROWS)
|
||||
throw new Error("NDJSON exceeds 10,000 records.");
|
||||
return lines.map((line, index) => {
|
||||
try {
|
||||
return safeJsonParse(line, {
|
||||
maxTextChars: 200_000,
|
||||
maxDepth: 32,
|
||||
maxNodes: 20_000,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`NDJSON line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseXml(source: string): JsonValue {
|
||||
if (/<!\s*(?:DOCTYPE|ENTITY)\b/iu.test(source))
|
||||
throw new Error("XML DTD and entity declarations are not accepted.");
|
||||
const errors: string[] = [];
|
||||
const document = new DOMParser({
|
||||
onError(level, message) {
|
||||
if (level !== "warning") errors.push(message);
|
||||
},
|
||||
}).parseFromString(source, "application/xml");
|
||||
if (
|
||||
errors.length ||
|
||||
document.getElementsByTagName("parsererror").length ||
|
||||
!document.documentElement
|
||||
)
|
||||
throw new Error(`Malformed XML${errors[0] ? `: ${errors[0]}` : "."}`);
|
||||
let nodes = 0;
|
||||
const convert = (element: XmlElement, depth: number): JsonValue => {
|
||||
if (++nodes > 100_000 || depth > 32)
|
||||
throw new Error("XML structure exceeds node/depth limits.");
|
||||
const output: Record<string, JsonValue> = {};
|
||||
for (let index = 0; index < element.attributes.length; index += 1) {
|
||||
const attribute = element.attributes.item(index);
|
||||
if (attribute) output[`@${attribute.name}`] = attribute.value;
|
||||
}
|
||||
const children = [
|
||||
...Array.from({ length: element.childNodes.length }, (_, index) =>
|
||||
element.childNodes.item(index),
|
||||
),
|
||||
].filter((node): node is XmlElement =>
|
||||
Boolean(node && node.nodeType === 1),
|
||||
);
|
||||
for (const child of children) {
|
||||
const value = convert(child, depth + 1),
|
||||
key = child.nodeName;
|
||||
const existing = output[key];
|
||||
output[key] =
|
||||
existing === undefined
|
||||
? value
|
||||
: Array.isArray(existing)
|
||||
? [...existing, value]
|
||||
: [existing, value];
|
||||
}
|
||||
const text = [
|
||||
...Array.from({ length: element.childNodes.length }, (_, index) =>
|
||||
element.childNodes.item(index),
|
||||
),
|
||||
]
|
||||
.filter((node) => node?.nodeType === 3 || node?.nodeType === 4)
|
||||
.map((node) => node?.nodeValue ?? "")
|
||||
.join("")
|
||||
.trim();
|
||||
if (!Object.keys(output).length) return inferScalar(text);
|
||||
if (text) output["#text"] = text;
|
||||
return output;
|
||||
};
|
||||
return {
|
||||
[document.documentElement.nodeName]: convert(document.documentElement, 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRows(value: JsonValue): JsonValue[] {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (isObject(value)) {
|
||||
const arrays = Object.values(value).filter(Array.isArray);
|
||||
if (arrays.length === 1) return arrays[0] as JsonValue[];
|
||||
}
|
||||
return [value];
|
||||
}
|
||||
export function isObject(value: JsonValue): value is Record<string, JsonValue> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
function inferScalar(value: string): JsonValue {
|
||||
const trimmed = value.trim();
|
||||
if (/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu.test(trimmed)) {
|
||||
const number = Number(trimmed);
|
||||
if (Number.isFinite(number)) return number;
|
||||
}
|
||||
if (/^(?:true|false)$/iu.test(trimmed))
|
||||
return trimmed.toLowerCase() === "true";
|
||||
if (/^null$/iu.test(trimmed)) return null;
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user