Skip to content

13. Reading and writing data

The io module

This chapter covers the io module, which ships in the next release. It is part of the prelude, so a program needs no import to use it.

Every program so far read a vault, or values written into the program itself. This chapter reads files and standard input, and writes files and standard output, without leaving the language. The pipeline you write stays the same whatever the data's location.

Endpoints

An endpoint says where bytes are. Constructing one performs no I/O: it opens, stats and resolves nothing.

file("examples/io/sightings.jsonl")
file("examples/io/sightings.jsonl")

Four endpoints are bound by the host, as vault is, and need no configuration: stdin, stdout, stderr and file(path). A relative path resolves against the directory of the program file or document that names it, and against the working directory for an expression.

What an endpoint can do is its type. Stdin is Readable, Stdout and Stderr are Writable, and File is both, and Replayable: it gives the same bytes on every read within one evaluation. Reading from an endpoint that cannot be read is refused before the program runs:

source(stdout, Json)
type error: `source` needs Readable, not Stdout
 --> <expression>:1:1
  |
1 | source(stdout, Json)
  | ^^^^^^^^^^^^^^^^^^^^

Reading with source

source(endpoint, Format) reads every document an endpoint holds, in a format. examples/io/sightings.jsonl holds four JSON documents, one per line:

{"species": "barn owl", "site": "meadow", "seen": "dusk"}
{"species": "wren", "site": "hedge", "seen": "morning"}
{"species": "barn owl", "site": "barn", "seen": "night"}
{"species": "kingfisher", "site": "river", "seen": "morning"}

A source is an ordinary value, and a pipeline starts from it the way it starts from cards:

source(file("examples/io/sightings.jsonl"), Json)
| filter(s => s.seen == "morning")
| map(s => s.species)
["wren", "kingfisher"]

A literal path whose extension names a format — .json, .jsonl, .yaml, .csv, .txt — implies it, so the format may be left out:

source(file("examples/io/sightings.jsonl"))
| group(by = s => s.species)
| map(g => {species: g.key, sightings: g.items | size})
{{sightings: 2, species: "barn owl"}, {sightings: 1, species: "wren"}, {sightings: 1, species: "kingfisher"}}

Each document is Data, the type of a dynamically structured value from chapter 2. A CSV row is a record of strings, keyed by the header.

Reading as a declared record

A binding whose declared type names an element decodes each document as that element. A tree is read as a declared record by its shape: every field at its type. A plain record, type Sighting {…}, is closed, so a document with an entry it does not declare does not decode; a record narrowing Data, type Sighting : Data {…}, is an open view that tolerates such entries and keeps them. The same reading is written as a step, source(file(p), Json) | Sighting.

type Sighting {
    species : String
    site    : String
    seen    : String
}

sightings : Seq<Sighting> = source(file("examples/io/sightings.jsonl"))
sightings | filter(s => s.seen == "morning") | map(s => s.species)
["wren", "kingfisher"]

A function's declared result is an expected type in the same way. A CSV cell is text, so it is read as the Int, Float or Bool its field declares; a JSON leaf keeps the type it was written with. A document that does not decode stops the evaluation with an error naming the document and the field:

counts.csv: document 2 does not decode as Tally: field `count`: "many" is not an Int

Finite or live: the type decides

A file is replayable, so reading it gives a Seq: it ends, and every step in the book applies. Standard input arrives once, as it is written, so reading it gives a Stream. hql check prints each type:

source(file("examples/io/sightings.jsonl"))
Seq<Data>
source(stdin, Json)
Stream<Data>

That difference is the one chapter 9 drew. map, filter and take run over standard input one document at a time, as it arrives, and a step that needs the whole input is refused until the stream is bounded or batched. The same program works on a file and on a pipe, and the checker says which steps each one allows.

tap(endpoint, Format) reads one document at a time from any readable endpoint, a file included, holding only the current one in memory. Over a file's end, file(p).end, it follows what is appended, as tail -F does:

tap(file("app.log").end, Lines) | take(1)

waits for the next line written to app.log, and returns it.

Writing with target and sink

Writing has two forms, chosen by the shape of the value.

target(endpoint, Format) writes one completed value as one document. Over a file it replaces the file, atomically:

source(file("examples/io/sightings.jsonl"))
| map(s => {species: s.species, site: s.site})
| target(file("sites.csv"))
site,species
meadow,barn owl
hedge,wren
barn,barn owl
river,kingfisher

sink(endpoint, Format) writes one document per value, as each arrives. It is how a stream is written, because a stream may never complete:

source(stdin, Json)
| filter(s => s.seen == "morning")
| map(s => s.species)
| sink(stdout, Text)
"wren"
"kingfisher"

Asking target to write a stream as one document is refused, with the two ways out:

source(stdin, Json) | target(file("all.json"))
type error: `target` writes one completed value, and Stream<Data> may not end; write it piece by piece with `sink`, or bound it with `take(n)` first
 --> <expression>:1:23
  |
1 | source(stdin, Json) | target(file("all.json"))
  |                       ^^^^^^^^^^^^^^^^^^^^^^^^

Writing file(p) replaces it; writing file(p).end appends. One evaluation may replace a file once or append to it any number of times, never both.

Both steps return Unit, and a Unit result prints nothing. What each one accepts, by the shape of the values:

Seq<T> Stream<T>
source from a replayable endpoint from a live endpoint
target one document refused: collect(n), or take(n)
sink one document per element one document per value

Batches are streams of sequences, so collect(100) | sink(stdout, Json) writes one JSON array per hundred values. The format describes the encoding, and the type describes the shape: JSON lines is not a separate format, but Json written by sink.

Branches that write

A tee branch from chapter 5 may end in a sink. The branch writes its documents, and the main pipeline goes on with every value:

source(stdin, Json)
| tee archived =
    filter(e => e.level == "error")
    | sink(file("errors.jsonl").end, Json)
| map(e => e.msg)
| sink(stdout, Text)

One program reads a log as it is written, appends its errors to a file, and prints every message. There is no orchestration around the query: the query is the dataflow.

On the command line

With these steps hql is a structured-data tool for the shell. A first argument that names no command is a program, so hql '<program>' means hql eval '<program>':

$ cat examples/io/sightings.jsonl | hql 'source(stdin, Json) | filter(s => s.seen == "morning") | map(s => s.species)'
"wren"
"kingfisher"

--to json pretty-prints each streamed document, as jq . does, and --compact gives one line each:

$ head -2 examples/io/sightings.jsonl | hql 'source(stdin, Json)' --to json --compact
{"seen":"dusk","site":"meadow","species":"barn owl"}
{"seen":"morning","site":"hedge","species":"wren"}

--from names the format of standard input once, on the command line, so the program reads stdin without naming one; a format the program names wins:

$ cat examples/io/sightings.jsonl | hql --from json 'source(stdin) | map(s => s.site)'
"meadow"
"hedge"
"barn"
"river"

Converting between formats is a source and a --to:

$ hql 'source(file("examples/io/sightings.jsonl")) | map(s => s.site)' --to yaml
- meadow
- hedge
- barn
- river

Input and output stay in the program; --to only chooses how the CLI writes the final result.

Effects and documents

Constructing an endpoint is pure. source, tap, target and sink are effectful: they reach outside the program, and the host decides which of them it allows.

hql render is one such host. A document reads only files under its own directory, reads no standard input, and writes nothing outside itself. A cell that tries is refused when the document is checked, before any cell runs:

type error: a document writes nothing outside itself, so `target` and `sink` are refused here
 --> report.hmd:8:7
  |
8 | [1] | target(file("out.json"))
  |       ^^^^^^^^^^^^^^^^^^^^^^^^

The same program runs under different capability policies without changing its model: a terminal may write files, and a document being rendered may not.

Configured sources

A program that names a file path is tied to one machine's files. A vault can instead declare its sources in hql.toml, each under an id, and a program names only the id:

[sources.journal]
kind = "vault"
root = "journal"

[sources.counts]
kind = "csv"
path = "data/counts.csv"
journal = source("journal")
journal.cards | map(c => c.name)

source("journal") is a Vault, with its own cards; source("counts") is a Seq<Data>, a row per element, decoded as a declared record where one is expected, as above. The type comes from the configuration, so checking reads hql.toml and opens nothing. The id is a literal: source(id) with a computed id is a type error, and source("postgres://db/birds") is refused with a diagnostic saying connection details belong in configuration. An id the configuration does not declare is a name error that suggests the nearest one. The data sources examples read two vaults side by side.

What is next

Not every endpoint is bytes. Chapter 14 passes values between parts of a program through channels, and logs what a program does.