// JSON record-style serialisation for `DataFrame`: the "records" /
// "row-orientation" wire shape — one top-level array of flat objects
// (`[{...}, ...]`), the canonical layout most JSON tooling produces by
// default. This file is the format's own face:
// the read options, the string / file entry points, and the `@json`
// payload adapter. Everything the JSON family shares — the
// records → frame core (also driven by NDJSON), the cell mapping (also
// feeding chart export), and the round-trip caveats — is factored into
// the shared core the whole family drives; the line-delimited sibling
// (NDJSON / JSON Lines) is a format face of its own.
///|
/// Options that control how a JSON records payload is parsed into a
/// `DataFrame`.
///
/// - `infer_schema_rows` — number of leading records inspected when
/// guessing each column's dtype. `0` (or any value `<= 0`) lifts the
/// cap and scans every record. Records past a finite window are still
/// parsed under the chosen
/// dtype; one that doesn't fit is handled per `on_parse_error`.
/// - `on_parse_error` — what to do when a non-null cell past the
/// inference window fails to parse under its column's locked-in dtype:
/// `Raise` (default) fails with `ParseError(Cell(...))`; `Null`
/// downgrades the offending cell to a null and continues.
/// - `reject_duplicate_keys` — whether an object repeating a key is an
/// error. The default `false` keeps the common JSON reading: a
/// repeated key survives only as its **last** value, the earlier
/// ones silently dropped. `true` rejects the read instead —
/// `ParseError(Message(...))` naming the key and the line / column it
/// repeated at — so a payload that would silently lose cells fails
/// loudly. The check rides the parser itself (which reports whichever
/// trouble it meets first: a repeat ahead of a syntax error surfaces as
/// the repeat, and vice versa).
/// - `allow_nonfinite_floats` — when `true` (default), a number whose
/// magnitude overflows `Double` (e.g. `1e999`) infers the column as
/// `Float` holding `±Infinity`. When `false`, such a number is not
/// `Float` evidence — the column stays `String`, the CSV reader's
/// `allow_nonfinite_floats` rule, so a silent retype to `Float(∞)`
/// cannot happen either way a file is read.
/// - `dtypes` — per-column dtype overrides that pin a column's type
/// instead of inferring it, as a name → `DataType` map. Only the four
/// dtypes a reader can parse (`Int` / `Float` / `Bool` / `String`) can
/// be pinned — anything else is refused with `InvalidOperation` at read
/// time, as is a name the input does not carry (a pin is an assertion
/// about the input; a misspelt one fails rather than silently does
/// nothing). Columns not named keep their inferred dtype. A pinned
/// column skips inference entirely, and a cell that does not parse
/// under the pin — a JSON `String` in a column pinned `Int`, say, since
/// JSON has strong types and no `"1" → 1` coercion — is handled per
/// `on_parse_error`. The map is copied, so mutating it afterwards
/// cannot alter the options; read it back through the `dtypes()`
/// accessor.
///
/// The NDJSON reader shares this type: the two formats differ in framing,
/// not in what there is to configure.
pub struct JsonReadOptions {
infer_schema_rows : Int32
on_parse_error : OnParseError
reject_duplicate_keys : Bool
allow_nonfinite_floats : Bool
priv dtypes : Map[String, @types.DataType]
}
///|
/// The per-column dtype overrides. A fresh `Map` is returned, so a caller
/// cannot reach into the options and change what the reader pins — the
/// same reason the constructor copies its input.
pub fn JsonReadOptions::dtypes(
self : JsonReadOptions,
) -> Map[String, @types.DataType] {
copy_dtypes(self.dtypes)
}
///|
/// Build read options. Every field has a default: scan the first 100 records
/// for inference, fail the read on a cell that doesn't fit its inferred
/// dtype, let a repeated object key keep its last value (the common JSON
/// reading), and accept overflow-to-`Infinity` numbers as `Float` cells. `JsonReadOptions::JsonReadOptions()` is the all-defaults reader; name only what
/// differs, as in `JsonReadOptions::JsonReadOptions(on_parse_error=Null)`.
/// `dtypes` is copied, so mutating the map afterwards cannot alter the
/// options.
pub fn JsonReadOptions::JsonReadOptions(
infer_schema_rows~ : Int32 = DEFAULT_INFER_SCHEMA_ROWS,
on_parse_error~ : OnParseError = Raise,
reject_duplicate_keys~ : Bool = false,
allow_nonfinite_floats~ : Bool = true,
dtypes~ : Map[String, @types.DataType] = Map([]),
) -> JsonReadOptions {
{
infer_schema_rows,
on_parse_error,
reject_duplicate_keys,
allow_nonfinite_floats,
dtypes: copy_dtypes(dtypes),
}
}
///|
/// Parse a JSON records string (`[ {...}, ... ]`) into a `DataFrame`.
///
/// `@json.parse` produces the `Json` AST; a top-level value that is not
/// an array surfaces as `ParseError`. The array's elements are then
/// handed to `frame_from_json_records`, which validates each is an
/// object, collects headers in first-seen order across all records,
/// infers one dtype per column, and materialises the frame. An empty
/// array — like empty or whitespace-only input — yields a 0×0 frame.
///
/// Raises:
/// - `ParseError(Message(...))` — malformed JSON, a top-level value that is
/// not an array, or a record that is not an object.
/// - `InvalidOperation` — `options.dtypes` names a column the input does
/// not carry, or pins a dtype that has no read parser (anything but
/// `Int` / `Float` / `Bool` / `String`).
/// - `ParseError(Cell(...))` — a non-null cell does not fit the column's
/// inferred dtype, unless `options.on_parse_error` is `Null`, which
/// downgrades that cell to a null instead.
/// - Whatever `DataFrame::from_parts` propagates (duplicate-header is
/// pre-empted upstream; length-mismatch cannot fire because every
/// column is built off `records.length()`).
pub fn parse_json_str(
content : String,
options~ : JsonReadOptions = JsonReadOptions::JsonReadOptions(),
) -> @frame.DataFrame raise @types.DataError {
parse_json_str_projected(content, options, None, None)
}
///|
/// Shared core behind `parse_json_str` and the JSON scan driver's read: the
/// full array-parse / records pipeline `parse_json_str` runs, with the two
/// knobs the lazy `scan_json` source pushes in — `projection` builds only
/// the named columns (in first-seen header order), and `pruner` keeps only
/// the rows its callback selects, the same pair the CSV / NDJSON projected
/// cores honour. The top-level array must still be parsed whole to find its
/// records, so what the knobs prune is the *assembly* — record validation
/// and header collection run before the filter, and a projected / pruned
/// read fails on malformed input exactly as a full one does; only a parse
/// error confined to a dropped column or row goes unraised, because those
/// cells are never built.
fn parse_json_str_projected(
content : String,
options : JsonReadOptions,
projection : Array[String]?,
pruner : RowPruner?,
) -> @frame.DataFrame raise @types.DataError {
// Strip a leading UTF-8 BOM (shared with the CSV / NDJSON readers); `@json.parse`
// rejects a stray `U+FEFF` at column 0, so a file saved by Excel would otherwise
// fail to read. Done first, so a BOM-only file still short-circuits as empty.
let content = strip_utf8_bom(content)
// Treat a blank payload (empty or whitespace-only) as "no payload" and
// short-circuit to a 0×0 frame, so callers can stream JSON without
// special-casing it — and so a whitespace-only file matches the NDJSON
// reader (which skips blank lines) rather than raising on an absent
// top-level value. The dtype pins are still asserted: no payload means
// no columns, so every pinned name is a false assertion — the same
// check an empty array takes below, or a misspelt pin rides the blank
// file as a silent 0×0 success.
if content.is_blank() {
check_override_columns(options.dtypes, [])
return @frame.DataFrame::DataFrame([])
}
// The strict duplicate-key reading rides the parser itself — it reports
// the repeat with a line / column, and off by default (the common JSON
// last-wins reading) it costs nothing.
let json = @json.parse(
content.view(),
reject_duplicate_keys=options.reject_duplicate_keys,
) catch {
// The parser's repeat diagnostic, re-homed in this module's error type
// with the key named and the position it repeated at — the whole
// document parses in one call, so the parser's line is the file's own
// (see `duplicate_key_detail` for the per-line reader's spelling).
DuplicateKey(pos, key) =>
raise @types.DataError::ParseError(
@types.ParseErrorDetail::Message(
duplicate_key_detail(key, pos.line, pos.column, single_line=false),
),
)
// Re-raise every other parse failure as this module's `ParseError`, so
// the parser's main pipeline sees one error type and stays in
// straight-line `match` form; the concrete `ParseError` is rendered
// through its `Show` impl so the caller sees the same diagnostic the
// standard library would.
err =>
raise @types.DataError::ParseError(
@types.ParseErrorDetail::Message("invalid JSON: \{err}"),
)
}
let records = match json {
Array(arr) => arr
_ =>
raise @types.DataError::ParseError(
@types.ParseErrorDetail::Message(
"expected a JSON array of objects at the top level",
),
)
}
frame_from_json_records(
records,
options.infer_schema_rows,
options.on_parse_error,
projection,
// JSON-array records have no source line of their own; build-stage errors
// are numbered by record (1-based array index).
None,
pruner?,
allow_nonfinite=options.allow_nonfinite_floats,
dtypes=options.dtypes,
)
}
///|
/// Read a JSON records file. `options` defaults to `JsonReadOptions::JsonReadOptions()`, so
/// `read_json(path)` is the default-options read. Filesystem errors surface
/// as `raise IoError(message)`.
pub fn read_json(
path : String,
options~ : JsonReadOptions = JsonReadOptions::JsonReadOptions(),
) -> @frame.DataFrame raise @types.DataError {
let content = read_text(path)
parse_json_str(content, options~)
}
///|
/// Render a `DataFrame` as a JSON records string `[ {...}, ... ]`.
///
/// Per cell:
/// - `Null` → JSON `null`
/// - `Int` → JSON number (no fractional part)
/// - `Float`→ JSON number for finite values. `NaN` / `±Infinity` have
/// no JSON literal, so they are emitted as `null` to keep the output
/// valid JSON — a round-trip reads a non-finite cell back as a null.
/// - `Bool` → JSON `true` / `false`
/// - `String` → JSON string (escaping `"`, `\`, control chars via the
/// builtin stringifier — we deliberately do **not** roll our own
/// escape so any future Unicode spec tweaks land transparently)
/// - `Date` / `Timestamp` / `Decimal` → JSON string holding the cell's
/// value-form rendering (ISO calendar text / ISO instant / fixed-point
/// decimal); the readers never infer these dtypes, so a round-trip
/// reads the column back as `String` / `Float` respectively.
///
/// Keys appear in the column declaration order, preserved by the
/// builtin `Map` (linked-hash-map) used for each record object.
pub fn format_json(df : @frame.DataFrame) -> String {
Json::array(df_to_json_records(df)).stringify()
}
///|
/// Write a `DataFrame` to a JSON records file. Filesystem errors
/// surface as `raise IoError(message)`. A string cell (or column name)
/// holding an unpaired UTF-16 surrogate is refused with `raise
/// InvalidOperation` — the UTF-8 file encoding would swallow the
/// following code unit and corrupt the JSON framing.
pub fn write_json(
path : String,
df : @frame.DataFrame,
) -> Unit raise @types.DataError {
let content = format_json(df)
write_text(path, content)
}
// ── internals ──────────────────────────────────────────────────────────