Sign in

    moonloom

    Pipeline query engine over semi-structured text: CSV, JSON, logfmt, access logs and syslog in, query plans, indexes and aggregations out

    query-engine
    log-analysis
    semi-structured-data
    csv
    json
    logfmt
    syslog
    index
    aggregation
    Download zip
    Author
    Version
    0.1.1
    License
    Apache-2.0
    Last updated
    17 days ago
    Downloads
    4

    Dependencies

    #MoonLoom

    A pipeline query engine over semi-structured text, written in MoonBit. Point it at a CSV export, a JSON Lines dump, a logfmt application log, an HTTP access log or a plain text file, and ask questions about it with a small query language. No server, no schema declaration, no third-party dependency in the engine itself.

    $ moonloom query --file examples/access.log --stats \ "where status >= 500 | stats count() as failures by path | sort failures desc" +--------------------+----------+ | path | failures | +--------------------+----------+ | /api/orders?page=2 | 2 | | /api/orders?page=1 | 1 | | /health | 1 | +--------------------+----------+ rows in 21, scanned 21, filtered 17, groups 3, out 3

    There is no single format to standardise on. Every service writes logs its own way, every export tool has its own dialect, and every incident starts with a file nobody can query. MoonLoom's answer is to make the reading part interchangeable: one model (Value, Record, Table), several readers, and a query language that runs over all of them. The same query works whether the bytes arrived as CSV or as logfmt, and the same engine powers the command line and the library.

    #Requirements

    • The MoonBit toolchain, with moonc 0.10.14 or newer. The interfaces in this repository were generated with moon 0.1.20260920 and moonc v0.10.14.
    • The engine packages (moonloom, ascii, text, query, index, plan, exec, render) import only moonbitlang/core. The command line adds moonbitlang/x for file access, which is the official MoonBit utility package.

    #Quick start

    moon check --target all moon build --target wasm moon test --deny-warn --target wasm moon run examples/demo # a self-contained tour, no files needed moon run --target js cmd/main -- query --file examples/access.log \ "where status >= 500 | stats count() as n by path | sort n desc"

    The demo prints the plan, the rows and the cost for six queries; its last two sections run the same aggregation with and without an index so the difference is visible rather than claimed.

    In the examples below moonloom stands for the freshly built command line, moon run --target js cmd/main --. Every query has to arrive as one argument: a query that the shell splits makes the tool report unexpected extra argument rather than running a fragment of it. Inside a query, text literals may use either quote style, which is what makes the examples work in PowerShell as well as in sh, where the outer quoting rules differ.

    #What the query language does

    A query is a source and a chain of stages, each feeding the next:

    from <name> | where ... | stats ... | sort ... | limit ...

    The source name is for the caller; the data is always supplied by the host.

    StageMeaning
    where <expr> / filterkeep the rows the expression accepts
    fields a, b / selectkeep and order these columns
    eval name = <expr>add or replace a column
    parse "pattern"extract named fields from the source line
    stats agg, agg by a, bgroup and aggregate
    sort a desc, b asc / orderorder rows
    limit n / headkeep the first n rows
    top n by <key>order and cut in one step
    dedup a, b / distinctkeep the first row per key

    Expressions support == = != <> < <= > >=, =~ !~ (regular expression), contains, startswith, endswith, in [..], is null / is not null, and && or || not !, and + - * / %. Literals are integers, decimals, single- or double-quoted text, true, false, null, and lists.

    Aggregates: count, count_distinct, sum, avg, min, max, percentile(x, 95), stddev, first, last. Column names are generated (sum_bytes, p95_duration) unless as gives one.

    Scalar functions: lower, upper, trim, len, str, int, float, type, is_null, is_num, coalesce, if, concat, substr, replace, abs, ceil, floor, round, sqrt, sign, pow, min, max.

    Three small examples over the files in examples/:

    # sales: revenue per region, largest first moon run --target js cmd/main -- query --file examples/sales.csv --output markdown \ "stats sum(amount) as revenue by region | sort revenue desc" # JSON Lines: nested objects become dotted field names moon run --target js cmd/main -- query --file examples/events.jsonl \ "where http.status >= 500 | stats count() as n, percentile(duration_ms, 95) as p95 by service" # plain text: a pattern turns prose into columns moon run --target js cmd/main -- query --file examples/service.log --format lines \ "parse \"{time} {level} {service}: {message}\" | where level == \"error\""

    #Reading formats

    --format auto (the default) sniffs the format from the first non-blank line. Naming one explicitly skips the guess.

    ReaderAccepts
    csv / tsvRFC 4180 delimited text; the delimiter is guessed from , ; \t | and quoted fields may contain it, doubled quotes and line breaks
    jsonone document: an array of objects, or a single object
    jsonlone JSON value per line; nested objects become a.b, arrays become a.0
    logfmtkey=value, quoted values, bare flags
    keyvaluekey: value or key = value, with [section] headers and # comments
    accessCommon and Combined Log Format, with the request split into method, path and protocol
    syslogRFC 3164 and RFC 5424, with facility and severity decoded from the priority
    linesone record per line, with a single line field

    Every reader returns a table and a list of diagnostics. A line it cannot read is skipped and reported instead of aborting the run, so one broken line in a million still leaves a usable result:

    $ moonloom schema --file examples/events.jsonl format jsonl records 10 skipped 0 +-------------+------+---------+---------+----------+ | name | kind | present | missing | distinct | +-------------+------+---------+---------+----------+ | time | text | 10 | 0 | 9 | | level | text | 10 | 0 | 3 | | duration_ms | num | 10 | 0 | 10 | | http.status | int | 10 | 0 | 6 | ...

    Types are decided while reading, and the engine only claims what the text proves. 1 and 2.5 become numbers, true becomes a boolean, an empty field becomes null, and 007 stays text because parsing it as 7 would lose the leading zeros.

    #How it executes

    Two things happen between the query text and the first row, and both are visible through explain:

    $ moonloom explain --file examples/access.log \ "where remote_host == \"203.0.113.51\" | fields time_text, method, path, status" plan scan <input> rows=21 index remote_host=203.0.113.51 kept=3/21 (14.3%) 1. filter remote_host == "203.0.113.51" 2. project time_text, method, path, status columns remote_host, time_text, method, path, status

    1. Index pruning. Every column is indexed as a value-to-rows map unless it is unique per row, in which case indexing it would prune nothing. Equality predicates are collected from the first where, their posting lists are intersected, and only those rows are read. The filter still runs, because the index only removes rows it can prove cannot match.
    2. Column pruning. The planner collects the fields the pipeline actually reads and reports them on the columns line. A stage after an aggregation can only name group keys and aggregate outputs, so it does not widen the set.

    The executor reports what it cost:

    $ moonloom query --file examples/access.log --stats "where status >= 500 | stats count() as n by path" ... rows in 21, scanned 21, filtered 17, groups 3, out 3

    #Using it as a library

    The packages are layered, and each one is usable on its own:

    PackageWhat it provides
    haol-05/moonloomValue, Record, Table, Dataset, diagnostics, schema inference, number parsing
    .../asciicode-unit predicates and lexicographic text comparison
    .../textthe readers plus timestamp and duration parsing
    .../querytokens, syntax tree and the parser
    .../indexinverted index and candidate pruning
    .../planplan construction and explain
    .../execexpression evaluation, aggregation and the operators
    .../renderaligned table and JSON / JSON Lines / CSV / Markdown exports

    The shortest path from bytes to rows is one call:

    match @exec.query_text(source, "where level == \"error\" | stats count() as n by service") {
    Ok(result) => println(@render.render_table(result))
    Err(message) => println("query error: " + message)
    }

    The long path is available when a host wants to control the pieces — parse once, build the index once, compile the plan, print plan.explain(), run it, and render in whichever format the caller needs:

    let dataset = @text.parse_auto(source, format="logfmt")
    let query = @query.Query::parse(text).unwrap()
    let index = @index.TableIndex::build(dataset.table)
    let plan = @plan.compile(query, dataset.table, Some(index))
    println(plan.explain())
    let result = @exec.run(query, dataset.table, Some(index))

    Query::parse returns Result, so a host never has to handle an exception to report a typo: the message carries the line and column of the mistake.

    #What it does not do

    • It is not a streaming engine. A whole input is read into memory before a query runs, so the working set is the size of the data. --limit caps how many records are read, which helps, but there is no windowed or incremental execution yet.
    • It is not a full regular expression engine. =~ uses the toolchain's matcher; a pattern that will not compile falls back to a substring test rather than failing the query.
    • It does not write. There is no INSERT, UPDATE or DELETE, and no storage layer; MoonLoom reads text and answers questions about it.
    • Joins are out of scope. One query looks at one input.
    • The index answers conjunctions only. A predicate under or is evaluated by the filter instead of the index, which is correct but slower.
    • Group ordering is by group value, not by first appearance, so results are reproducible regardless of input order. A query that needs another order says so with sort.
    • Display width is approximate. The renderer implements the common East Asian Width ranges, not the full property, so a handful of emoji may still misalign a column.
    • Floats in a table are rounded to six decimals for readability; the JSON and CSV exports keep the value unchanged.

    #Reproducing the checks

    moon version --all moon check --deny-warn --target all moon build --target wasm moon test --deny-warn --target wasm moon test --deny-warn --target wasm-gc moon test --deny-warn --target js moon fmt && git diff --exit-code moon info && git diff --exit-code moon run --target wasm examples/demo

    A search of GitHub and mooncakes.io on 2026-09-29 for moonbit queryengine, moonbit log query, moonbit logfmt and moonbit csv query found no project that reads several text formats and queries them. The nearest neighbours, and how MoonLoom differs:

    • sundaysebasidian-byte/moon-loglens streams JSON Lines with filters and grouped metrics. It reads one format, has no query parser, no plan, no index and no command line, so filters are expressed by calling its API rather than by writing a query.
    • ihb2032/MoonFrame is a DataFrame with an expression engine over structured data. It expects tabular input; recovering a table from an access log, a syslog file or a pattern is left to the caller.
    • sqhyyy/moonlogfmt-lens parses, redacts and diffs logfmt specifically.
    • moonbit-community/sqlparser is an extensible SQL parser with no executor.

    MoonLoom's contribution is the layer above the readers: one query language over all of them, a planner that makes predicate and column pruning explicit, and an executor that reports what it scanned. Those searches cover GitHub and mooncakes.io only and are not proof of uniqueness.

    moonbitlang/x is used by the command line for file access (Apache-2.0). No code was copied from it or from any other project; the readers, the query language, the planner and the executor are written for this repository. The sample files in examples/ are fictional: the addresses come from the documentation ranges reserved by RFC 5737 and every name is invented.

    #Environment note

    Verified on 2026-09-29 on Windows with moon 0.1.20260920 and mooncv0.10.14: moon check --deny-warn --target all, moon build and moon test--deny-warn on wasm, wasm-gc and js (95 tests each), the demo through moon run, the command line through moon run --target js cmd/main, and moon fmt / moon info leaving the working tree unchanged.

    The native target does not build on that host for two reasons that are both outside this repository: the toolchain's own runtime file <moon-home>/lib/runtime/env.c calls rand_s without a declaration, and the C toolchain cannot create object files under a path containing non-ASCII characters, which every build directory on that machine has. A three-line test package fails identically. GitHub Actions runs the same steps on ubuntu-latest, including native.

    #License

    Apache-2.0. See LICENSE.

    Dataset

    pub(all) struct Dataset {
    table : Table
    format : String
    lines_read : Int
    skipped : Int
    } derive(
    Debug
    )

    A parsed input together with how it was read.

    Dataset::add

    fn Dataset::add(self : Dataset, record : Record) -> Unit

    Dataset::empty

    fn Dataset::empty(format : String) -> Dataset

    Dataset::rows

    fn Dataset::rows(self : Dataset) -> Int

    Dataset::skip

    fn Dataset::skip(self : Dataset, diagnostic : Diagnostic) -> Unit

    Dataset::to_repr

    Diagnostic

    pub(all) struct Diagnostic {
    code : String
    line : Int
    message : String
    } derive(Eq,
    Debug
    )

    Something that went wrong while reading input. Diagnostics never stop a parse: the offending line is skipped and reported, so one broken line in a million still leaves a usable table.

    Diagnostic::equal

    fn Diagnostic::equal(Diagnostic, Diagnostic) -> Bool

    Diagnostic::new

    fn Diagnostic::new(code : String, line : Int, message : String) -> Diagnostic

    Diagnostic::not_equal

    fn Diagnostic::not_equal(x : Diagnostic, y : Diagnostic) -> Bool

    FieldInfo

    pub(all) struct FieldInfo {
    name : String
    kind : ValueKind
    present : Int
    missing : Int
    distinct : Int
    } derive(Eq,
    Debug
    )

    Per column summary used by moonloom schema, by the planner when it decides whether a comparison can be answered numerically, and by explain.

    FieldInfo::equal

    fn FieldInfo::equal(FieldInfo, FieldInfo) -> Bool

    FieldInfo::not_equal

    fn FieldInfo::not_equal(x : FieldInfo, y : FieldInfo) -> Bool

    Record

    pub(all) struct Record {
    fields : Map[String, Value]
    order : Array[String]
    source_line : Int
    raw : String
    } derive(
    Debug
    )

    One row. fields holds the extracted values, order remembers the order in which field names were first seen so that output columns stay stable, and raw keeps the source text for parse stages and for debugging.

    Record::empty

    fn Record::empty(source_line : Int, raw : String) -> Record

    Record::field

    fn Record::field(self : Record, name : String) -> Value

    The value of a field, or Null when the record does not carry it.

    Record::has

    fn Record::has(self : Record, name : String) -> Bool

    Record::names

    fn Record::names(self : Record) -> Array[String]

    Field names in first-seen order.

    Record::remove

    fn Record::remove(self : Record, name : String) -> Unit

    Record::set

    fn Record::set(self : Record, name : String, value : Value) -> Unit

    Stores a value. A new name is appended to the column order, an existing one keeps its position.

    Record::set_if_absent

    fn Record::set_if_absent(self : Record, name : String, value : Value) -> Unit

    Stores a value only when the name is not present yet, which is how a parser keeps the first occurrence of a repeated key.

    Record::size

    fn Record::size(self : Record) -> Int

    Record::to_repr

    Table

    pub(all) struct Table {
    columns : Array[String]
    records : Array[Record]
    diagnostics : Array[Diagnostic]
    } derive(
    Debug
    )

    A table: an ordered set of columns plus the rows that carry them.

    Table::add_column

    fn Table::add_column(self : Table, name : String) -> Unit

    Registers a column name, keeping first-seen order and ignoring repeats.

    Table::add_diagnostic

    fn Table::add_diagnostic(self : Table, diagnostic : Diagnostic) -> Unit

    Table::column_names

    fn Table::column_names(self : Table) -> Array[String]

    Column names in stable order, widened by any field that only some rows carry.

    Table::empty

    fn Table::empty() -> Table

    Table::is_empty

    fn Table::is_empty(self : Table) -> Bool

    Table::length

    fn Table::length(self : Table) -> Int

    Table::of_records

    fn Table::of_records(columns : Array[String], records : Array[Record]) -> Table

    Table::push

    fn Table::push(self : Table, record : Record) -> Unit

    Table::to_repr

    Value

    pub(all) enum Value {
    Null
    Bool(Bool)
    Int(Int64)
    Num(Double)
    Str(String)
    } derive(Eq,
    Debug
    )

    One field value.

    Value::as_int

    fn Value::as_int(self : Value) -> Int64?

    Integral view of a value. Fractional values are only accepted when they have no fractional part, so 1.0 converts while 1.5 does not.

    Value::as_num

    fn Value::as_num(self : Value) -> Double?

    Numeric view of a value: integers are widened, booleans become 0 or 1, and everything else has no numeric view.

    Value::as_text

    fn Value::as_text(self : Value) -> String

    Text view of a value, used by string functions and by grouping.

    Value::compare

    fn Value::compare(self : Value, other : Value) -> Int

    Total order over values, used for sorting and for range predicates: missing first, then booleans, then every number together, then text.

    Value::display

    fn Value::display(self : Value) -> String

    User-facing text of a value. Strings are printed verbatim, without quotes; use Value::json_text when the output has to stay machine readable.

    Value::equal

    fn Value::equal(Value, Value) -> Bool

    Value::from_text

    fn Value::from_text(text : String) -> Value

    Schema-on-read typing of one text field.

    The result only claims what the text itself proves: an integer is only reported when the digits round-trip, so "007" stays text instead of silently losing its leading zeros, and a value only becomes a number when it really carries a fraction or an exponent.

    Value::is_null

    fn Value::is_null(self : Value) -> Bool

    Value::json_text

    fn Value::json_text(self : Value) -> String

    The text of a value as it would appear inside a JSON document.

    Value::kind

    fn Value::kind(self : Value) -> ValueKind

    The kind of a value.

    Value::not_equal

    fn Value::not_equal(x : Value, y : Value) -> Bool

    Value::text_eq

    fn Value::text_eq(self : Value, other : Value) -> Bool

    True when two values carry the same text, ignoring their kinds. 1 and "1" agree here, which is what a text filter over a log expects.

    Value::to_repr

    Value::truthy

    fn Value::truthy(self : Value) -> Bool

    Truth value used by where and by boolean functions. Missing values are false, numbers are true when non-zero, and non-empty strings are true.

    ValueKind

    pub(all) enum ValueKind {
    KindNull
    KindBool
    KindInt
    KindNum
    KindText
    KindMixed
    } derive(Eq,
    Debug
    )

    The coarse type of a value, used by schema inference and by the renderer. KindMixed is never produced by a single value; inference reports it for a column whose rows disagree.

    ValueKind::equal

    fn ValueKind::equal(ValueKind, ValueKind) -> Bool

    ValueKind::label

    fn ValueKind::label(self : ValueKind) -> String

    Stable label of a kind, as printed by moonloom schema.

    ValueKind::not_equal

    fn ValueKind::not_equal(x : ValueKind, y : ValueKind) -> Bool

    escape_json

    fn escape_json(s : String) -> String

    Escapes a string for embedding inside a JSON document.

    find_field

    fn find_field(fields : Array[FieldInfo], name : String) -> FieldInfo?

    Looks up one column summary by name.

    format_double

    fn format_double(d : Double) -> String

    Renders a double in a compact, stable form: whole numbers lose the .0 suffix so that a count of 3 prints as 3 rather than 3.0, while fractional values keep their digits.

    infer_fields

    fn infer_fields(table : Table, distinct? : Bool) -> Array[FieldInfo]

    Summarises every column of a table.

    distinct counts the different printed values per column; it is optional because it costs one set per column on wide tables.

    parse_double_opt

    fn parse_double_opt(text : StringView) -> Double?

    parse_int64_opt

    fn parse_int64_opt(text : StringView) -> Int64?

    parse_int_opt

    fn parse_int_opt(text : StringView) -> Int?