fs2-data

repository·main·Indexed 41 days ago

https://github.com/gnieh/fs2-data

A collection of streaming data parsers built on the fs2 library for efficient data processing in Scala. It includes tools for CSV parsing via CellDecoder and RowDecoderF, as well as a finite-state machine parser module featuring Pattern and Rhs for structural data matching and rule transitions.

Tokens
29.6K
Snippets
84
Records
110
Agent score
51%

What's inside fs2-data

  1. Overview of fs2-data

    main
    fs2-data is a collection of libraries designed for streaming parsing and transformation of data. It is built to be lightweight, aiming for minimal dependencies (primarily relying on fs2). It provides streaming support for various data formats including JSON, XML, CSV, CBOR, and MessagePack.
  2. Implement JSON queries with fs2-data-json

    main

    The fs2-data-json module provides a streaming, declarative implementation of a jq-like query language. This allows you to extract and transform specific parts of a JSON input stream.

    Note: The JSON query feature is currently experimental.

    import fs2._
    import fs2.data.json._
    
    // Example setup: converting a string input into a stream of JSON tokens
    val input = "{\"field1\": 0, \"field2\": \"test\", \"field3\": [1, 2, 3]}"
    val stream = Stream.emit(input).through(tokens[SyncIO, String])
  3. Build and eventify XML DOMs

    main

    The fs2.data.xml.dom package allows you to convert between XML event streams and a Document Object Model (DOM).

    Building a DOM from events

    Use the documents pipe. This requires an implicit DocumentBuilder[Doc] in scope, which defines how your specific Doc type is constructed from events.

    Converting a DOM back to events

    Use the eventify pipe to transform a stream of Doc objects back into a stream of XML events. This requires an implicit DocumentEventifier[Doc] in scope.

    import dom._
    
    trait SomeDocType
    
    // Building DOM
    implicit val builder: DocumentBuilder[SomeDocType] = ???
    stream.through(documents[IO, SomeDocType])
    
    // Eventifying DOM
    implicit val builder: DocumentBuilder[SomeDocType] = ???
    implicit val eventifier: DocumentEventifier[SomeDocType] = ???
    
    stream.through(documents[IO, SomeDocType])
          .through(eventify[IO, SomeDocType])
  4. Understand StaticHeaders in generic derivation

    main

    When you derive a CsvRowDecoder or CsvRowEncoder using the generic modules (automatic or semi-automatic), the resulting instances also implement the StaticHeaders type class.

    This implementation allows these decoders/encoders to be used with CSV pipes that require header information to be available at compile-time/statically, such as:

    • encodeUsingFirstHeaders
    • encodeUsingHeaders
  5. Understand the fs2-data-msgpack API namespaces

    main

    The fs2-data-msgpack module is divided into two primary namespaces depending on whether you want to work with Scala types or raw MessagePack structures:

    • fs2.data.msgpack.high: The recommended API for most users. It works directly with Scala values. You define MsgpackSerializer[A] and MsgpackDeserializer[A] to map your classes to MessagePack.
    • fs2.data.msgpack.low: A low-level API that operates on a flat model of MsgpackItems. This model mimics the raw MessagePack format and serves as the intermediate layer between binary data and the high-level Scala API.
  6. How to read, transform, and write JSON data with fs2-data

    main

    The fs2-data approach to processing JSON follows a linear pipeline:

    1. Reading: Use fs2 operators (like fs2-io) to read raw bytes/text.
    2. Parsing: Use the json.tokens pipe from fs2-data-json to convert the input stream into JSON Tokens.
    3. Transforming: Apply logic to the stream of tokens. This can be done using standard fs2 operators or by using the fs2.data.json.jq.Compiler to turn a jq query into a Pipe.
    4. Printing: Use json.render.prettyPrint() to convert the transformed tokens back into a formatted JSON string.
    5. Writing: Use fs2 operators (like fs2-io) to write the resulting stream to a destination (e.g., stdout or a file).
  7. How the high-level and low-level CSV APIs work together

    main

    The fs2-data-csv module provides two layers of abstraction for processing CSV data:

    1. High-level API (fs2.data.csv._): Provides convenient fs2.Pipes designed for common use cases, such as decoding CSV directly into case classes or encoding data with headers. It is built by composing low-level pipes.
    2. Low-level API (fs2.data.csv.lowlevel._): Provides granular pipes that perform single steps in a processing pipeline (e.g., parsing raw characters into rows). These are ideal for building complex, custom CSV processing logic.

    Both APIs share core data types and type classes located in fs2.data.csv._.

  8. Extend fs2-data with new XML DOM libraries

    main

    To add support for a new Scala XML DOM library to fs2-data, you must implement the following two traits:

    1. DocumentBuilder: Responsible for building a DOM from a stream of events.
    2. DocumentEventifier: Responsible for converting a DOM back into a stream of events.

    fs2-data provides several implementations out of the box for common libraries.

  9. How the jq query language works

    main

    A query consists of two potential phases:

    1. Filtering Phase: Selects elements from the input stream (similar to JSONPath). If no selector is provided, the input is selected as a whole.
    2. Construction Phase: Builds the output based on the selected elements. If no constructor is provided, elements are emitted unchanged.

    Query Patterns

    • filter: Applies only selection (e.g., .field).
    • filter | constructor: Selects a subset and builds a new structure (e.g., .field | { "new": . }).
    • constructor: Builds a result from the input top-level value (e.g., [ . ]).

    Constructors can contain sub-queries applied to each element selected during the filtering phase.

  10. Use the high-level CBOR representation

    main

    The high-level representation in fs2.data.cbor.high provides structured data (AST-like) which is useful for transformations that require knowledge of the data's hierarchy.

    Parsing

    • To parse directly from a byte stream, use the values[F] pipe.
    • To convert an existing stream of low-level items into high-level values, use the parseValues[F] pipe.

    Serializing

    • To serialize high-level values back to binary, use the toBinary pipe.
    • To convert high-level values back into low-level items, use the toItems pipe.

    Controlling Streamed Formats

    You can control whether arrays or maps are serialized using indefinite length encoding by setting the indefinite flag to true on the CborValue.

    Note: When using toItems, the indefinite flag on CborValue.Array or CborValue.Map determines if they are streamed in the serialized form.

    import fs2.data.cbor.high._
    
    // Parsing from bytes
    val valueStream = byteStream.through(values[Fallible])
    
    // Parsing from low-level items
    val valueStreamFromItems = itemStream.through(parseValues[Fallible])
    
    // Serializing to binary
    valueStream.through(data.cbor.high.toBinary)
    
    // Converting values back to low-level items
    valueStream.through(toItems)
    
    // Example: making top-level arrays streamed (indefinite)
    valueStream.map {
      case CborValue.Array(elements, _) => CborValue.Array(elements, true)
      case value                         => value
    }.through(toItems)
  11. Use the low-level CBOR representation

    main

    The low-level representation in fs2.data.cbor.low follows the RFC structure as a flat stream of items rather than an Abstract Syntax Tree (AST). This is ideal for processing data that is too large to fit in memory (e.g., collections exceeding Int.MaxValue elements) or when a tree structure is unnecessary.

    Parsing

    To parse a CBOR binary stream into a stream of low-level items, use the items[F] pipe, where F is your error handling context (e.g., Fallible).

    Serializing

    To convert a stream of CborItems back into a binary representation, use the toBinary[F] pipe. This pipe validates the input stream and will fail if the stream is invalid (e.g., if an array declaration does not match the number of elements provided).

    If you require higher throughput and are confident the source is valid, or if you prefer to silently generate potentially invalid byte streams, use the toNonValidatedBinary pipe instead.

    import fs2._
    import fs2.data.cbor.low._
    import scodec.bits._
    
    // Parsing
    val byteStream = Stream.chunk(Chunk.byteVector(hex"8301820203820405"))
    val itemStream = byteStream.through(items[Fallible])
    
    // Serializing (Validated)
    Stream.emits(List(CborItem.StartArray(2), CborItem.TextString("an"), CborItem.TextString("array")))
      .through(toBinary[Fallible])
    
    // Serializing (Unvalidated/High-throughput)
    Stream.emits(List(CborItem.StartArray(3), CborItem.TextString("an"), CborItem.TextString("array")))
      .through(toNonValidatedBinary)
  12. Handle optional data in CSV rows

    main

    CSV does not have a standard for missing or null values. fs2-data-csv provides asOptionalAt (for Row) and asOptional (for Row[Header]) to handle these cases.

    By default, these methods return None if a field is an empty string, but will fail if the field is missing (index out of bounds or header missing). You can customize this behavior by providing missing and isEmpty functions.

    val testRow = CsvRow.fromNelHeaders(NonEmptyList.of("first" -> "value1", "second" -> "null"))
    
    // Custom logic: treat missing fields as None and the string "null" as None
    def get(field: String) =
      testRow.asOptional[String](field, missing = _ => Right(None), isEmpty = _ == "null")
    
    get("first")  // Some(value1)
    get("second") // None
    get("third")  // None