Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ number says nothing about any other port. See the Releases section of

- **`DatasetReader` reads a row under the caller's `max_bytes_literal_length`** (#160), as protowire-go does. A row's cells were parsed under the default `kMaxBytesLiteralLength` whatever the options said. A caller who lowered the limit still got a row whose `b"…"` cell was past it, and a caller who raised it had that cell refused. `0` still means the default. Only the nesting depth used to reach the row parser. The header (a `@header` block and the `@dataset` line) was parsed under the defaults for both limits. It is now parsed under the caller's `max_nesting_depth` and `max_bytes_literal_length`, so `Create` refuses such a header as protowire-go's `NewDatasetReader` does, with the same text and position.
- **`DatasetReader` positions count from the start of the stream** (#161), as protowire-go's do. A row's `pos`, its cells' positions and the line and column of an error from `Next` or `Scan` counted from the start of that row, so every row was at `1:1`. A refused row in a long stream now names its own line. `pxf::Lexer` gains a constructor that starts at a given `Position`, which the reader uses for each row.
- **A `@dataset` row cut short no longer reads as the end of the table** (#164), as protowire-go's streaming reader does. `DatasetReader::Next` and `Scan` ended the table cleanly at a row whose `)` never came, at an unterminated string, or at anything else where a row could begin. So an interrupted append read as a shorter, complete dataset, and the rows after a broken one were dropped. The rows now end only at the end of the input or at the next directive. Anything else is an error, and it is the error `Parse` reports for the same input, at the same position. This narrows what streams: the spec's `truncated-row-eof`, `truncated-row-then-rows` and `junk-between-rows` fixtures are now refused through the reader as well as by `Parse`. At the end of the input the reader consumes what is left, so `Tail()` is empty there.
- **`DatasetReader` caps the bytes it holds while looking for a row's end at `max_message_size`** (#164), as protowire-go does: `dataset input holds N bytes without a row boundary, MaxMessageSize=M`. A row that never closed was buffered whole, whatever the limit.
- **`DatasetReader::Scan` binds a row under the default `max_message_size`** (#164), as protowire-go does. The body a row is bound as repeats the column names, so it is wider than the row, and a row whose bytes fit the caller's limit could still be refused when bound. The row's bytes are still capped as they are read. The other limits apply as before.
- **A broken `@dataset` header is refused with the error `Parse` gives** (#164), as protowire-go does. `Create` said `no @dataset directive in stream` for a header cut short, and gave its own text for an unterminated string in it. The reader now finds the header's and each row's end with the lexer `Parse` uses rather than a byte scanner of its own. Streaming is about 15% faster on a 300,000-row table.

## [2.0.0] — 2026-11-05

Expand Down
42 changes: 34 additions & 8 deletions include/protowire/pxf/dataset_reader.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,20 @@
// Per draft §3.4.4: streaming consumers MUST enforce per-row arity and
// the v1 cell-grammar rule on each row as it is consumed (not deferred
// to end-of-input) and MUST yield rows in source order. Both invariants
// fall out of this implementation: the byte-level row scanner produces
// one (...) slice at a time, and the same `Parse()` used by the
// materializing path validates it.
// fall out of this implementation: the row scanner, which reads with
// the lexer `Parse()` uses, produces one (...) slice at a time, and the
// row parser the materializing path uses validates it.
//
// Convenience: `Scan(msg)` reads the next row and binds its cells to
// `msg`'s fields by column name; `BindRow` is exported for callers that
// iterate the materializing path's `Result::Datasets()[i].rows`.

#pragma once

#include <cstdint>
#include <istream>
#include <memory>
#include <optional>
#include <string>
#include <vector>

Expand Down Expand Up @@ -65,9 +67,13 @@ class DatasetReader {
// stream") or on a parse / I/O error.
static StatusOr<std::unique_ptr<DatasetReader>> Create(std::istream* src);

// Create under options: Scan binds rows with them, so a cell holding an
// Any block needs opts.type_resolver, and opts.skip_validate turns off
// Scan's schema check.
// Create under options. The bytes held while a row's end is sought are
// capped at opts.max_message_size, and the header and every row are
// parsed under its nesting and bytes-literal limits. Scan binds rows with
// the options, but under the default max_message_size, since a row's body
// repeats the column names. So a cell holding an Any block needs
// opts.type_resolver, and opts.skip_validate turns off Scan's schema
// check.
static StatusOr<std::unique_ptr<DatasetReader>> Create(std::istream* src, UnmarshalOptions opts);

// Row message type declared by the @dataset header (e.g. "trades.v1.Trade").
Expand All @@ -83,8 +89,12 @@ class DatasetReader {

// Reads the next row into `*out`. Returns OK on success; returns OK
// and sets `done_` when the row sequence is exhausted (callers check
// `Done()` to distinguish "got a row" from "EOF"). Returns a non-OK
// Status on parse / I/O error.
// `Done()` to distinguish "got a row" from "EOF"): at the end of the
// input, or before the next directive (read it via Tail). Anything else
// that is not a well-formed row, a row cut short at the end of the input
// included, is an error, and it is the error Parse reports for the same
// input, at the same position. So is a read failure, and holding more
// than the options' max_message_size bytes while seeking a row's end.
//
// After EOF or error, all subsequent calls return the same sticky
// result.
Expand Down Expand Up @@ -114,15 +124,31 @@ class DatasetReader {
private:
DatasetReader() = default;

// What ScanRow or ScanHeader found; see dataset_reader.cc.
enum class RowScan : uint8_t { kNeedMore, kRow, kBad, kEnd, kNotRow };

Status ReadHeader();
Status Pull(size_t n);
Status Fail(Status st);
int MaxBytesLiteral() const;
StatusOr<DatasetRow> ParseRow(int start, int end, int* end_offset = nullptr) const;
std::optional<DatasetRow> TryWholeRow();
Status RowError(int start) const;
RowScan ScanRow(int* start, int* end);
RowScan ScanHeader(int* end) const;
bool Complete(int tok_end) const;

std::istream* src_ = nullptr;
Position PosAt(size_t i) const;
void Consume(size_t n);

std::string pending_; // bytes pulled from src_ but not yet consumed
Position base_; // where pending_[0] is in the stream
// The row scan resumes where the last read left it: scan_pos_ is the
// first token not known to be complete, row_start_ the open row's `(`
// or -1 between rows. Both index pending_.
int scan_pos_ = 0;
int row_start_ = -1;
bool src_eof_ = false;
bool finished_ = false;
Status err_;
Expand Down
Loading
Loading