Introduction
SuperDB is a new type of analytics database that promises an easier approach to modern data because it unifies relational tables and eclectic JSON in a powerful, new data model called super-structured data.
Note
The SuperDB implementation is open source and available as a GitHub repository. Pre-built binaries may be downloaded and installed via customary mechanisms.
Super-structured data is
- dynamic so that data collections can vary by type and are not handcuffed by schemas,
- strongly typed ensuring that all the benefits of a comprehensive type apply to dynamic data, and
- self-describing thus obviating the need to define schemas up front.
SuperDB has taken many of the best ideas of current data systems and adapted them for super-structured data with the introduction of:
- the super-structured data model,
- several super-structured serialization formats,
- a SQL-compatible query language adapted for super-structured data,
- a super-structured query engine, and
- a super-structured database format compatible with cloud object stores.
To achieve high performance for the dynamically typed data that lies at the heart of super-structured data, SuperDB has devised a novel vectorized runtime built as a clean slate around algebraic types. This contrasts with the Frankenstein approach taken by other analytics systems that shred variants into relational columns.
Putting JSON into a relational table — whether adding a JSON or variant column to a relational table or performing schema inference that does not always work — is like putting a square peg in a round hole. SuperDB turns this status quo upside down where JSON and schema-constrained relational tables are simply special cases of the more general and holistic super-structured data model.
This leads to ergonomics for SuperDB that are far better for the query language and for managing data end to end because there is not one way for handling relational data and a different way for managing dynamic data — relational tables and eclectic JSON data are treated in a uniform way from the ground up. For exmaple, there’s no need for a set of Parquet input files to all be schema-compatible and it’s easy to mix and match Parquet with JSON across queries.
Super-structured Data
Super-structured data is strongly typed and self describing. While compatible with relational schemas, SuperDB does not require such schemas as they can be modeled as super-structured records.
More specifically, a relational table in SuperDB is simply a collection of uniformly typed records, whereas a collection of dynamic but strongly-typed data can model any sequence of JSON values, e.g., observability data, application events, system logs, and so forth.
Thus, data in SuperDB is
- strongly typed like databases, but
- dynamically typed like JSON.
Self-describing data makes data easier: when transmitting data from one entity to another, there is no need for the two sides to agree up front what the schemas must be in order to communicate and land the data.
Likewise, when extracting and serializing data from a query, there is never any loss of information as the super-structured formats capture all aspects of the strongly-typed data whether in human-readable form, binary row-like form, or columnar-like form.
The super Command
SuperDB is implemented as the standalone,
dependency-free super command.
super is a little like DuckDB and a little like
jq but super-structured data ties these
two command styles together with strong typing of dynamic data.
Because super has no dependencies, it’s easy to get going —
just install the binary and you’re off and running.
SuperDB separates compute and storage and is decomposed into a runtime system that
- runs directly on any data inputs like files, streams, or APIs, or
- manipulates and queries data in a persistent storage layer — the SuperDB database — that rhymes in design with the emergent lakehouse pattern but is based on super-structured data.
To invoke the SuperDB runtime without a database, just run super without
the db subcommand and specify an optional query with -c:
super -c "SELECT 'hello, world'"
To interact with a SuperDB database, invoke the db subcommands
of super and/or program against the database API.
Note
The persistent database layer is still under development and not yet ready for turnkey production use.
Why Not Relational?
The fashionable argument against a new system like SuperDB is that SQL and the relational model (RM) are perfectly good solutions that have stood the test of time so there’s no need to replace them. In fact, a recent paper from legendary database experts argues that any attempt to supplant SQL or the RM is doomed to fail because any good ideas that arise from such efforts will simply be incorporated into SQL and the RM.
Yet, the incorporation of the JSON data model into the relational model never fails to disappoint. One must typically choose between creating columns of a JSON or variant type that layers in a parallel set of operators and behaviors that diverge from core SQL semantics, or rely upon schema inference to convert variant data into relational tables, which unfortunately does not always work.
To understand the difficulty of schema inference,
consider this simple line of JSON data is in a file called example.json:
{"a":[1,"foo"]}
Note
The literal
[1,"foo"]is a contrived example but it adequately represents the challenge of mixed-type JSON values, e.g., an API returning an array of JSON objects with varying shape.
Surprisingly, this simple JSON input causes unpredictable schema inference
across different SQL systems.
Clickhouse converts the JSON number 1 to a string:
$ clickhouse -q "SELECT * FROM 'example.json'"
['1','foo']
DuckDB does only partial schema inference and leaves the contents of the array as type JSON:
$ duckdb -c "SELECT * FROM 'example.json'"
┌──────────────┐
│ a │
│ json[] │
├──────────────┤
│ [1, '"foo"'] │
└──────────────┘
And DataFusion fails with an error:
$ datafusion-cli -c "SELECT * FROM 'example.json'"
DataFusion CLI v46.0.1
Error: Arrow error: Json error: whilst decoding field 'a': expected string got 1
It turns out there’s no easy way to represent this straightforward
literal array value [1,'foo'] in these SQLs, e.g., simply including this
value in a SQL expression results in errors:
$ clickhouse -q "SELECT [1,'foo']"
Code: 386. DB::Exception: There is no supertype for types UInt8, String because some of them are String/FixedString/Enum and some of them are not. (NO_COMMON_TYPE)
$ duckdb -c "SELECT [1,'foo']"
Conversion Error:
Could not convert string 'foo' to INT32
LINE 1: SELECT [1,'foo']
^
$ datafusion-cli -c "SELECT [1,'foo']"
DataFusion CLI v46.0.1
Error: Arrow error: Cast error: Cannot cast string 'foo' to value of Int64 type
The more recent innovation of an open variant type is more general than JSON but suffers from similar problems. In both these cases, the JSON type and the variant type are not individual types but rather entire type systems that differ from the base relational type system and so are shoehorned into the relational model as a parallel type system masquerading as a specialized type to make it all work.
Maybe there is a better way?
Enter Algebraic Types
What’s missing here is an easy and native way to represent mixed-type entities. In modern programming languages, such entities are enabled with a sum type or tagged union.
While the original conception of the relational data model anticipated “product types” — in fact, describing a relation’s schema in terms of a product type — it unfortunately did not anticipate sum types.
Note
Codd’s original paper on the relational model has a footnote that essentially describes as a product type:
But sum types were notably absent.
Armed with both sum and product types, super-structured data provides a comprehensive algebraic type system that can represent any JSON value as a concrete type. And since relations are simply product types as originally envisioned by Codd, any relational table can be represented also as a super-structured product type. Thus, JSON and relational tables are cleanly unified with an algebraic type system.
In this way, SuperDB “just works” when it comes to processing the JSON example from above:
$ super -c "SELECT * FROM 'example.json'"
{a:[1,"foo"]}
$ super -c "SELECT [1,'foo'] AS a"
{a:[1,"foo"]}
In fact, we can see algebraic types at work here if we interrogate the type of such an expression:
$ super -c "SELECT typeof(a) as type FROM (SELECT [1,'foo'] AS a)"
{type:<[int64|string]>}
In this super-structured representation, the type field is a first-class
type value
representing an array type of elements having a sum type of int64 and string.
SuperSQL
Since super-structured data is a superset of the relational model, it turns out that a query language for super-structured data can be devised that is a superset of SQL. The SuperDB query language is a Pipe SQL adapted for super-structured data called SuperSQL.
SuperSQL is particularly well suited for data-wrangling use cases like ETL and data exploration and discovery. Syntactic shortcuts, keyword search, and the pipe syntax make interactively querying data a breeze. And language features like recursive functions with re-entrant subqueries allow for traversing nested data in a general and powerful fashion.
Instead of operating upon statically typed relational tables as SQL does, SuperSQL operates upon super-structured data. When such data happens to look like a table, then SuperSQL can work just like SQL:
$ super -c "
SELECT a+b AS x, a-b AS y FROM (
VALUES (1,2), (3,0)
) AS T(a,b)
"
{x:3,y:-1}
{x:3,y:3}
But when data does not conform to the relational model, SuperSQL can still handle it with its super-structured runtime:
$ super -c "
SELECT avg(radius) as R, avg(width) as W FROM (
VALUES
{kind:'circle',radius:1.5},
{kind:'rect',width:2.0,height:1.0},
{kind:'circle',radius:2},
{kind:'rect',width:1.0,height:3.5}
)
"
{R:1.75,W:1.5}
Things get more interesting when you want to do different types of processing for differently typed entities, e.g., let’s compute an average radius of circles, and double the width of each rectangle. This time we’ll use the pipe syntax with shortcuts and employ first-class errors to flag unknown types:
$ super -c "
values
{kind:'circle',radius:1.5},
{kind:'rect',width:2.0,height:1.0},
{kind:'circle',radius:2},
{kind:'rect',width:1.0,height:3.5}
| switch kind
case 'circle' (
R:=avg(radius)
)
case 'rect' (
width:=width*2
)
"
{R:1.75}
{kind:"rect",width:4.,height:1.}
{kind:"rect",width:2.,height:3.5}
So what’s going on here? The data model here is acting both as a strongly typed representation of JSON-like sequences as well as a means to represent relational tables. And SuperSQL is behaving like SQL when applied to table-like data, but at the same time is a pipe-syntax language for arbitrarily typed data. The super-structured data model ties it all together.
To make this all work, the runtime must handle arbitrarily typed data. Hence, every operator in SuperSQL has defined behavior for every possible input type. This is the key point of departure for super-structured data: instead of the unit of processing being a relational table, which requires a statically defined schema, the unit of processing is a collection of arbitrarily typed values. In a sense, SuperDB generalizes Codd’s relational algebra to polymorphic operators. All of Codd’s relational operators can be recast in this fashion forming the polymorphic algebra of super-structured data implemented by SuperDB.
Evolving SQL
Despite SQL’s enduring success, it is widely accepted that there are serious flaws in the language and a number of authors argue that SQL should be replaced in its entirety. Among many such works, here are some noteworthy arguments:
- A Critique of the SQL Database Language a 1983 paper by C.J. Date,
- Against SQL by Jamie Brandon,
- A Critique of Modern SQL And A Proposal Towards A Simple and Expressive Query Language by Neumann and Leis.
A very different approach is taken in SQL Has Problems. We Can Fix Them: Pipe Syntax In SQL, which argues that SQL should be merely improved upon and not replaced outright. Here the authors argue that except for compositional syntax, SQL is perfectly reasonable and we should live with its anachronisms (see Section 2.4). Thus, their Pipe SQL specification carries forward SQL eccentricities into their modern adaptation of pipes for SQL.
SuperSQL takes a different approach and seizes the opportunity to modernize the ergonomics of a SQL-compatible query language. While embracing backward compatibility, SuperSQL diverges significantly from SQL anachronisms in the pipe portion of the language by introducing pipe-scoping semantics that coexists next to the relational-scoping semantics of SQL tables and columns.
The vision here is that comprehensive backward compatibility can reside in the SQL operators while a modernized syntax and and improved ergonomics can reside in the pipe operators, e.g.,
- array indexing can be configured as 1-based in SQL clauses but 0-based in pipe operators,
- column names in SQL clauses are case insensitive while record field references are case sensitive in pipe operators,
- complex scoping rules for table aliases and column references are required in
relational SQL while binding from names to data in pipe operators is managed
in a uniform and simple way as derefenced paths on
this, - the syntactic structure of SQL clauses means all data must conform to a table whereas pipe operators can emit any data type desired in a varying fashion, and
- sum types are integral to piped data allowing mix-typed data processing and results that need not fit in a uniform table.
With this approach, SuperSQL can be adopted and used for existing use cases based on legacy SQL while incrementally expanding and embracing the pipe model tied to super-structured data. Perhaps this could enable a long-term and gradual transition away from relational SQL toward a modern and more ergonomic replacement.
The jury is out as to whether a Pipe SQL for super-structured data is the right approach for curing SQL’s ills, but it certainly provides a framework for exploring entirely new language abstractions while maintaining complete backward compatibility with SQL all in the same query language.
Note
If SuperDB or super-structured data has piqued your interest, you can dive deeper by:
- exploring the SuperSQL query language,
- learning about the super-structured data model and formats underlying SuperDB, or
- browsing the tutorials.
We’d love your feedback and we hope to build a thriving community around SuperDB so please feel free to reach out to us via
See you online!
Getting Started
It’s super easy to get going with SuperDB.
Short on time? Just browse the TL;DR.
Otherwise, try out the embedded playground examples throughout the documentation, or
- install super, and
- try it out.
The super command is a single binary
arranged into a hierarchy of sub-commands.
SuperDB’s disaggregation of compute and storage is reflected into the
design of its command hierarchy: running the top-level super command
runs the compute engine only on inputs like files and URLs, while
the db subcommands of super operate upon
a persistent database.
To get online help, run the super command or any sub-command with -h,
e.g.,
super -h
displays help for the top-level command, while
super db load -h
displays help for loading data into a SuperDB database, and so forth.
Note
While
superand its accompanying data formats are production quality for many use cases, the project’s persistent database is a bit earlier in development.
TL;DR
TL;DR
Don’t have time to dive into the documentation?
Just skim these one liners to get the gist of what SuperDB can do!
Note that JSON files can include any sequence of JSON values like newline-deliminted JSON though the values need not be newline deliminated.
Query a CSV, JSON, or Parquet file using SuperSQL
super -c "SELECT * FROM file.[csv|csv.gz|json|json.gz|parquet]"
Run a SuperSQL query sourced from an input file
super -I path/to/query.sql
Pretty-print a sample value as super-structured data
super -S -c "limit 1" file.[csv|csv.gz|json|json.gz|parquet]
Compute a histogram of the “data shapes” in a JSON file
super -c "count() by typeof(this)" file.json
Display a sample value of each “shape” of JSON data
super -c "any(this) by typeof(this) | values any" file.json
Search Parquet files easily and efficiently without schema handcuffs
super *.parquet > all.bsup
super -c "? search keywords | other pipe processing" all.bsup
Read a CSV from stdin, process with a query, and write to stdout
cat input.csv | super -f csv -c <query> -
Fuse JSON data into a unified schema and output as Parquet
super -f parquet -o out.parquet -c fuse file.json
Run as a calculator
super -c "1.+(1/2.)+(1/3.)+(1/4.)"
Search all values in a database pool called logs for keyword “alert” and level >= 2
super db -c "from logs | ? alert level >= 2"
Handle and wrap errors in a SuperSQL pipeline
... | super -c "
switch is_error(this) (
case true ( values error({message:"error into stage N", on:this}) )
default (
<non-error processing here>
...
)
)
"
| ...
Embed a pipe query search in SQL FROM clause
super -c "
SELECT union(type) as kinds, network_of(srcip) as net
FROM ( from logs.json | ? example.com AND urgent )
WHERE message_length > 100
GROUP BY net
"
Or write this as a pure pipe query using SuperSQL shortcuts
super -c "
from logs.json
| ? example.com AND urgent
| message_length > 100
| kinds:=union(type), net:=network_of(srcip) by net
"
Installation
Installation
TODO: upon release, update this first paragraph.
Because SuperDB is still under construction, GA releases are not yet available.
However, you can install a build of the super
command-line tool based on code that’s under active development to start
tinkering.
Multiple options for installing super are available:
- Homebrew for Mac or Linux,
- Build from source.
To install the SuperDB Python client, see the Python library documentation.
Homebrew
On macOS and Linux, you can use Homebrew to install super:
brew install --cask brimdata/tap/super
Once installed, run a quick test.
Building From Source
Tip
If you don’t have Go installed, download and install it from the Go install page. Go 1.24 or later is required.
With Go installed, you can easily build super from source:
go install github.com/brimdata/super/cmd/super@main
This installs the super binary in your $GOPATH/bin.
Try It
Once installed, run a quick test.
Hello World
Hello World
To test out the installed super binary, try running Hello World!
First, here is a Unix-y version. Copy this to your shell and run it:
echo '"hello, world"' | super -
You should get:
"hello, world"
In this simple case,
there is no query argument specified for super (i.e., no -c argument), which causes
super to presume an implied from operator.
This from operator scans each of the command-line arguments
interpreted as file paths or URLs (or - for standard input).
In this case, the input is read from the implied operator, no further query
is applied, and the results are emitted to standard output.
This results is the string value "hello, world",
serialized in the default SUP format,
which is simply the string literal itself.
A SQL version of Hello World is:
super -c "SELECT 'hello, world' as Message"
which outputs
{Message:"hello, world"}
This is single row in a table with one column called Message of type string.
SuperDB Database
The top-level super command runs without any underlying persistent database,
but you can also run Hello World with a database.
To create a database and populate it with data, run the following commands:
export SUPER_DB=./scratch
super db init
super db create Demo
echo '{Message:"hello, world"}' | super db load -use Demo -
Now you have a database with a data pool called “Demo” and some data in it. Query this data as follow:
super db -c "from Demo"
and you should see
{Message:"hello, world"}
SuperDB Service
Now that you have a database in the ./scratch directory, you could also
run Hello World as a client talking to a SuperDB server instance.
Continuing the example above (with the SUPER_DB environment pointing to ./scratch),
run a service as follows:
super db serve
This command will block and output logging information to standard output.
In another window (without SUPER_DB defined), run super as a client talking
to the service input as follows:
super db -c "from Demo"
Playground
Playground
If you have super installed, a common pattern for experimentation is to
“echo” some input to the super -c command, e.g.,
echo <values> | super -c <query> -
But you can also experiment with SuperDB using the browser-embedded
playground. The super binary has been
compiled into Web assembly
and executes a super -c <query> - command like this:
# spq
SELECT upper(message) AS out
# input
{id:0,message:"Hello"}
{id:1,message:"Goodbye"}
# expected output
{out:"HELLO"}
{out:"GOODBYE"}
The QUERY and INPUT panes are editable. So go ahead and experiment.
Try changing upper to lower in the query text
and you should get this alternative output in the RESULT panel above:
{out:"hello"}
{out:"goodbye"}
The input in the playground examples are generally formatted as
SUP but the super playground command autodetects
the format, so feel free to experiment with other text formats like CSV or JSON.
For example, if you change the input above to
id,message
0,"Hello"
1,"Goodbye"
super will detect this as CSV and you will get the same result.
Examples
To explore a broad range of SuperSQL functionality, try browsing the documentation for pipe operators or functions. Each operator and function has a section of examples with playgrounds where you can edit the example queries and inputs to explore how SuperSQL works. The tutorials section also has many playground examples.
Here are a few examples to get going.
Hello, world
super -s -c "SELECT 'hello, world' as s"
produces this SUP output
{s:"hello, world"}
Some values of available data types
# spq
SELECT in as out
# input
{in:1}
{in:1.5}
{in:[1,"foo"]}
{in:|["apple","banana"]|}
# expected output
{out:1}
{out:1.5}
{out:[1,"foo"]}
{out:|["apple","banana"]|}
The types of various data
# spq
SELECT typeof(in) as typ
# input
{in:1}
{in:1.5}
{in:[1,"foo"]}
{in:|["apple","banana"]|}
# expected output
{typ:<int64>}
{typ:<float64>}
{typ:<[int64|string]>}
{typ:<|[string]|>}
A simple aggregation
# spq
sum(val) by key | sort key
# input
{key:"foo",val:1}
{key:"bar",val:2}
{key:"foo",val:3}
# expected output
{key:"bar",sum:2}
{key:"foo",sum:4}
Read CSV input and cast a to an integer from default float
# spq
a:=a::int64
# input
a,b
1,foo
2,bar
# expected output
{a:1,b:"foo"}
{a:2,b:"bar"}
Read JSON input and cast to an integer from default float
# spq
a:=a::int64
# input
{"a":1,"b":"foo"}
{"a":2,"b":"bar"}
# expected output
{a:1,b:"foo"}
{a:2,b:"bar"}
Make a schema-rigid Parquet file using fuse, then output the Parquet file as SUP
echo '{a:1}{a:2}{b:3}' | super -f parquet -o tmp.parquet -c fuse -
super -s tmp.parquet
produces
{a:1,b:null::int64}
{a:2,b:null::int64}
{a:null::int64,b:3}
Command
super — invoke or manage SuperDB
Synopsis
super [ -c query ] [ options ] [ file ... ]
super [ options ] <sub-command> ...
Sub-commands
Options
TODO: link these short-hand flag descriptions to longer form descriptions
- Output Options
-aggmemmaximum memory used per aggregate function value in MiB, MB, etc-cSuperSQL query to execute-csv.delimCSV field delimiter-estop upon input errors-fusememmaximum memory used by fuse in MiB, MB, etc-hdisplay help-helpdisplay help-hiddenshow hidden options-iformat of input data-Isource file containing query text-qdon’t display warnings-sortmemmaximum memory used by sort in MiB, MB, etc-statsdisplay search stats on stderr-versionprint version and exit
Description
super is the command-line tool for interacting with and managing SuperDB
and is organized as a hierarchy of sub-commands similar to
docker
or kubectl.
For built-in command help and a listing of all available options,
simply run super without any arguments.
When invoked at the top level without a sub-command, super executes the
SuperDB query engine detached from the database storage layer
where the data inputs may be files, HTTP APIs, S3 cloud objects, or standard input.
Optional SuperSQL query text may be provided with
the -c argument. If no query is provided, the inputs are scanned
and output is produced in accordance with -f to specify a serialization format
and -o to specified an optional output (file or directory).
The query text may originate in files using one or more -I arguments.
In this case, these source files are concatenated together in order and prepended
to any -c query text. -I may be used without -c.
When invoked using the db sub-command, super interacts with
an underlying SuperDB database.
The dev sub-command provides dev tooling for the advanced users or developers of SuperDB while the compile command allows detailed interactions with various stages of the query compiler.
Supported Formats
| Option | Auto | Extension | Specification |
|---|---|---|---|
arrows | yes | .arrows | Arrow IPC Stream Format |
bsup | yes | .bsup | BSUP |
csup | yes | .csup | CSUP |
csv | yes | .csv | Comma-Separated Values (RFC 4180) |
json | yes | .json | JSON (RFC 8259) |
jsup | yes | .jsup | Super over JSON (JSUP) |
line | no | n/a | One text value per line |
parquet | yes | .parquet | Apache Parquet |
sup | yes | .sup | SUP |
tsv | yes | .tsv | Tab-Separated Values |
zeek | yes | .zeek | Zeek Logs |
Note
Best performance is achieved when operating on data in binary columnar formats such as CSUP, Parquet, or Arrow.
Input
When run detached from a database, super executes a query over inputs
external to the database including
- file system paths,
- standard input, or
- HTTP, HTTPS, or S3 URLs.
These inputs may be specified with the operator within the query text or via the file arguments (including stdin) to the command.
Command-line paths are treated as if a from operator precedes the provided query, e.g.,
super -c "FROM example.json | SELECT a,b,c"
is equivalent to
super -c "SELECT a,b,c" example.json
and both are equivalent to the classic SQL
super -c "SELECT a,b,c FROM example.json"
When multiple input files are specified, they are processed in the order given as
if the data were provided by a single, concatenated FROM clause.
If no input is specified,
the query is fed a single null value analogous to SQL’s default
input of a single empty row of an unnamed table. This provides a convenient means
to run standalone examples or compute results like a calculator, e.g.,
super -s -c '1+1'
is shorthand
for values 1+1 and emits
2
Format Detection
In general, super just works when it comes to automatically inferring
the data formats of its inputs.
For files with a well known extension (like .json, .parquet, .sup etc.),
the format is implied by the extension.
For standard input or files without a recognizable extension, super attempts
to detect the format by reading and parsing some of the data.
To override these format inference heuristics, -i may be used to specify
the input formats of command-line files or the (format) option of a data source
specified in a from operator.
When -i is used, all of the input files must have the same format.
Without -i, each file format is determined independently so you can
mix and match input formats.
For example, suppose this content is in a file sample.csv:
a,b
1,foo
2,bar
and this content is in sample.json
{"a":3,"b":"baz"}
then the command
super -s sample.csv sample.json
would produce this output in the default SUP format
{a:1.,b:"foo"}
{a:2.,b:"bar"}
{a:3,b:"baz"}
Note that the line format cannot be automatically detected and
requires -i or (format line) for reading.
TODO: Parquet and CSUP require a seekable input and cannot be operated upon when read on standard input. It seems like this should change given the pipe-able nature of super and the desire to make CSUP be the default output to a non-terminal output.
Output
TODO: make CSUP not BSUP the default output format when not a terminal.
Output is written to standard output by default or, if -o is specified,
to the indicated file or directory.
When writing to stdout and stdout is a terminal, the default
output format is SUP.
Otherwise, the default format is CSUP.
These defaults may be overridden with -f, -s, or -S.
Since SUP is a common format choice for interactive use,
the -s flag is shorthand for -f sup.
Also, -S is a shortcut for -f sup with -pretty 2 as
described below.
And since plain JSON is another common format choice, the -j flag
is a shortcut for -f json and -J is a shortcut for pretty-printing JSON.
Note
Having the default output format dependent on the terminal status causes an occasional surprise (e.g., forgetting
-for-sin a scripted test that works fine on the command line but fails in CI), this avoids problematic performance where a data pipeline deployed to product accidentally uses SUP instead of CSUP. Sincesupergracefully handles any input, this would be hard to detect. Alternatively, making CSUP the default would cause much annoyance when binary data is written to the terminal.
If no query is specified with -c, the inputs are scanned without modification
and output in the specified format
providing a convenient means to convert files from one format to another, e.g.,
super -f arrows -o out.arrows file1.json file2.parquet file3.csv
Pretty Printing
SUP and plain JSON text may be “pretty printed” with the -pretty option, which takes
the number of spaces to use for indentation. As this is a common option,
the -S option is a shortcut for -f sup -pretty 2 and -J is a shortcut
for -f json -pretty 2.
For example,
echo '{a:{b:1,c:[1,2]},d:"foo"}' | super -S -
produces
{
a: {
b: 1,
c: [
1,
2
]
},
d: "foo"
}
and
echo '{a:{b:1,c:[1,2]},d:"foo"}' | super -f sup -pretty 4 -
produces
{
a: {
b: 1,
c: [
1,
2
]
},
d: "foo"
}
When pretty printing, colorization is enabled by default when writing to a terminal,
and can be disabled with -color false.
Pipeline-friendly Formats
Though it’s a compressed format, CSUP and BSUP data is self-describing and stream-oriented and thus is pipeline friendly.
Since data is self-describing you can simply take super-structured output of one command and pipe it to the input of another. It doesn’t matter if the value sequence is scalars, complex types, or records. There is no need to declare or register schemas or “protos” with the downstream entities.
In particular, super-structured data can simply be concatenated together, e.g.,
super -f bsup -c 'values 1, [1,2,3]' > a.bsup
super -f bsup -c "values {s:'hello'}, {s:'world'}" > b.bsup
cat a.bsup b.bsup | super -s -
produces
1
[1,2,3]
{s:"hello"}
{s:"world"}
Schema-rigid Outputs
Certain data formats like Arrow and Parquet are schema rigid in the sense that they require a schema to be defined before values can be written into the file and all the values in the file must conform to this schema.
SuperDB, however, has a fine-grained type system instead of schemas such that a sequence of data values is completely self-describing and may be heterogeneous in nature. This creates a challenge converting the type-flexible super-structured data formats to a schema-rigid format like Arrow and Parquet.
For example, this seemingly simple conversion:
echo '{x:1}{s:"hello"}' | super -o out.parquet -f parquet -
causes this error
parquetio: encountered multiple types (consider 'fuse'): {x:int64} and {s:string}
To write heterogeneous data to a schema-based file format, you must
convert the data to a monolithic type. To handle this,
you can either fuse
the data into a single fused type or you can specify
the -split flag to indicate a destination directory that receives
a separate output file for each output type.
Fused Data
The fuse operator uses type fusion to merge different record types into a blended type, e.g.,
echo '{x:1}{s:"hello"}' | super -o out.parquet -f parquet -c fuse -
super -s out.parquet
which produces
{x:1,s:null::string}
{x:null::int64,s:"hello"}
The downside of this approach is that the data muts be changed (by inserting nulls) to conform to a single type.
Also, data fusion can sometimes involve sum types that are not representable in a format like Parquet. While a bit cumbersome, you could write a query that adjusts the output be renaming columns so that heterogenous data column types are avoided. This modified data could then be fused without sum types and output to Parquet.
Splitting Schemas
An alternative approach to the schema-rigid limitation of Arrow and Parquet is to create a separate file for each schema.
super can do this too with its -split option, which specifies a path
to a directory for the output files. If the path is ., then files
are written to the current directory.
The files are named using the -o option as a prefix and the suffix is
-<n>.<ext> where the <ext> is determined from the output format and
where <n> is a unique integer for each distinct output file.
For example, the example above would produce two output files, which can then be read separately to reproduce the original data, e.g.,
echo '{x:1}{s:"hello"}' | super -o out -split . -f parquet -
super -s out-*.parquet
produces the original data
{x:1}
{s:"hello"}
While the -split option is most useful for schema-rigid formats, it can
be used with any output format.
SuperDB Database Metadata Output
TODO: We should get rid of this. Or document it as an internal format. It’s not a format that people should rely upon.
The db format is used to pretty-print lake metadata, such as in
super db sub-command outputs. Because it’s super db’s default output format,
it’s rare to request it explicitly via -f. However, since it’s possible for
super db to generate output in any supported format,
the db format is useful to reverse this.
For example, imagine you’d executed a meta-query via
super db query -S "from :pools" and saved the output in this file pools.sup.
{
ts: 2024-07-19T19:28:22.893089Z,
name: "MyPool",
id: 0x132870564f00de22d252b3438c656691c87842c2::=ksuid.KSUID,
layout: {
order: "desc"::=order.Which,
keys: [
[
"ts"
]::=field.Path
]::=field.List
}::=order.SortKey,
seek_stride: 65536,
threshold: 524288000
}::=pools.Config
Using super -f db, this can be rendered in the same pretty-printed form as it
would have originally appeared in the output of super db ls, e.g.,
super -f db pools.sup
produces
MyPool 2jTi7n3sfiU7qTgPTAE1nwTUJ0M key ts order desc
Line Format
The line format is convenient for interacting with other Unix-style tooling that
produces text input and output a line at a time.
When -i line is specified as the input format, data is read a line as a
string type.
When -f line is specified as the output format, each value is formatted
a line at a time. String values are printed as is with otherwise escaped
values formatted as their native character in the output, e.g.,
| Escape Sequence | Rendered As |
|---|---|
\n | Newline |
\t | Horizontal tab |
\\ | Backslash |
\" | Double quote |
\r | Carriage return |
\b | Backspace |
\f | Form feed |
\u | Unicode escape (e.g., \u0041 for A) |
Non-string values are formatted as SUP.
For example:
echo '"hi" "hello\nworld" { time_elapsed: 86400s }' | super -f line -
produces
hi
hello
world
{time_elapsed:1d}
Because embedded newlines create multi-lined output with -i line, this mode can
alter the sequence of values, e.g.,
super -c "values 'foo\nbar' | count()"
results in 1 but
super -f line -c "values 'foo\nbar'" | super -i line -c "count()" -
results in 2.
Debugging
TODO: this belongs in the super-sql section. We can link to it.
If you are ever stumped about how the super compiler is parsing your query,
you can always run super -C to compile and display your query in canonical form
without running it.
This can be especially handy when you are learning the language and its
shortcuts.
For example, this query
super -C -c 'has(foo)'
is an implied where operator, which matches values
that have a field foo, i.e.,
where has(foo)
while this query
super -C -c 'a:=x+1'
is an implied put operator, which creates a new field a
with the value x+1, i.e.,
put a:=x+1
Errors
TODO: this belongs in the super-sql section. We can link to it. TODO: document compile-time errors and reference type checking.
Fatal errors like “file not found” or “file system full” are reported
as soon as they happen and cause the super process to exit.
On the other hand,
runtime errors resulting from the query itself
do not halt execution. Instead, these error conditions produce
first-class errors
in the data output stream interleaved with any valid results.
Such errors are easily queried with the
is_error function.
This approach provides a robust technique for debugging complex queries, where errors can be wrapped in one another providing stack-trace-like debugging output alongside the output data. This approach has emerged as a more powerful alternative to the traditional technique of looking through logs for errors or trying to debug a halted query with a vague error message.
For example, this query
echo '1 2 0 3' | super -s -c '10.0/this' -
produces
10.
5.
error("divide by zero")
3.3333333333333335
and
echo '1 2 0 3' | super -c '10.0/this' - | super -s -c 'is_error(this)' -
produces just
error("divide by zero")
compile
Command
compile — compile a SuperSQL query for inspection and debugging
Synopsis
super compile [ options ] query
Options
-Cdisplay DAG or AST as query text (default “false”)-dagdisplay output as DAG (implied by -O or -P) (default “false”)-filescompile query as if command-line input files are present) (default “false”)-Isource file containing query text (may be repeated)-Odisplay optimized DAG (default “false”)-Pdisplay parallelized DAG (default “0”)
Additional options of the super top-level command
Description
This command parses a SuperSQL query
and emits the resulting abstract syntax tree (AST) or
runtime directed acyclic graph (DAG) in the output
format desired. Use -dag to specify the DAG form; otherwise, the
AST form is assumed.
The -C option causes the output to be shown as query language
source instead of the AST. This is particularly helpful to
see how SQP queries in their abbreviated form are translated
into the exanded, pedantic form of piped SQL. The DAG can
also be formatted as query-style text but the resulting text is
informational only and does not conform to any query syntax. When
-C is specified, the result is sent to stdout and the -f and
-o options have no effect.
This command is often used for dev and test but is also useful to advanced users for understanding how SuperSQL syntax is parsed into an AST or compiled into a runtime DAG.
db
Sub-command
db — invoke SuperDB on a super-structured database
Synopsis
super [ options ] db [ options ] -c <query>
super [ options ] db <sub-command> ...
Sub-commands
- auth
- branch
- compact
- create
- delete
- drop
- init
- load
- log
- ls
- manage
- merge
- query TODO: ref this doc
- rename
- revert
- serve
- use
- vacate
- vacuum
- vector
Options
TODO
Description
super db is a sub-command of super to manage and query SuperDB databases.
You can import data from a variety of formats and it will automatically be committed in super-structured format, providing full fidelity of the original format and the ability to reconstruct the original data without loss of information.
A SuperDB database offers an easy-to-use substrate for data discovery, preparation, and transformation as well as serving as a queryable and searchable store for super-structured data both for online and archive use cases.
While super db is itself a sub-command of super, it invokes
a large number of interrelated sub-commands, similar to the
docker
or kubectl
commands.
The following sections describe each of the available commands and highlight some key options. Built-in help shows the commands and their options:
super db -hwith no args displays a list ofsuper dbcommands.super db command -h, wherecommandis a sub-command, displays help for that sub-command.super db command sub-command -hdisplays help for a sub-command of a sub-command and so forth.
By default, commands that display lake metadata (e.g., log or
ls) use a text format. However, the -f option can be used
to specify any supported output format.
Database Connection
TODO: document database location
Commitish
TODO: document this somewhere maybe not here
Sort Key
auth
Command
auth — connect to a database and authenticate
Synopsis
super db auth login|logout|method|verify
Options
TODO
Additional options of the db sub-command
Description
TODO: rename this command. it’s really about connecting to a database. authenticating is something you do to connect.
branch
Command
branch — create a new branch on a pool
Synopsis
super db branch [options] [name]
Options
TODO
Additional options of the db sub-command
Description
The branch command creates a branch with the name name that points
to the tip of the working branch or, if the name argument is not provided,
lists the existing branches of the selected pool.
For example, this branch command
super db branch -use logs@main staging
creates a new branch called “staging” in pool “logs”, which points to the same commit object as the “main” branch. Once created, commits to the “staging” branch will be added to the commit history without affecting the “main” branch and each branch can be queried independently at any time.
Supposing the main branch of logs was already the working branch,
then you could create the new branch called “staging” by simply saying
super db branch staging
Likewise, you can delete a branch with -d:
super db branch -d staging
and list the branches as follows:
super db branch
compact
create
Command
create — create a new pool in a database
Synopsis
super db create [-orderby key[,key...][:asc|:desc]] <name>
Options
TODO
Additional options of the db sub-command
Description
The create command creates a new data pool with the given name,
which may be any valid UTF-8 string.
The -orderby option indicates the sort key that is used to sort
the data in the pool, which may be in ascending or descending order.
If a sort key is not specified, then it defaults to
the special value this.
TODO: if we have no sort key, then there should be no sort key
A newly created pool is initialized with a branch called main.
Pools can be used without thinking about branches. When referencing a pool without a branch, the tooling presumes the “main” branch as the default, and everything can be done on main without having to think about branching.
delete
Command
delete — delete data from a pool
Synopsis
super db delete [options] <id> [<id>...]
super db delete [options] -where <filter>
Options
TODO
Additional options of the db sub-command
Description
The delete command removes one or more data objects indicated by their ID from a pool.
This command
simply removes the data from the branch without actually deleting the
underlying data objects thereby allowing time travel to work in the face
of deletes. Permanent deletion of underlying data objects is handled by the
separate vacuum command.
If the -where flag is specified, delete will remove all values for which the
provided filter expression is true. The value provided to -where must be a
single filter expression, e.g.:
super db delete -where 'ts > 2022-10-05T17:20:00Z and ts < 2022-10-05T17:21:00Z'
drop
Command
drop — remove a pool from a database
Synopsis
super db drop [options] <name>|<id>
Options
TODO
Additional options of the db sub-command
Description
The drop command deletes a pool and all of its constituent data.
As this is a DANGER ZONE command, you must confirm that you want to delete
the pool to proceed. The -f option can be used to force the deletion
without confirmation.
init
Command
init — create and initialize a new database
Synopsis
super db init [path]
Options
TODO
Additional options of the db sub-command
Description
A new database is created and initialized with the init command. The path argument
is a storage path
and is optional. If not present, the path
is determined automatically.
If the database already exists, init reports an error and does nothing.
Otherwise, the init command writes the initial cloud objects to the
storage path to create a new, empty database at the specified path.
load
Command
load — load data into database
Synopsis
super db load [options] input [input ...]
Options
TODO
Additional options of the db sub-command
Description
The load command commits new data to a branch of a pool.
Run super db load -h for a list of command-line options.
Note that there is no need to define a schema or insert data into a “table” as all super-structured data is self describing and can be queried in a schema-agnostic fashion. Data of any shape can be stored in any pool and arbitrary data shapes can coexist side by side.
As with super,
the input arguments can be in
any supported format and
the input format is auto-detected if -i is not provided. Likewise,
the inputs may be URLs, in which case, the load command streams
the data from a Web server or S3
and into the database.
When data is loaded, it is broken up into objects of a target size determined
by the pool’s threshold parameter (which defaults to 500MiB but can be configured
when the pool is created). Each object is sorted by the sort key but
a sequence of objects is not guaranteed to be globally sorted. When lots
of small or unsorted commits occur, data can be fragmented. The performance
impact of fragmentation can be eliminated by regularly compacting
pools.
For example, this command
super db load sample1.json sample2.bsup sample3.sup
loads files of varying formats in a single commit to the working branch.
An alternative branch may be specified with a branch reference with the
-use option, i.e., <pool>@<branch>. Supposing a branch
called live existed, data can be committed into this branch as follows:
super db load -use logs@live sample.bsup
Or, as mentioned above, you can set the default branch for the load command
via use:
super db use logs@live
super db load sample.bsup
During a load operation, a commit is broken out into units called data objects
where a target object size is configured into the pool,
typically 100MB-1GB. The records within each object are sorted by the sort key.
A data object is presumed by the implementation
to fit into the memory of an intake worker node
so that such a sort can be trivially accomplished.
Data added to a pool can arrive in any order with respect to its sort key. While each object is sorted before it is written, the collection of objects is generally not sorted.
Each load operation creates a single commit, which includes:
- an author and message string,
- a timestamp computed by the server, and
- an optional metadata field of any type expressed as a Super (SUP) value. This data has the type signature:
{
author: string,
date: time,
message: string,
meta: <any>
}
where <any> is the type of any optionally attached metadata .
For example, this command sets the author and message fields:
super db load -user user@example.com -message "new version of prod dataset" ...
If these fields are not specified, then the system will fill them in with the user obtained from the session and a message that is descriptive of the action.
The date field here is used by the database for
time travel
through the branch and pool history, allowing you to see the state of
branches at any time in their commit history.
Arbitrary metadata expressed as any SUP value
may be attached to a commit via the -meta flag. This allows an application
or user to transactionally commit metadata alongside committed data for any
purpose. This approach allows external applications to implement arbitrary
data provenance and audit capabilities by embedding custom metadata in the
commit history.
Since commit objects are stored as super-structured data, the metadata can easily be
queried by running the log -f bsup to retrieve the log in BSUP format,
for example, and using super to pull the metadata out
as in:
super db log -f bsup | super -c 'has(meta) | values {id,meta}' -
log
Command
log — display the commit log
Synopsis
super db log [options] [commitish]
Options
Additional options of the db sub-command
Description
The log command, like git log, displays a history of the
commits
starting from any commit, expressed as a commitish. If no argument is
given, the tip of the working branch is used.
Run super db log -h for a list of command-line options.
To understand the log contents, the load operation is actually
decomposed into two steps under the covers:
an “add” step stores one or more
new immutable data objects in the lake and a “commit” step
materializes the objects into a branch with an ACID transaction.
This updates the branch pointer to point at a new commit object
referencing the data objects where the new commit object’s parent
points at the branch’s previous commit object, thus forming a path
through the object tree.
The log command prints the commit ID of each commit object in that path
from the current pointer back through history to the first commit object.
A commit object includes
an optional author and message, along with a required timestamp,
that is stored in the commit journal for reference. These values may
be specified as options to the load command, and are also available in the
database API for automation.
Note
The branchlog meta-query source is not yet implemented.
ls
Command
ls — list the pools in a database
Synopsis
super db ls [options] [pool]
Options
Additional options of the db sub-command
Description
The ls command lists pools in a database or branches in a pool.
By default, all pools in the database are listed along with each pool’s unique ID and sort key.
If a pool name or pool ID is given, then the pool’s branches are listed along with the ID of their commit object, which points at the tip of each branch.
manage
Command
manage — run regular maintenance on a database
Synopsis
super db manage [options]
Options
Additional options of the db sub-command
Description
The manage command performs maintenance tasks on a database.
Currently the only supported task is compaction, which reduces fragmentation by reading data objects in a pool and writing their contents back to large, non-overlapping objects.
If the -monitor option is specified and the database is
configured
via network connection, super db manage will run continuously and perform updates
as needed. By default a check is performed once per minute to determine if
updates are necessary. The -interval option may be used to specify an
alternate check frequency as a duration.
If -monitor is not specified, a single maintenance pass is performed on the
database.
By default, maintenance tasks are performed on all pools in the database. The
-pool option may be specified one or more times to limit maintenance tasks
to a subset of pools listed by name.
The output from manage provides a per-pool summary of the maintenance
performed, including a count of objects_compacted.
As an alternative to running manage as a separate command, the -manage
option is also available on the serve command to have maintenance
tasks run at the specified interval by the service process.
merge
Command
merge — merged data from one branch to another
Synopsis
super db merge -use logs@updates <branch>
Options
Additional options of the db sub-command
Description
Data is merged from one branch into another with the merge command, e.g.,
super db merge -use logs@updates main
where the updates branch is being merged into the main branch
within the logs pool.
A merge operation finds a common ancestor in the commit history then computes the set of changes needed for the target branch to reflect the data additions and deletions in the source branch. While the merge operation is performed, data can still be written concurrently to both branches and queries performed and everything remains transactionally consistent. Newly written data remains in the branch while all of the data present at merge initiation is merged into the parent.
This Git-like behavior for a data lake provides a clean solution to
the live ingest problem.
For example, data can be continuously ingested into a branch of main called live
and orchestration logic can periodically merge updates from branch live to
branch main, possibly compacting data after the merge
according to configured policies and logic.
query
Command
query — query a database
Synopsis
super db query [options] <query>
Options
TODO: unify this with super command flags.
-aggmem maximum memory used per aggregate function value in MiB, MB, etc
-B allow Super Binary to be sent to a terminal output
-bsup.compress compress Super Binary frames
-bsup.framethresh minimum Super Binary frame size in uncompressed bytes
-color enable/disable color formatting for -S and lake text output
-f format for output data [arrows,bsup,csup,csv,json,lake,line,parquet,sup,table,text,tsv,zeek,zjson]
-fusemem maximum memory used by fuse in MiB, MB, etc
-I source file containing Zed query text
-J use formatted JSON output independent of -f option
-j use line-oriented JSON output independent of -f option
-o write data to output file
-persist regular expression to persist type definitions across the stream
-pretty tab size to pretty print JSON and Super JSON output
-S use formatted Super JSON output independent of -f option
-s use line-oriented Super JSON output independent of -f option
-sortmem maximum memory used by sort in MiB, MB, etc
-split split output into one file per data type in this directory
-splitsize if >0 and -split is set, split into files at least this big rather than by data type
-stats display search stats on stderr
-unbuffered disable output buffering
Additional options of the db sub-command
Description
The query command runs a SuperSQL query with data from a lake as input.
A query typically begins with a from operator
indicating the pool and branch to use as input.
The pool/branch names are specified with from in the query.
As with super, the default output format is SUP for
terminals and BSUP otherwise, though this can be overridden with
-f to specify one of the various supported output formats.
If a pool name is provided to from without a branch name, then branch
“main” is assumed.
This example reads every record from the full key range of the logs pool
and sends the results to stdout.
super db query 'from logs'
We can narrow the span of the query by specifying a filter on the database sort key:
super db query 'from logs | ts >= 2018-03-24T17:36:30.090766Z and ts <= 2018-03-24T17:36:30.090758Z'
Filters on sort keys are efficiently implemented as the data is laid out according to the sort key and seek indexes keyed by the sort key are computed for each data object.
When querying data to the BSUP output format,
output from a pool can be easily piped to other commands like super, e.g.,
super db query -f bsup 'from logs' | super -f table -c 'count() by field' -
Of course, it’s even more efficient to run the query inside of the pool traversal like this:
super db query -f table 'from logs | count() by field'
By default, the query command scans pool data in sort-key order though
the query optimizer may, in general, reorder the scan to optimize searches,
aggregations, and joins.
An order hint can be supplied to the query command to indicate to
the optimizer the desired processing order, but in general,
the sort operator
should be used to guarantee any particular sort order.
Arbitrarily complex queries can be executed over the lake in this fashion and the planner can utilize cloud resources to parallelize and scale the query over many parallel workers that simultaneously access the lake data in shared cloud storage (while also accessing locally- or cluster-cached copies of data).
Meta-queries
Commit history, metadata about data objects, database and pool configuration, etc. can all be queried and returned as super-structured data, which in turn, can be fed into analytics. This allows a very powerful approach to introspecting the structure of a lake making it easy to measure, tune, and adjust lake parameters to optimize layout for performance.
These structures are introspected using meta-queries that simply
specify a metadata source using an extended syntax in the from operator.
There are three types of meta-queries:
from :<meta>- lake levelfrom pool:<meta>- pool levelfrom pool[@<branch>]<:meta>- branch level
<meta> is the name of the metadata being queried. The available metadata
sources vary based on level.
For example, a list of pools with configuration data can be obtained in the SUP format as follows:
super db query -S "from :pools"
This meta-query produces a list of branches in a pool called logs:
super db query -S "from logs:branches"
You can filter the results just like any query, e.g., to look for particular branch:
super db query -S "from logs:branches | branch.name=='main'"
This meta-query produces a list of the data objects in the live branch
of pool logs:
super db query -S "from logs@live:objects"
You can also pretty-print in human-readable form most of the metadata records using the “lake” format, e.g.,
super db query -f lake "from logs@live:objects"
The main branch is queried by default if an explicit branch is not specified,
e.g.,
super db query -f lake "from logs:objects"
rename
Command
rename — rename a database pool
Synopsis
super db rename <existing> <new-name>
Options
Additional options of the db sub-command
Description
The rename command assigns a new name <new-name> to an existing
pool <existing>, which may be referenced by its ID or its previous name.
revert
use
Command
use — set working branch for db commands
Synopsis
super db use [ <commitish> ]
Options
-usespecify commit to use, i.e., pool, pool@branch, or pool@commit
Description
The use command sets the working branch to the indicated commitish.
When run with no argument, it displays the working branch and
database connection.
For example,
super db use logs
provides a “pool-only” commitish that sets the working branch to logs@main.
If a @branch or commit ID are given without a pool prefix, then the pool of
the commitish previously in use is presumed. For example, if you are on
logs@main then run this command:
super db use @test
then the working branch is set to logs@test.
To specify a branch in another pool, simply prepend the pool name to the desired branch:
super db use otherpool@otherbranch
This command stores the working branch in $HOME/.super_head.
vacate
vacuum
Command
vacuum — vacuum deleted storage in database
Synopsis
super db vacuum [ options ]
Options
-dryrunrun vacuum without deleting anything-fdo not prompt for confirmation-usespecify commit to use, i.e., pool, pool@branch, or pool@commit
Description
The vacuum command permanently removes underlying data objects that have
previously been subject to a delete operation. As this is a
DANGER ZONE command, you must confirm that you want to remove
the objects to proceed. The -f option can be used to force removal
without confirmation. The -dryrun option may also be used to see a summary
of how many objects would be removed by a vacuum but without removing them.
vector
serve
Command
serve — run a SuperDB service endpoint
Synopsis
super db serve [options]
Options
-auth.audienceAuth0 audience for API clients (will be publicly accessible)-auth.clientidAuth0 client ID for API clients (will be publicly accessible)-auth.domainAuth0 domain (as a URL) for API clients (will be publicly accessible)-auth.enabledenable authentication checks-auth.jwkspathpath to JSON Web Key Set file-cors.originCORS allowed origin (may be repeated)-defaultfmtdefault response format (default “sup”)-l [addr]:portto listen on (default “:9867”)-log.devmodedevelopment mode (if enabled dpanic level logs will cause a panic)-log.filemodlogger file write mode (values: append, truncate, rotate)-log.levellogging level-log.pathpath to send logs (values: stderr, stdout, path in file system)-managewhen positive, run lake maintenance tasks at this interval-rootcontentfilefile to serve for GET /
Additional options of the db sub-command
Description
TODO: get rid of personality metaphor?
The serve command implements the
server personality to service requests
from instances of the client personality.
It listens for API requests on the interface and port
specified by the -l option, executes the requests, and returns results.
The -log.level option controls log verbosity. Available levels, ordered
from most to least verbose, are debug, info (the default), warn,
error, dpanic, panic, and fatal. If the volume of logging output at
the default info level seems too excessive for production use, warn level
is recommended.
The -manage option enables the running of the same maintenance tasks
normally performed via the separate manage command.
dev
dev
The super dev command contains various subcommands for developers.
Run super dev with no arguments to see what is available.
As the dev tooling becomes stable, these tools will eventually all be documented here.
Options
Output Options
Output Options
-Ballow Super Binary to be sent to a terminal output-bsup.compresscompress Super Binary frames-bsup.framethreshminimum Super Binary frame size in uncompressed bytes (default “524288”)-bsup.readmaxmaximum Super Binary read buffer size in MiB, MB, etc.-bsup.readsizetarget Super Binary read buffer size in MiB, MB, etc.-bsup.threadsnumber of Super Binary read threads-bsup.validatevalidate format when reading Super Binary-colorenable/disable color formatting for -S and db text output-csv.delimCSV field delimiter-fformat for output data-Jshortcut for-f json -pretty, i.e., multi-line JSON-jshortcut for-f json -pretty=0, i.e., line-oriented JSON-owrite data to output file-persistregular expression to persist type definitions across the stream-prettytab size to pretty print JSON and Super JSON output-Sshortcut for-f sup -pretty, i.e., multi-line SUP-sshortcut for-f sup -pretty=0, i.e., line-oriented SUP-split splitoutput into one file per data type in this directory-splitsizeif >0 and -split is set, split into files at least this big rather than by data type-unbuffereddisable output buffering
SuperSQL
SuperSQL is a Pipe SQL adapted for super-structured data. The language is a superset of SQL query syntax and includes a modern type system with sum types to represent heterogeneous data.
Similar to a Unix pipeline, a SuperSQL query is expressed as a data source followed by a number of operators that manipulate the data:
from source | operator | operator | ...
As with SQL, SuperSQL is declarative and the SuperSQL compiler often optimizes a query into an implementation different from the dataflow implied by the pipeline to achieve the same semantics with better performance.
While SuperSQL at its core is a pipe-oriented language, it is also backward compatible with relational SQL in that any arbitrarily complex SQL query may appear as a single pipe operator anywhere in a SuperSQL pipe query.
In other words, a single pipe operator that happens to be a standalone SQL query is also a SuperSQL pipe query. For example, these are all valid SuperSQL queries:
SELECT 'hello, world'
SELECT * FROM table
SELECT * FROM f1.json JOIN f2.json ON f1.id=f2.id
SELECT watchers FROM https://api.github.com/repos/brimdata/super
Interactive UX
To support an interactive pattern of usage, SuperSQL includes search syntax reminiscent of Web or email keyword search along with operator shortcuts.
With shortcuts, verbose queries can be typed in a shorthand facilitating rapid data exploration. For example, the query
SELECT count(), key
FROM source
GROUP BY key
can be simplified as from source | count() by key.
With search, all of the string fields in a value can easily be searched for patterns, e.g., this query
from source
| ? example.com urgent message_length > 100
searches for the strings “example.com” and “urgent” in all of the string values in
the input and also includes a numeric comparison regarding the field message_length.
Pipe Queries
The entities that transform data within a SuperSQL pipeline are called pipe operators and take super-structured input from the upstream operator or data source, operate upon the input, and produce zero or more super-structured values as output.
Unlike relational SQL, SuperSQL pipe queries define their computation in terms of dataflow through the directed graph of operators. But instead of relational tables propagating from one pipe operator to another (e.g., as in ZetaSQL pipe syntax), any sequence of potentially heterogeneously typed data may flow between SuperSQL pipe operators.
When a super-structured sequence conforms to a single, homogeneous record type, then the data is equivalent to a SQL relation. And because any SQL query is also a valid pipe operator, SuperSQL is thus a superset of SQL. In particular, a single operator defined as pure SQL is an acceptable SuperSQL query so all SQL query texts are also SuperSQL queries.
Unlike a Unix pipeline, a SuperSQL query can be forked and joined, e.g.,
from source
| operator
| fork
( operator | ... )
( operator | ... )
| join on condition
| ...
| switch expr
case value ( operator | ... )
case value ( operator | ... )
default ( operator | ... )
| ...
A query can also include multiple data sources, e.g.,
fork
( from source1 | ... )
( from source2 | ... )
| ...
Pipe Sources
Like SQL, input data for a query is typically sourced with the
from operator.
When from is not present, the file arguments to the
super command are used as input to the query
as if there is an implied
from operator, e.g.,
super -c "op1 | op2 | ..." input.json
is equivalent to
super -c "from input.json | op1 | op2 | ..."
When neither
from nor file arguments are specified, a single null value
is provided as input to the query.
super -c "pass"
results in
null
This pattern provides a simple means to produce a constant input within a
query using the values operator, wherein
values takes as input a single null and produces each constant
expression in turn, e.g.,
super -c "values 1,2,3"
results in
1
2
3
When running on the local file system,
from may refer to a file or an HTTP URL
indicating an API endpoint.
When connected to SuperDB database,
from typically
refers to a collection of super-structured data called a pool and
is referenced using the pool’s name similar to SQL referencing
a relational table by name.
For more detail, see the reference page of the from operator,
but as an example, you might use its
from to fetch data from an
HTTP endpoint and process it with super, in this case, to extract the description
and license of a GitHub repository:
super -f line -c "
from https://api.github.com/repos/brimdata/super
| values description,license.name
"
Relational Scoping
In SQL queries, data from tables is generally referenced with expressions that
specify a table name and a column name within that table,
e.g., referencing a column x in a table T as
SELECT T.x FROM (VALUES (1),(2),(3)) AS T(x)
More commonly, when the column name is unambiguous, the table name can be omitted as in
SELECT x FROM (VALUES (1),(2),(3)) AS T(x)
When SQL queries are nested, joined, or invoked as subqueries, scoping rules define how identifiers and dotted expressions resolve to the different available table names and columns reachable via containing scopes. To support such semantics, SuperSQL implements SQL scoping rules inside of any SQL pipe operator but not between pipe operators.
In other words, table aliases and column references all work within a SQL query written as a single pipe operator but scoping of tables and columns does not reach across pipe operators. Likewise, a pipe query embedded inside of a nested SQL query cannot access tables and columns in the containing SQL scope.
Pipe Scoping
For pipe queries, SuperSQL takes a different approach to scoping called pipe scoping.
Here, a pipe operator takes any sequence of input values
and produces any computed sequence of output values and all
data references are limited to these inputs and outputs.
Since there is just one sequence of values, it may be
referenced as special value with a special name, which for
SuperSQL is the value this.
This scoping model can be summarized as follows:
- all input is referenced as a single value called
this, and - all output is emitted into a single value called
this.
As mentioned above,
when processing a set of homogeneously-typed records,
the data resembles a relational table where the record type resembles a
relational schema and each field in the record models the table’s column.
In other words, the record fields of this can be accessed with the dot operator
reminiscent of a table.column reference in SQL.
For example, the SQL query from above can thus be written in pipe form
using the values operator as:
values {x:1}, {x:2}, {x:3} | select this.x
which results in:
{x:1}
{x:2}
{x:3}
As with SQL table names, where this is implied, it is optional can be omitted, i.e.,
values {x:1}, {x:2}, {x:3} | select x
produces the same result.
Referencing this is often convenient, however, as in this query
values {x:1}, {x:2}, {x:3} | aggregate collect(this)
which collects each input value into an array and emits the array resulting in
[{x:1},{x:2},{x:3}]
Combining Piped Data
If all data for all operators were always presented as a single input sequence
called this, then there would be no way to combine data from different entities,
which is otherwise a hallmark of SQL and the relational model.
To remedy this, SuperSQL extends pipe scoping to
joins and
subqueries
where multiple entities can be combined into the common value this.
Join Scoping
To combine joined entities into this via pipe scoping, the
join operator
includes an as clause that names the two sides of the join, e.g.,
... | join ( from ... ) as {left,right} | ...
Here, the joined values are formed into a new two-field record
whose first field is left and whose second field is right where the
left values come from the parent operator and the right values come
from the parenthesized join query argument.
For example, suppose the contents of a file f1.json is
{"x":1}
{"x":2}
{"x":3}
and f2.json is
{"y":4}
{"y":5}
then a join can bring these two entities together into a common record
which can then be subsequently operated upon, e.g.,
from f1.json
| cross join (from f2.json) as {f1,f2}
computes a cross-product over all the two sides of the join and produces the following output
{f1:{x:1},f2:{y:4}}
{f1:{x:2},f2:{y:4}}
{f1:{x:3},f2:{y:4}}
{f1:{x:1},f2:{y:5}}
{f1:{x:2},f2:{y:5}}
{f1:{x:3},f2:{y:5}}
A downstream operator can then operate on these records,
for example, merging the two sides of the join using
spread operators (...), i.e.,
from f1.json
| cross join (from f2.json) as {f1,f2}
| values {...f1,...f2}
produces
{x:1,y:4}
{x:2,y:4}
{x:3,y:4}
{x:1,y:5}
{x:2,y:5}
{x:3,y:5}
In contrast, relational scoping using identifier scoping in a SELECT clause
with the table source identified in FROM and JOIN clauses, e.g., this query
produces the same result:
SELECT f1.x, f2.y FROM f1.json as f1 CROSS JOIN f2.json as f2
Subquery Scoping
A subquery embedded in an expression can also combine data entities via pipe scoping as in
from f1.json | values {outer:this,inner:[from f2.json | ...]}
Here data from the outer query can be mixed in with data from the
inner array subquery embedded in the expression inside of the
values operator.
The array subquery produces an array value so it is often desirable to
unnest this array with respect to the outer
values as in
from f1.json | unnest {outer:this,inner:[from f2.json | ...]} into ( <scope> )
where <scope> can be an arbitrary pipe query that processes each
collection of unnested values separately as a unit for each outer value.
The into ( <scope> ) body is an optional component of unnest, and if absent,
the unnested collection boundaries are ignored and all of the unnested data is output.
With the unnest operator, we can now consider how a correlated subquery from
SQL can be implemented purely as a pipe query with pipe scoping.
For example,
SELECT (SELECT sum(f1.x+f2.y) FROM f1.json) AS s FROM f2.json
results in
{s:18}
{s:21}
To implement this with pipe scoping, the correlated subquery is carried out by unnesting the data from the subquery with the values coming from the outer scope as in
from f2.json
| unnest {f2:this,f1:[from f1.json]} into ( s:=sum(f1.x+f2.y) )
giving the same result
{s:18}
{s:21}
Strong Typing
Data in SuperSQL is always strongly typed.
Like relational SQL, SuperSQL data sequences can conform to a static schema that is type-checked at compile time. And like document databases and SQL++, data sequences may also be dynamically typed, but unlike these systems, SuperSQL data is always strongly typed.
To perform type checking of dynamic data, SuperSQL utilizes a novel approach based on fused types. Here, the compiler interrogates the data sources for their fused type and uses these types (instead of relational schemas) to perform type checking. This is called fused type checking.
Because a relational schema is a special case of a fused type, fused type checking works for both traditional SQL as well as for super-structured pipe queries. These fused types are maintained in the super-structured database and the binary forms of super-structured file formats provide efficient ways to retrieve their fused type.
For traditional formats like JSON or CSV, the file is read by the compiler and the fused type computed on the fly. When such files are sufficiently large creating too much overhead for the compilation stage, this step may be skipped using a configurable limit and the compilation completed with more limited type checking, instead creating runtime errors when type errors are encountered.
In other words, dynamic data is statically type checked when possible. This works by computing fused types of each operator’s output and propagating these types in a dataflow analysis. When types are unknown, the analysis flexibly models them as having any possible type.
For example, this query produces the expected output
$ super -c "select b from (values (1,2),(3,4)) as T(b,c)"
{b:1}
{b:2}
But this query produced a compile-time error:
$ super -c "select a from (values (1,2),(3,4)) as T(b,c)"
column "a": does not exist at line 1, column 8:
select a from (values (1,2),(3,4)) as T(b,c)
~
Now supposing this data is in the file input.json:
{"b":1,"c":2}
{"b":3,"c":4}
If we run this the query from above without type checking data from the source
(enabled with the -dynamic flag), then the query runs even though there are type
errors. In this case, “missing” values are produced:
$ super -dynamic -c "select a from input.json"
{a:error("missing")}
{a:error("missing")}
Even though the reference to column “a” is dynamically evaluated, all the data is still strongly typed, i.e.,
$ super -c "from input.json | values typeof(this)"
<{b:int64,c:int64}>
<{b:int64,c:int64}>
Data Order
Data sequences from sources may have a natural order. For example, the values in a file being read are presumed to have the order they appear in the file. Likewise, data stored in a database organized by a sort constraint is presumed to have the sorted order.
For order-preserving pipe operators, this order is preserved.
For order-creating operators like sort
an output order is created independent of the input order.
For other operators, the output order is undefined.
Each operator defines whether or not it is order is preserved, created, or discarded.
For example, the where drops values that do
not meet the operator’s condition but otherwise preserves data order,
whereas the sort creates an output order defined
by the sort expressions. The aggregate
creates an undefined order at output.
When a pipe query branches as in
join,
fork, or
switch,
the order at the merged branches is undefined.
Queries
Queries
The syntactical structure of a query consists of
- an optional concatenation of declarations, followed by
- a sequence of pipe operators
separated by a pipe symbol (
|or|>).
Any valid SQL query may appear as a pipe operator and thus be embedded in a pipe query. A SQL query expressed as a pipe operator is called a SQL operator.
Operator sequences may be parenthesized and nested to form lexical scopes.
Operators utilize expressions in composable variations to perform their computations and all expressions share a common expression syntax. While operators consume a sequence of values, the expressions embedded within an operator are typically evaluated once for each value processed by the operator.
Scope
A scope is formed by enclosing a set of declarations along with an operator sequence in the parentheses having the structure:
(
<declarations>
<operators>
)
Scope blocks may appear
- anywhere a pipe operator may appear,
- as a subquery in an expression, or
- as the body of declared operator.
The parenthesized block forms a lexical scope and the bindings created by declarations within the scope are reachable only within that scope inclusive of other scopes defined within the scope.
A declaration cannot redefine an identifier that was previously defined in the same scope but can override identifiers defined in ancestor scopes.
The topmost scope is the global scope where all declared identifiers are available everywhere and does not include parentheses.
Note that this lexical scope refers only to the declared identifiers. Scoping of references to data input is defined by pipe scoping and relational scoping.
For example, this example of a constant declaration
const PI=3.14
values PI
emits the value 3.14 whereas
(
const PI=3.14
values PI
)
| values this+PI
emits error("missing") because the second reference to PI is not
in the scope of the declared constant and thus the identifier is interpreted
as a field reference this.pi via pipe scoping.
Identifiers
Identifiers are names that arise in many syntactical structures and
may be any sequence of UTF-8 characters. When not quoted,
an identifier may be comprised of Unicode letters, $, _,
and digits [0-9], but may not start with a digit.
To express an identifier that does not meet the requirements of an
unquoted identifier, arbitrary text may be quoted inside of backtick (`)
quotes.
Escape sequences in backtick-quoted identifiers are interpreted as in
string literals. In particular, a backtick (`)
character may be included in a backtick string with Unicode escape \u0060.
In SQL expressions, identifiers may also be enclosed in double-quoted strings.
The special value this is also available in SQL but has
peculiar semantics
due to SQL scoping rules. To reference a column called this
in a SQL expression, simply use double quotes, i.e., "this".
An unquoted identifier cannot be true, false, null, NaN, or Inf.
Patterns
For ease of use, several operators utilize a syntax for string entities outside of expression syntax where quotation marks for such entities may be conveniently elided.
For example, when sourcing data from a file on the file system, the file path can be expressed as a text entity and need not be quoted:
from file.json | ...
Likewise, in the search operator, the syntax for a
regular expression search can be specified as
search /\w+(foo|bar)/
whereas an explicit function call like regexp must be invoked to utilize
regular expressions in expressions as in
where len(regexp(r'\w+(foo|bar)', this)) > 0
Regular Expression
Regular expressions follow the syntax and semantics of the RE2 regular expression library, which is documented in the RE2 Wiki.
When used in an expression, e.g., as a parameter to a function, the RE2 text is simply passed as a string, e.g.,
regexp('foo|bar', this)
To avoid having to add escaping that would otherwise be necessary to
represent a regular expression as a raw string,
with prefix with r, e.g.,
regexp(r'\w+(foo|bar)', this)
But when used outside of expressions where an explicit indication of
a regular expression is required (e.g., in a
search or
from operator), the RE2 is instead
prefixed and suffixed with a /, e.g.,
/foo|bar/
matches the string "foo" or "bar".
Glob
Globs provide a convenient short-hand for regular expressions and follow
the familiar pattern of “file globbing” supported by Unix shells.
Globs are a simple, special case that utilize only the * wildcard.
Like regular expressions, globs may be used in
a search operator or a
from operator.
Valid glob characters include letters, digits (excepting the leading character),
any valid string escape sequence
(along with escapes for *, =, +, -), and the unescaped characters:
_ . : / % # @ ~ *
A glob cannot begin with a digit.
Text Entity
A text entity represents a string where quotes can be omitted for certain common use cases regarding URLs and file paths.
Text entities are syntactically valid as targets of a
from operator and as named arguments
to from and the
load operator.
Specifically, a text entity is one of:
- a string literal (double quoted, single quoted, or raw string),
- a path consisting of a sequence of characters consisting of letters, digits,
_,$,., and/, or - a simple URL consisting of a sequence of characters beginning with
http://orhttps://, followed by dotted strings of letters, digits,-, and_, and in turn optionally followed by/and a sequence of characters consisting of letters, digits,_,$,., and/.
If a URL does not meet the constraints of the simple URL rule,
e.g., containing a : or &, then it must be quoted.
Comments
Single-line comments are SQL style begin with two dashes -- and end at the
subsequent newline.
Multi-line comments are C style and begin with /* and end with */.
# spq
values 1, 2 -- , 3
/*
| aggregate sum(this)
*/
| aggregate sum(this / 2.0)
# input
# expected output
1.5
Declarations
Declarations
Declarations bind a name in the form of an identifier to various entities and may appear at the beginning of any scope including the main scope.
Declarations may be created for
All of the names defined in a given scope are available to other declarations defined in the same scope (as well as containing scopes) independent of the order of declaration, i.e., a declaration may forward-reference another declaration that is defined in the same scope.
A declaration may override another declaration of the same name in a parent scope, but declarations in the same scope with the same name conflict and result in an error.
Constants
Constants
Constants are declared with the syntax
const <id> = <expr>
where <id> is an identifier
and <expr> is a constant expression
that must evaluate at compile time without referencing any
runtime state such as this or a field of this.
Constant declarations must appear in the declaration section of a scope.
A constant can be any expression, inclusive of subqueries and function calls, as long as the expression evaluates to a compile-time constant.
Examples
A simple declaration for the identifier PI
# spq
const PI=3.14159
values 2*PI*r
# input
{r:5}
{r:10}
# expected output
31.4159
62.8318
A constant as a subquery that is independent of external input
# spq
const ABC = [
values 'a', 'b', 'c'
| upper(this)
]
values ABC
# input
# expected output
["A","B","C"]
Types
Types
Named types are declared with the syntax
type <id> = <type>
where <id> is an identifier and <type> is a type.
This creates a new type with the given name in the type system.
Type declarations must appear in the declaration section of a scope.
Any named type that appears in the body of a type declaration must be previously declared in the same scope or in an ancestor scope, i.e., types cannot contained forward references to other named types. In particular, named types cannot be recursive.
Note
A future version of SuperSQL may include recursive types. This is a research topic for the SuperDB project.
Input data may create named types that conflict with type declarations. In this case, a reference to a declared type in the query text uses the type definition of the nearest containing scope that binds the type name independent of types in the input.
When a named type is referenced as a string argument to cast, then any type definition with that name is ignored and the named type is bound to the type of the argument of cast. This does not affect the binding of the type used in other expressions in the query text.
Types can also be bound to identifiers without creating a named type using a constant declaration binding the name to a type value.
Examples
Cast integers to a network port type
# spq
type port=uint16
values this::port
# input
80
# expected output
80::(port=uint16)
Cast integers to a network port type calling cast with a type value
# spq
type port=uint16
values cast(this, <port>)
# input
80
# expected output
80::(port=uint16)
Override binding to type name with this
# spq
type foo=string
values cast(x, foo), cast(x, this.foo)
# input
{x:1,foo:<float64>}
{x:2,foo:<bool>}
# expected output
"1"::=foo
1.
"2"::=foo
true
A type name argument to cast in the form of a string is independent type declarations
# spq
type foo=string
values {str:cast(this, 'foo'), named:cast(this, foo)}
# input
1
2
# expected output
{str:1::=foo,named:"1"::=foo}
{str:2::=foo,named:"2"::=foo}
Bind a name to a type without creating a named type
# spq
const foo=<string>
values this::foo
# input
1
2
# expected output
"1"
"2"
Queries
Queries
A query may be bound to an identifier as a named query with the syntax
let <name> = ( <query> )
where <name> is an identifier
and <query> is any standalone query that sources its own input.
Named queries are similar to common-table expressions (CTE) and may be likewise invoked in a from operator by referencing the query’s name, as in
from <name>
When invoked, a named query behaves like any query evaluated in the main scope
and receives a single null value as its input. Thus, such queries typically
begin with a
from or
values operator to provide explicit input.
Named queries can also be referenced within an expression, in which case they are automatically invoked as an expression subquery. As with any expression subquery, multiple values result in an error, so when this is expected, the query reference may be enclosed in brackets to form an array subquery.
To create a query that can be used as an operator and thus can operate on its input, declare an operator.
A common use case for a named query is to compute a complex query that returns a scalar, then embedding that scalar result in an expression. Even though the named query appears syntactically as a sub-query in this case, the result is efficient because the compiler will materialize the result and reuse it on each invocation.
Examples
Simplest named query
# spq
let hello = (values 'hello, world')
from hello
# input
# expected output
"hello, world"
Use an array subquery if multiple values expected
# spq
let q = (values 1,2,3)
values [q]
# input
# expected output
[1,2,3]
Assemble multiple query results into a record
# spq
let q1 = (values 1,2,3)
let q2 = (values 3,4)
let q3 = (values 5)
values {a:[q1],b:[q2],c:q3}
# input
# expected output
{a:[1,2,3],b:[3,4],c:5}
Functions
Functions
New functions are declared with the syntax
fn <id> ( [<param> [, <param> ...]] ) : <expr>
where
<id>is an identifier representing the name of the function,- each
<param>is an identifier representing a positional argument to the function, and <expr>is any expression that implements the function.
Function declarations must appear in the declaration section of a scope.
The function body <expr> may refer to the passed-in arguments by name.
Specifically, the references to the named parameters are
field references of the special value this, as in any expression.
In particular, the value of this referenced in a function body
is formed as record from the actual values passed to the function
where the field names correspond to the parameters of the function.
For example, the function add as defined by
fn add(a,b): a+b
when invoked as
values {x:1} | values add(x,1)
is passed the record the {a:x,b:1}, which after resolving x to 1,
is {a:1,b:1} and thus evaluates the expression
this.a + this.b
which results in 2.
Any function-as-value arguments passed to a function do not appear in the this
record formed from the parameters. Instead, function values are expanded at their
call sites in a macro-like fashion.
Functions may be recursive. If the maximum call stack depth is exceeded, the function returns an error value indicating so. Recursive functions that run for an extended period of time without exceeding the stack depth will simply be allowed to run indefinitely and stall the query result.
Subquery Functions
Since the body of a function is any expression and an expression may be a subquery, function bodies can be defined as subqueries. This leads to the commonly used pattern of a subquery function:
fn <id> ( [<param> [, <param> ...]] ) : (
<query>
)
where <query> is any query and is simply wrapped in parentheses
to form the subquery.
As with any subquery, when multiple results are expected, an array subquery
may be used by wrapping <query> in square brackets instead of parentheses:
fn <id> ( [<param> [, <param> ...]] ) : [
<query>
]
Note when using a subquery expression in this fashion, the function’s parameters do not appear in the scope of the expressions embedded in the query. For example, this function results in a type error:
fn apply(a,val): (
unnest a
| collect(this+val))
)
values apply([1,2,3], 1)
because the field reference to val within the subquery does not exist.
Instead the parameter val can be carried into the subquery using the
alternative form of unnest:
fn apply(a,val): (
unnest {outer:val,item:a}
| collect(outer+item)
)
values apply([1,2,3], 1)
See the example below.
Examples
A simple function that adds two numbers
# spq
fn add(a,b): a+b
values add(x,y)
# input
{x:1,y:2}
{x:2,y:2}
{x:3,y:3}
# expected output
3
4
6
A simple recursive function
# spq
fn fact(n): n<=1 ? 1 : n*fact(n-1)
values fact(5)
# input
# expected output
120
A subquery function that computes some stats over numeric arrays
# spq
fn stats(numbers): (
unnest numbers
| sort this
| avg(this),min(this),max(this),mode:=collect(this)
| mode:=mode[len(mode)/2]
)
values stats(a)
# input
{a:[3,1,2]}
{a:[4]}
# expected output
{avg:2.,min:1,max:3,mode:2}
{avg:4.,min:4,max:4,mode:4}
Function arguments are actually fields in the “this” record
# spq
fn that(a,b,c): this
values that(x,y,3)
# input
{x:1,y:2}
# expected output
{a:1,b:2,c:3}
Functions passed as values do not appear in the “this” record
# spq
fn apply(f,arg):{that:this,result:f(arg)}
fn square(x):x*x
values apply(&square,val)
# input
{val:1}
{val:2}
# expected output
{that:{arg:1},result:1}
{that:{arg:2},result:4}
Function parameters do not reach into subquery scope
# spq
fn apply(a,val): (
unnest a
| collect(this+val)
)
values apply([1,2,3], 1)
# input
# expected output
"val" no such field at line 3, column 18:
| collect(this+val)
~~~
The compound unnest form brings parameters into subquery scope
# spq
fn apply(a,val): (
unnest {outer:val,item:a}
| collect(outer+item)
)
values apply([1,2,3], 1)
# input
# expected output
[2,3,4]
Operators
Operators
New operators are declared with the syntax
op <name> [<param> [, <param> ...]] : (
<query>
)
where
<name>is an identifier representing the name of the new operator,- each
<param>is an identifier representing a positional parameter to the operator, and <query>is any query.
Operator declarations must appear in the declaration section of a scope.
Call
A declared operator is invoked by its name
using the call keyword.
Operators can be invoked without the call keyword as a shortcut when such use
is unambiguous with the built-in operators.
A called instance of a declared operator consumes input, operates on that input, and produces output. The body of the operator declaration with argument expressions substituted into referenced parameters defines how the input is processed.
An operator may also source its own data by beginning the query body with a from operator or SQL statement.
Nested Calls
Operators do not support recursion. They cannot call themselves nor can they form a mutually recursive dependency loop. However, operators can call other operators whose declaration is in scope as long as no dependency loop is formed.
Closure-like Arguments
In contrast to function calls, where the arguments are evaluated at the call site and values are passed to the function, operator arguments are instead passed to the operator body as an expression template and the expression is evaluated in the context of the operator body. That said, any other declared identifiers referenced by these expressions (e.g., constants, functions, named queries, etc.) are bound to those entities using the lexical scope where they are defined rather than the scope of their expanded use in the operator’s definition.
These expression arguments can be viewed as a closure though there is no persistent state stored in the closure. The jq language describes its expression semantics as closures as well, though unlike jq, the operator expressions here are not generators and do not implement backtracking.
Examples
Trivial operator that echoes its input
# spq
op echo: (
values this
)
echo
# input
{x:1}
# expected output
{x:1}
Simple example that adds a new field to inputs records
# spq
op decorate field, msg: (
put field:=msg
)
decorate message, "hello"
# input
{greeting: "hi"}
# expected output
{greeting:"hi",message:"hello"}
Error checking works as expected for non-l-values used as l-values
# spq
op decorate field, msg: (
put field:=msg
)
decorate 1, "hello"
# input
{greeting: "hi"}
# expected output
illegal left-hand side of assignment at line 2, column 7:
put field:=msg
~~~~~~~~~~
Nested calls
# spq
op add4 x: (
op add2 x: (
op add1 x: ( x:=x+1 )
add1 x | add1 x
)
add2 x | add2 x
)
add4 a.b
# input
{a:{b:1}}
# expected output
{a:{b:5}}
Pragmas
Pragmas
Pragmas control various language features and appear in a declaration block so their effect is lexically scoped. They have the form
pragma <id> = <expr>
where <id> is an identifier
and <expr> is a constant expression
that must evaluate at compile time without referencing any
runtime state such as this or a field of this.
Pragmas must appear in the declaration section of a scope.
List of Pragmas
Currently, there is just one supported pragma.
index_base- controls whether index expressions and slice expressions are 0-based or 1-based.0for zero-based indexing1for one-based indexing
Example
Controlling indexing and slicing
# spq
pragma index_base = 1
values {
a: this[2:3],
b: (
pragma index_base = 0
values this[0]
)
}
# input
"bar"
[1,2,3]
# expected output
{a:"a",b:error("missing")}
{a:[2],b:1}
Expressions
Expressions
Expressions are the means the carry out calculations and utilize familiar query-language elements like literal values, function calls, subqueries, and so forth.
Within pipe operators,
expressions may reference input values either via the special value
this or implied field references to this, while
within SQL clauses, input is referenced with table and
column references.
For example, values, where,
cut, put,
sort and so forth all utilize various expressions
as part of their semantics.
Likewise, the projected columns of a
SELECT from the very same expression syntax
used by pipe operators.
While SQL expressions and pipe expressions share an identical syntax, their semantics diverges in some key ways:
- SQL expressions that reference
thishave semantics that depend on the SQL clause that expression appears in, - relational tables and/or columns cannot be referenced using aliases in pipe scoping,
- double-quoted string literals may be used in pipe expressions but are interpreted as identifiers in SQL expressions.
Expression Syntax
Expressions are composed from operands and operators over operands.
Operands include
- inputs,
- literals,
- formatted strings
- function calls,
- subqueries, or
- other expressions.
Operators include
- arithmetic to add, subtract, multiply, divide, etc,
- cast to convert values from one type to another,
- comparisons to compare two values resulting in a Boolean,
- concatenation of strings,
- conditionals including C-style
?-:operator and SQLCASEexpressions, - containment to test for the existing value inside an array or set,
- dot to access a field of a record (or a SQL column of a table),
- exists for SQL compatibility to test for non-empty subqueries,
- indexing to select and slice elements from an array, record, map, string, or bytes,
- logic to combine predicates using Boolean logic, and
- slices to extract subsequences from arrays, sets, strings, and bytes.
Identifier Resolution
An identifier that appears as an operand in an expression is resolved to the entity that it represents using lexical scoping.
For identifiers that appear in the context of call syntax, i.e., having the form
<func> ( <args> )
then <func> is one of:
- the name of a built-in function,
- the name of a declared function,
- a lambda expression, or
- a function parameter that resolves to a function reference or lambda expression.
Identifiers that correspond to an in-scope function may also be referenced with the syntax
& <name>
as a function reference and must appear as an argument to an operator or function; otherwise, such expressions are errors.
For identifiers that resolve to in-scope declarations, the resolution is as follows:
- constants resolve to their defined constant values,
- types resolve to their named type,
- queries resolve to an implied subquery invocation, and
- functions and operators produce an error.
For other instances of identifiers, then identifier is presumed to be an input reference and is resolved as such.
Precedence
When multiple operators appear in an unparenthesized sequence, ambiguity may arise by the order of evaluation as expressions are not always evaluated in a strict left-to-right order. Precedence rules determine the operator order when such ambiguity exists where higher precedence operators are evaluated before lower precedence operators.
For example,
1 + 2 * 3
is 7 not 9 because multiplication has higher precedence than addition
and the above expression is equivalent to \
1 + ( 2 * 3 )
Operators have the following precedence from highest to lowest:
[]indexing-,+unary sign||string concatenation*,/,%multiplication, division, modulo-,+subtraction, addition=,>=,>,<=,<,<>,!=,iscomparisonsnot,!logical NOT,existsexistencelike,in,betweencomparisonsandlogical ANDorlogical OR?:ternary conditional
Some operators like case expressions do not have any such ambiguity as keywords delineate their sub-expressions and thus do not have any inherent precedence.
Coercion
Note
A forthcoming version of this documentation will describe the coercion rules for automatically casting of values for type compatibility in expressions.
Arithmetic
Arithmetic
Arithmetic operations (*, /, %, +, -) follow customary syntax
and semantics and are left-associative with multiplication and division having
precedence over addition and subtraction. % is the modulo operator.
Unary Sign
Any number may be signed with a unary operator having the form:
- <expr>
and
+ <expr>
where <expr> is any expression that results in a number type.
Example
# spq
values 2*3+1, 11%5, 1/0, "foo"+"bar", +1, -1
# input
# expected output
7
1
error("divide by zero")
"foobar"
1
-1
Casts
Casts
Casting is the process of converting a value from its current data type to another type using an explicit expression having the form
<expr> :: <type>
where <expr> is any expression and <type> is any
type that is
compatible with <expr>. When <expr> and <type> are incompatible,
structured errors result as described below.
The SQL syntax
CAST(<expr> AS <type>)
is also supported.
To cast to the value form of a type, i.e., a type value, the cast function may be used.
When a cast is successful, the return value of cast always has the target type.
If errors are encountered, then some or all of the resulting value will be embedded with structured errors and the result does not have the target type.
The target type cannot contain an error type. The error function
should instead be used to create error values.
Primitive Values
Some primitive values can be cast to other primitive types, but not all possibilities are permitted and instead result in structured errors. Except for union and named types, primitive values cannot be cast to complex types.
The casting rules for primitives are as follows:
- A number may be cast to
- another number type as long as the numeric value is not outside the scope of the target type, which results in a structured error,
- type
string, - type
boolwhere zero isfalseand non-zero istrue, - type
durationwhere the number is presumed to be nanoseconds, - type
timewhere the number is presumed to be nanoseconds since epoch, or - a union or named type.
- A string may be cast to any other primitive type as long as
the string corresponds to a valid SuperSQL primitive literal. Time strings
in particular may represent typical timestamp formats. When cast to the
bytestype, the result is the byte encoding of the UTF-8 string. A string may also be cast to a union or named type. To parse a literal string that is in the SUP or JSON format without having to specify the target type, use theparse_supfunction. - A bool may be cast to
- a number type where
falseis zero andtrueis1, - type
string, or - a union or named type.
- a number type where
- A time value may be cast to
- a number type where the numeric value is nanoseconds since epoch
- type
string, or - a union or named type.
A null value of type null may be cast to any type.
Note
A future version of this documentation will provide detailed documentation for acceptable date/time strings.
Complex Values
When a complex value has multiple levels of nesting, casting is applied recursively into the value hierarchy. For example, cast is recursively applied to each element in an array of records, then recursively applied to each of those records.
If there is a mismatch between the type of the input value and target type then structured errors appear within the portion of a nested value that is not castable.
The casting rules for complex values are as follows:
- A record may be cast to
- a record type where any fields not present in the target type are omitted, any fields not present in the input value while present in the target type are set to null, and the value of each input field present in both the input and target are recursively cast to the target’s type of that field,
- a string type where the string is the input value serialized in the SUP format, or
- a union or named type.
- An array may be cast to
- an array type where the elements of the input value are recursively cast to the element type of the target array type,
- a set type where the elements of the input value are recursively cast to the element type of the target set type and any duplicate values are automatically removed, or
- a string type where the string is the input value serialized in the SUP format, or
- a union or named type.
- A set may be cast to
- a set type where the elements of the input value are recursively cast to the element type of the target set type,
- an array type where the elements of the input value are recursively cast to the element type of the target array type, or
- a string type where the string is the input value serialized in the SUP format, or
- a union or named type.
- A map may be cast to
- a map type where the keys and values of the input value are recursively cast to the key and value type of the target map type, or
- a string type where the string is the input value serialized in the SUP format, or
- a union or named type.
- An enum may be cast to
- an enum type where the target type includes the symbol of the value being cast, or
- a string type where the string is the input value serialized in the SUP format, or
- a union or named type.
Union Types
When casting a value to a union type, the member type of the union is selected to find a best fit of the available types. If no fit exists, a structured error is returned.
If the input type is present in the member types, then the best fit is that type.
Otherwise, the best fit is determined from the input type as follows:
Note
A future version of this documentation will provide detailed documentation for best-fit selection algorithm.
Named Types
When casting to a named type, the cast is carried out using its underlying type then the named type is reattached to the result.
Errors
Casts attempted between a value and a type that are not defined result in a structured error of the form of:
{message:"cannot cast to <target>", on:<val>}
When errors appear within a complex value, the returned value may not be wrapped in a structured error and the problematic portions of the cast can be debugged by inspecting the result for precisely where the errors arose.
For example, this function call
cast({a:"1",b:2}, <{a:int64,b:ip}>)
returns
{a:1,b:error({message:"cannot cast to ip",on:2})}
That is the value for a was successfully cast from string "1“ to integer 1 but
the value for b could not be cast to an IP address so a structured error is
instead embedded as the value for b.
Examples
Cast various primitives to type ip
# spq
values this::ip
# input
"10.0.0.1"
1
"foo"
# expected output
10.0.0.1
error({message:"cannot cast to ip",on:1})
error({message:"cannot cast to ip",on:"foo"})
Cast array of strings to array of IPs
# spq
values this::[ip]
# input
["10.0.0.1","10.0.0.2"]
# expected output
[10.0.0.1,10.0.0.2]
Cast a record to a different record type
# spq
values this::{b:string}
# input
{a:1,b:2}
{a:3}
{b:4}
# expected output
{b:"2"}
{b:null::string}
{b:"4"}
Multiple syntax options for casting
# spq
values
80::(port=uint16),
CAST(80 AS (port=uint16)),
cast(80::uint16, 'port'),
cast(cast(80, <uint16>), 'port')
# input
# expected output
80::(port=uint16)
80::(port=uint16)
80::(port=uint16)
80::(port=uint16)
Casting time strings is fairly flexible
# spq
values this::time
# input
"May 8, 2009 5:57:51 PM"
"oct 7, 1970"
# expected output
2009-05-08T17:57:51Z
1970-10-07T00:00:00Z
Cast to a declared type
# spq
type port = uint16
values this::port
# input
80
8080
# expected output
80::(port=uint16)
8080::(port=uint16)
Cast nested records
# spq
values this::{ts:time,r:{x:float64,y:float64}}
# input
{ts:"1/1/2022",r:{x:"1",y:"2"}}
{ts:"1/2/2022",r:{x:3,y:4}}
# expected output
{ts:2022-01-01T00:00:00Z,r:{x:1.,y:2.}}
{ts:2022-01-02T00:00:00Z,r:{x:3.,y:4.}}
Comparisons
Comparisons
Comparison expressions follow customary syntax and semantics and
result in a truth value of type bool or an error.
The binary comparison operators have the form
<expr> <op> <expr>
where <op> is one of
<for less than,<=for less than or equal,>for greater than>=for greater than or equal,==or=for equal,!=or<>for not equal, orlikeornot likefor the SQL string pattern matching.
The between comparator has the form
<expr> between <lower> and <upper>
where <expr>, <lower>, and <upper> are any expressions that result in
an orderable type and are type compatible. This is shorthand for
<expr> >= <lower> and <expr> <= <upper>
The null comparators have the form
<expr> is null
and is true if <expr> is a null value of any type. Likewise,
<expr> is not null
if true if <expr> is not a null value of any type.
As with SQL, any comparison of a null value to any other value is a null
value of type bool, i.e., null::bool. This is because comparing an unknown
value with any other value has an unknown result.
The like comparator has the form
<expr> like <pattern>
where <expr> is any expression that produces a string type and <pattern>
is a constant expression that results in a string type.
Note
Currently,
<pattern>must be a constant value and cannot depend on the input. Also, theilikeoperator for case-insensitive matching is not yet supported. These capabilities will be included in a future version of SuperSQL.
The like comparator is true if the <pattern> matches <expr> where pattern
consists of literal characters, _ for matching any single letter, and % for
matching any sequence of characters.
The not like comparator has the form
<expr> not like <pattern>
and is true when the pattern does not match the expression.
String values are compared via byte order in accordance with C/POSIX collation as found in other SQL databases such as Postgres.
Note
SuperSQL does net yet support SQL COLLATE keyword and variations.
When the operands are coercible to like types, the result is the truth value
of the comparison. Otherwise, the result is false. To compare values of
different types, consider the compare function.
If either operand to a comparison
is error("missing"), then the result is error("missing").
Examples
Various scalar comparisons
# spq
values 1 > 2, 1 < 2, "b" > "a", 1 > "a", 1 > x
# input
# expected output
false
true
true
false
error("missing")
Null comparisons
# spq
values {isNull:this is null,isNotNull:this is not null}
# input
1
null
2
error("missing")
# expected output
{isNull:false,isNotNull:true}
{isNull:true,isNotNull:false}
{isNull:false,isNotNull:true}
{isNull:error("missing"),isNotNull:error("missing")}
Comparisons using a like pattern
# spq
values f"{this} like '%abc_e': {this like '%abc_e'}"
# input
"abcde"
"xabcde"
"abcdex"
"abcdd"
null
error("missing")
# expected output
"abcde like '%abc_e': true"
"xabcde like '%abc_e': true"
"abcdex like '%abc_e': false"
"abcdd like '%abc_e': false"
null::string
error("missing")
Concatenation
Concatenation
Strings may be concatenated using the concatenation operator having the form
<expr> || <expr>
where <expr> is any expression that results in a string value.
It is an error to concatenate non-string values. Values may be converted to string using a cast to type string.
Examples
Concatenate two fields
# spq
values a || b
# input
{a:"foo",b:"bar"}
{a:"hello, ",b:"world"}
{a:"foo",b:123}
# expected output
"foobar"
"hello, world"
error("incompatible types")
Cast non-string values to concatenate
# spq
values "foo" || 123::string
# input
# expected output
"foo123"
Conditionals
Conditionals
Conditional expressions compute a result from two or more possibilities determined by Boolean predicates.
Conditionals can be written using SQL-style CASE syntax or C-style ternary expressions.
Case Expressions
SQL-style CASE expressions have two forms.
The first form has the syntax
CASE <expr>
WHEN <expr-1> THEN <result-1>
[ WHEN <expr-2> THEN <result-2> ]
...
[ ELSE <else-result> ]
END
The expression <expr> is evaluated and compared with each subsequent
WHEN expression <expr-1>, <expr-2>, etc. until a match is found,
in which case, the corresponding expression <result-n> is evaluated for the match,
and that value becomes the result of the CASE expression.
If there is no match and an ELSE clause is present, the the result is
determined by the expression <else-result>. Otherwise, the result is null.
The second form omits <expr> from above and has the syntax
CASE
WHEN <predicate-1> THEN <result-1>
[ WHEN <predicate-2> THEN <result-2> ]
...
[ ELSE <else-result> ]
END
Here, each WHEN expression must be Boolean-valued and
<predicate-1>, <predicate-2>, etc. are evaluated
in order until a true result is encountered,
in which case, the corresponding expression <result-n> is evaluated for the match,
and that value becomes the result of the CASE expression.
If there is no true result and an ELSE clause is present, the the result is
determined by the expression <else-result>. Otherwise, the result is null.
If the predicate expressions are not Boolean valued, then an error results. The error is reported at compile time if possible, but when input is dynamic and the type cannot be statically determined, a structured error is generated at run time as the result of the conditional expression.
Ternary Conditional
The ternary form follows the C language and has syntax
<predicate> ? <true-expr> : <false-expr>
where <predicate> is a Boolean-valued expression
and <true-expr> and <false-expr> are any expressions.
When <predicate> is true, then <true-expr> is evaluated and becomes
the result of the conditional expression; otherwise, <false-expr>
becomes the result.
If <predicate> is not a Boolean, then an error results. The error
is reported at compile time if possible, but when input is dynamic and
the type cannot be statically determined, a structured error
is generated at run time as the result of the conditional expression.
Examples
A simple ternary conditional
# spq
values (s=="foo") ? v : -v
# input
{s:"foo",v:1}
{s:"bar",v:2}
# expected output
1
-2
The previous example as a CASE expression
# spq
values CASE WHEN s="foo" THEN v ELSE -v END
# input
{s:"foo",v:1}
{s:"bar",v:2}
# expected output
1
-2
Ternary conditionals can be chained
# spq
values (s=="foo") ? v : (s=="bar") ? -v : v*v
# input
{s:"foo",v:1}
{s:"bar",v:2}
{s:"baz",v:3}
# expected output
1
-2
9
The previous example as a CASE expression
# spq
values
CASE s
WHEN "foo" THEN v
WHEN "bar" THEN -v
ELSE v*v
END
# input
{s:"foo",v:1}
{s:"bar",v:2}
{s:"baz",v:3}
# expected output
1
-2
9
Containment
Containment
A containment expression expression tests for the existence of a value in another value and has the form
<item> in <target>
where <item> and <target> are expressions.
The result is Boolean-valued and is true
and is true if the <item> expression results in a value that
appears somewhere in the <target> expression as an exact match of the item.
In contrast to SQL’s IN operator, the right-hand side can be any value and when the
<item> and <target> are equal, the result of in is true, e.g.,
1 in 1
is semantically valid and results in true.
The inverse of in has the syntax
<item> not in <target>
and is true when <item> is not contained in the <target>.
Note
The
inoperator currently does not support SQL NULL semantics in that1 not in [2,NULL]is false instead of NULL. This will be fixed in a future version.
When the <target> is a non-array subquery, it is coerced to an
array subquery and the in expression is evaluated
on the array result of the subquery.
Examples
Test for the value 1 in the input values
# spq
where 1 in this
# input
{a:[1,2]}
{b:{c:3}}
{d:{e:1}}
# expected output
{a:[1,2]}
{d:{e:1}}
Test against a predetermined values with a literal array
# spq
unnest accounts | where id in [1,2]
# input
{accounts:[{id:1},{id:2},{id:3}]}
# expected output
{id:1}
{id:2}
Complex values are recursively searched for containment
# spq
where {s:"foo"} in this
# input
{s:"foo"}
{s:"foo",t:"bar"}
{a:{s:"foo"}}
[1,{s:"foo"},2]
# expected output
{s:"foo"}
{a:{s:"foo"}}
[1,{s:"foo"},2]
Subqueries work the same whether they are standard style or array style
# spq
let vals = (values 1,2,3)
values {a:(this in vals), b:(this in [vals])}
# input
1
2
4
# expected output
{a:true,b:true}
{a:true,b:true}
{a:false,b:false}
Dot
Dot
Records and maps with string keys are dereferenced with the dot operator .
as is customary in other languages. The syntax is
<expr> . <id>
where <expr> is an expresson resulting in a dereferenceable value
and <id> is an identifier representing the
field name of a record or a string key of a map.
Dereferenceable values include records, maps with keys of type string, and type values that are of type record.
The result of a dot expression is
- the value of the indicated field for a record type,
- the value of the indicated entry for a map with string keys, or
- the type value of the indicated field for a record type value.
When a field or key is referenced in a dereferenceable type but that
field or key is not present, then error("missing") is the result.
If a non-dereferenceable type is operated upon with the dot operator, then a compile-time error results for statically typed data and a structured error results for dynamic data.
Note that identifiers containing unusual characters can be represented by enclosing the identifier in backtick quotes, e.g.,
x.`a b`
references the field or key whose name is a b.
Alternatively, index syntax may be used to access the field as a string value, e.g.,
x["a b"]
If a field name is not representable as an identifier,
then indexing
may be used with a quoted string to represent any valid field name.
Such field names can be accessed using this
with an index-style reference, e.g., this["field with spaces"].
Examples
Derefence a map, a record, and a record type
# spq
values this.x.y
# input
|{"x":{y:1},"y":2}|
{x:{y:1},y:2}
<{x:{y:int64},y:int64}>
# expected output
1
1
<int64>
Use backtick quotes for identifiers with special characters
# spq
values x.`a b`, x["a b"]
# input
{x:{"a b":123}}
# expected output
123
123
Exists
Exists
The exists operator is Boolean-valued function that tests whether
a subquery has a non-empty result and has the form
exists ( <query> )
where <query> is any query.
It is a syntactic shortcut for
len([<query>]) != 0
Examples
Simple example showing true for non-empty result
# spq
values exists (values 1,2,3)
# input
# expected output
true
EXISTS is typically used with correlated subqueries but they are not yet supported
# spq
let Orders = (values {product:"widget",customer_id:1})
SELECT 'there are orders in the system' as s
WHERE EXISTS (
SELECT 1
FROM Orders
)
# input
# expected output
{s:"there are orders in the system"}
F-Strings
F-Strings
A formatted string (or f-string) is a string literal prefixed with f
that includes replacement expressions delimited by curly braces:
f"... { <expr> } ... { <expr> } ..."
The text starting with { and ending at } is substituted
with the result of the expression <expr>. As shown, multiple such
expressions may be embedded in an f-string. If the expression results
in a value that is not a string, then it is implicitly cast to a string.
F-strings may be nested in that the embedded expressions may contain additional f-strings as in
f"an example {upper(f"{foo + bar}")} of nested f-strings"
If any expression results in an error, then the value of the f-string is the first error encountered in left-to-right order.
To represent a literal { character inside an f-string, it must be escaped
with a backslash as \{. This escape sequence is valid only in f-strings.
Examples
Some simple arithmetic
# spq
values f"pi is approximately {numerator / denominator}"
# input
{numerator:22.0, denominator:7.0}
# expected output
"pi is approximately 3.142857142857143"
A complex expression with nested f-strings
# spq
values f"oh {this[upper(f"{foo + bar}")]}"
# input
{foo:"hello", bar:"world", HELLOWORLD:"hi!"}
# expected output
"oh hi!"
Curly braces can be escaped
# spq
values f"{this} look like: \{}"
# input
"curly braces"
# expected output
"curly braces look like: {}"
Function Calls
Function Calls
Functions compute a result from zero or more input arguments that are passed by value as positional arguments.
A function call is an expression having the form
<entity> ( [ <arg> [ , <arg> ... ]] )
where the <entity> is either an identifier or
a lambda expression. When the <entity> is
an identifier, it is one of
- the name of built-in function,
- the name of a declared function that is in scope, or
- a parameter name that resolves to a function reference where the entity called is inside of a declared function.
Each argument <arg> is either
- an expression that is evaluated before the function is called and passed as a value, or
- a function reference.
Functions are not first-class values and cannot be assigned to super-structured values as there are no function values in the super-structured data model. Instead, functions may only be called or passed as a reference to another function.
Lambda Expressions
A lambda expression is an anonymous function having the form
lambda [ <param> [ , <param> ... ]] : <expr>
where <expr> is any expression defining the function and
each <param> is an identifier defining the positional parameters of the
function.
For example,
lambda x:x+1
is an anonymous function that adds one to its argument.
Like named functions, lambda expressions may only be called or passed to another function as an argument.
For example,
lambda x:x+1 (2)
is the value 3 and
f(lambda x:x+1, 2)
calls the function f with the lambda as its first argument and the value 2
as its second argument.
Function References
The syntax for referencing a function by name is
& <name>
where <name> is an identifier corresponding to
either a built-in function
or a declared function that is in scope.
Note
Many languages form function references simply by referring to their name without the need for a special symbol like
&. However, an ambiguity arises here between a field reference, which is not declared, and a function name.
For example,
&upper
is a reference to the built-in function upper.
Examples
Sample calls to various built-in functions
# spq
values pow(2,3), lower("ABC")+upper("def"), typeof(1)
# input
# expected output
8.
"abcDEF"
<int64>
Calling a lambda function
# spq
values lambda x:x+1 (2)
# input
# expected output
3
Passing a lambda function
# spq
fn square(g,val):g(val)*g(val)
values square(lambda x:x+1, 2)
# input
# expected output
9
Passing function references
# spq
fn inc(x):x+1
fn apply(val,sfunc,nfunc):
case typeof(val)
when <string> then sfunc(val)
when <int64> then nfunc(val)
else val
end
values apply(this,&upper,&inc)
# input
1
"foo"
true
# expected output
2
"FOO"
true
Function references may not be assigned to super-structured values
# spq
fn f():null
values {x:&f}
# input
# expected output
parse error at line 2, column 11:
values {x:&f}
=== ^ ===
Index
Index
The index operation is denoted with square brackets and can be applied to any indexable data type and has the form:
<entity> [ <index> ]
where <entity> is an expression resulting in an indexable entity
and <index> is an expression resulting in a value that is
used to index the indexable entity.
Indexable entities include records, arrays, sets, maps, strings, and bytes.
If <entity> is a record, then the <index> operand
must be coercible to a string and the result is the record’s field
of that name.
If <entity> is an array, then the <index> operand
must be coercible to an integer and the result is the
value in the array of that index.
If <entity> is a set, then the <index> operand
must be coercible to an integer and the result is the
value in the set of that index ordered by total order of values.
If <entity> is a map, then the <index> operand
is presumed to be a key and the corresponding value for that key is
the result of the operation. If no such key exists in the map, then
the result is error("missing").
If <entity> is a string, then the <index> operand
must be coercible to an integer and the result is an integer representing
the unicode code point at that offset in the string.
If <entity> is type bytes, then the <index> operand
must be coercible to an integer and the result is an unsigned 8-bit integer
representing the byte value at that offset in the bytes sequence.
Note
Indexing of strings and bytes is not yet implemented.
Index Base
Indexing in SuperSQL are 0-based meaning the first element is at index 0 and
the last element is at index n-1 for an entity of size n.
If 1-based indexing is desired, a scoped language pragma may be used to specify either 1-based indexing or mixed indexing. In mixed indexing, 0-based indexing is used for expressions appearing in pipe operators and 1-based indexing is used for expressions appearing in SQL operators.
Note
Mixed indexing is not yet implemented.
Examples
Simple index
# spq
values a[2]
# input
{a:[1,2,3,4]}
{a:|[1,2,3,4]|}
{a:"1234"}
{a:0x01020304}
# expected output
3
3
error("missing")
error("missing")
One-based indexing
# spq
pragma index_base = 1
values a[2]
# input
{a:[1,2,3,4]}
{a:|[1,2,3,4]|}
{a:"1234"}
{a:0x01020304}
# expected output
2
2
error("missing")
error("missing")
Index from end of entity
# spq
values a[-1]
# input
{a:[1,2,3,4]}
{a:|[1,2,3,4]|}
{a:"1234"}
{a:0x01020304}
# expected output
4
4
error("missing")
error("missing")
Inputs
Inputs
Input data is processed by queries through expressions that contain input-data references.
In pipe scoping, input data
is always referenced as the special value this.
In relational scoping, input data
is referenced by specifying the columns of one or more tables.
See the SQL section for
details on how columns are bound to identifiers, how table references
are resolved, and how this behaves in a SQL expression.
The type of this may be any type.
When this is a record, references
to fields of the record may be referenced by an identifier that names the
field of the implied this value, e.g., x means this.x.
For expressions that appear in a SQL operator, input is presumed to be in the form of records and is referenced using relational scoping. Here, identifiers refer to table aliases and/or column names and are bound to the available inputs based on SQL semantics. When the input schema is known, these references are statically checked and compile-time errors are raised when invalid tables or columns are referenced.
In a SQL operator, if the input is not a record (i.e., not relational),
then the input data can still be referred to as the value this and placed
into an output relation using SELECT.
When referring to non-relational with *, there are no input columns and
thus the select value is empty, i.e., the value {}.
When non-record data is referenced in a SQL operator and the input
schema is dynamic and unknown, runtime errors like error("missing")
will generally arise and be present in the output data.
Examples
A simple reference to this
# spq
values this
# input
1
true
"foo"
# expected output
1
true
"foo"
Referencing a field of this
# spq
values this.x
# input
{x:1,y:4}
{x:2,y:5}
{x:3,y:6}
# expected output
1
2
3
Referencing an implied field of this
# spq
values x
# input
{x:1,y:4}
{x:2,y:5}
{x:3,y:6}
# expected output
1
2
3
Selecting a column of an input table in a SQL operator
# spq
SELECT x
# input
{x:1,y:4}
{x:2,y:5}
{x:3,y:6}
# expected output
{x:1}
{x:2}
{x:3}
Selecting a column of an named input table
# spq
let input = (
values
{x:1,y:4},
{x:2,y:5},
{x:3,y:6}
)
SELECT x FROM input
# input
# expected output
{x:1}
{x:2}
{x:3}
Literals
Literals
Literal values represent specific instances of a type embedded directly
into an expression like the integer 1, the record {x:1.5,y:-4.0},
or the mixed-type array [1,"foo"].
Any valid SUP serialized text is a valid literal in SuperSQL. In particular, complex-type expressions composed recursively of other literal values can be used to construct any complex literal value, e.g.,
Literal values of types enum, union, and named may be created with a cast.
Logic
Logic
The keywords and, or, not, and ! perform logic on operands of type bool.
The binary operators and and or operate on Boolean values and result in
an error value if either operand is not a Boolean. Likewise, not (and its
equivalent !) operates on its unary operand and results in an error if its
operand is not type bool. Unlike many other languages, non-Boolean values are
not automatically converted to Boolean type using “truthiness” heuristics.
Slices
Slices
A slice expression is a variation of an index index expression that returns a range of values instead of a single value and can be applied to sliceable data types. A slice has the form
<entity> [ <from> : <to> ]
where <entity> is an expression that returns an sliceable value
and <from> and <to> are expressions that are coercible to integers.
Sliceable entities include arrays, sets, strings, and bytes.
The value <from> and <to> represent a range of index values
to form a subset of elements from the <entity> term provided.
The range begins at the <from> position and ends one element before
the <to> position. A negative value of <from> or <to> represents
a position relative to the end of the value being sliced.
If the <entity> expression is an array, then the result is an array of
elements comprising the indicated range.
If the <entity> expression is a set, then the result is a set of
elements comprising the indicated range ordered by total order of values.
If the <entity> expression is a string, then the result is a substring
consisting of unicode code points comprising the given range.
If the <entity> expression is type bytes, then the result is a bytes sequence
consisting of bytes comprising the given range.
Index Base
The index base for slice expressions is determined identically to the index base for indexing. By default, slice indexes are zero based.
Examples
Simple slices
# spq
values a[1:3]
# input
{a:[1,2,3,4]}
{a:|[1,2,3,4]|}
{a:"1234"}
{a:0x01020304}
# expected output
[2,3]
|[2,3]|
"23"
0x0203
1-based slices
# spq
pragma index_base = 1
values a[1:3]
# input
{a:[1,2,3,4]}
{a:|[1,2,3,4]|}
{a:"1234"}
{a:0x01020304}
# expected output
[1,2]
|[1,2]|
"12"
0x0102
Prefix and suffix slices
# spq
values {prefix:a[:2],suffix:a[-2:-1]}
# input
{a:[1,2,3,4]}
{a:|[1,2,3,4]|}
{a:"1234"}
{a:0x01020304}
# expected output
{prefix:[1,2],suffix:[3]}
{prefix:|[1,2]|,suffix:|[3]|}
{prefix:"12",suffix:"3"}
{prefix:0x0102,suffix:0x03}
Subqueries
Subqueries
A subquery is a query embedded in an expression.
When the expression containing the subquery is evaluated, the query is run with an input consisting of a single value equal to the value being evaluated by the expression.
The syntax for a subquery is simply a query in parentheses as in
( <query> )
where <query> is any query, e.g., the query
values {s:(values "hello, world" | upper(this))}
results in in the value {s:"HELLO, WORLD"}.
Except for subqueries appearing as the right-hand side of an in operator, the result of a subquery must be a single value. When multiple values are generated, an error is produced.
For the in operator, any subquery on the right-hand side is always treated as an array subquery, thus providing compatibility with SQL syntax.
Array Subqueries
When multiple values are expected, an array subquery can be used to group the multi-valued result into a single-valued array.
The syntax for an array subquery is simply a query in square brackets as in
[ <query> ]
where <query> is any query, e.g., the query
values {a:[values 1,2,3 | values this+1]}
results in the value {a:[2,3,4]}.
An array subquery is shorthand for
( <query> | collect(this) )
e.g., the array subquery above could also be rewritten as
values {a:(values 1,2,3 | values this+1 | collect(this))}
Independent Subqueries
A subquery that depends on its input as described above is called a dependent subquery.
When the subquery ignores its input value, e.g., when it begins with a from operator, then they query is called an independent subquery.
For efficiency, the system materializes independent subqueries so that they are evaluated just once.
For example, this query
let input = (values 1,2,3)
values 3,4
| values {that:this,count:(from input | count())}
evaluates the subquery from input | count() just once and materializes the result.
Then, for each input value 3 and 4, the result is emitted, e.g.,
{that:3,count:3::uint64}
{that:4,count:3::uint64}
Correlated Subqueries
When a subquery appears within a SQL operator, relational scope is active and references to table aliases and columns may reach a scope that is outside of the subquery. In this case, the subquery is a correlated subquery.
Correlated subqueries are not yet supported. They are detected and a compile-time error is reported when encountered.
A correlated subquery can always be rewritten as a pipe subquery using unnest using this pattern:
unnest {outer:this,inner:[<query>]}
where <query> generates the correlated subquery values, then they can
be accessed as if the outer field is the outer scope and the inner field
is the subquery scope.
Named Subqueries
When a previously declared named query is referenced in an expression, it is automatically evaluated as a subquery, e.g.,
let q = (values 1,2,3 | max(this))
values q+1
outputs the value 4.
When a named query is expected to return multiple values, it should be referenced as an array subquery, e.g.,
let q = (values 1,2,3)
values [q]
outputs the value [1,2,3].
Recursive Subqueries
When subqueries are combined with recursive invocation of the function they appear in, some powerful patterns can be constructed.
For example, the visitor-walk pattern can be implemented using recursive subqueries and function values.
Here’s a template for walk:
fn walk(node, visit):
case kind(node)
when "array" then
[unnest node | walk(this, visit)]
when "record" then
unflatten([unnest flatten(node) | {key,value:walk(value, visit)}])
when "union" then
walk(under(node), visit)
else visit(node)
end
Note
In this case, we are traversing only records and arrays. Support for flattening and unflattening maps and sets is forthcoming.
Here, walk is invoking an array subquery on the unnested
entities (records or arrays), calling the walk function recursively on each item,
then assembling the results back into an array (i.e., the raw result of the array subquery)
or a record (i.e., calling unflatten on the key/value pairs returned in the array).
If we call walk with this function on an arbitrary nested value
fn addOne(node): case typeof(node) when <int64> then node+1 else node end
then each leaf value of the nested value of type int64 would be incremented
while the other leaves would be left alone. See the example below.
Examples
Operate on arrays with values shortcuts and arrange answers into a record
# spq
values {
squares:[unnest this | this*this],
roots:[unnest this | round(sqrt(this)*100)*0.01]
}
# input
[1,2,3]
[4,5]
# expected output
{squares:[1,4,9],roots:[1.,1.41,1.73]}
{squares:[16,25],roots:[2.,2.24]}
Multi-valued subqueries emit an error
# spq
values (values 1,2,3)
# input
# expected output
error("query expression produced multiple values (consider [subquery])")
Multi-valued subqueries can be invoked as an array subquery
# spq
values [values 1,2,3]
# input
# expected output
[1,2,3]
Right-hand side of “in” operator is always an array subquery
# spq
let data = (values {x:1},{x:2})
where this in (select x from data)
# input
1
2
3
# expected output
1
2
Independent subqueries in SQL operators are supported while correlated subqueries are not
# spq
let input = (values {x:1},{x:2},{x:3})
select *
from input
where x >= (select avg(x) from input)
# input
# expected output
{x:2}
{x:3}
Correlated subqueries in SQL operators not yet supported
# spq
select *
from (values (1),(2)) a(x)
where exists (
select 1
from (values (3),(4)) b(y)
where x=y
)
# input
# expected output
correlated subqueries not currently supported at line 6, column 9:
where x=y
~
Recursive subqueries inside function implementing the walk-visitor pattern
# spq
fn walk(node, visit):
case kind(node)
when "array" then
[unnest node | walk(this, visit)]
when "record" then
unflatten([unnest flatten(node) | {key,value:walk(value, visit)}])
when "union" then
walk(under(node), visit)
else visit(node)
end
fn addOne(node): case typeof(node) when <int64> then node+1 else node end
values walk(this, &addOne)
# input
1
[1,2,3]
[{x:[1,"foo"]},{y:2}]
# expected output
2
[2,3,4]
[{x:[2,"foo"]},{y:3}]
Types
Types
SuperSQL has a comprehensive types system that adheres to the super-structured data model comprising primitive types, complex types, sum types, named types, the null type, and first-class errors and types.
The syntax of individual literal values as well as types follows the SUP format in that any legal SUP value is also a valid SuperSQL literal.
Likewise, any SUP type is also valid type syntax, which may be used in cast expressions or type declarations.
Note that the type decorators in SUP utilize a double colon (::)
syntax that is compatible with cast expressions.
Arguments to functions and operators are all dynamically typed, yet certain functions expect certain specific types or classes of data types. The following names for these categories of types are used in throughout the documentation:
any- any SuperSQL data typefloat- any floating point typeint- any signed or unsigned integer typenumber- eitherfloatorintrecord- any record typeset- any set typemap- any map typefunction- a function reference of lambda expression
To be clear, none of these categorical names are actual types and may not be used in a SuperSQL query. They are simply used to document expected type categories.
Note
In a future version of SuperSQL, user-defined function and operator declarations will include optional type signatures and these names representing type categories may be included in the language for that purpose.
Numbers
Numbers
Numbers in SuperSQL follow the customary semantics and syntax of SQL and other programming languages and include:
Signed Integers
A 64-bit signed integer literal of type int64 is formed from
an optional minus character (-) followed by a sequence of one or more decimal digits
whose value is between -2^63 and 2^63 - 1 inclusively.
Values of signed integer of other widths can be created when reading external data that corresponds to such types or by casting numbers to the desired types. These signed types include:
int8,int16, andint32.
Note
The
int128type is not yet implemented in SuperDB.
For backward compatibility with SQL, syntactic aliases for signed integers are defined as follows:
BIGINTmaps toint64INTmaps toint32INTEGERmaps toint32SMALLINTmaps toint16
Unsigned Integers
A sequence of one or more decimal digits that has a value greater than
2^63 - 1 and less than 2^64 exclusively forms an unsigned 64-bit integer literal.
Values of unsigned integer of other widths can be created when reading external data that corresponds to such types or by casting numbers to the desired types. These unsigned types include:
uint8,uint16, anduint32.
Note
The
uint128type is not yet implemented in SuperDB.
Floating Point
A sequence of one or more decimal digits followed by a decimal point (.)
followed optionally by one or more decimal digits forms
a 64-bit IEEE floating point value of type float64.
Alternatively, a floating point value may appear in scientific notation
having the form of a mantissa number (integer or with decimal point)
followed by the character e and in turn followed by a signed integer exponent.
Also Inf, +Inf, -Inf, or NaN are valid 64-bit floating point numbers.
Floating-point values with widths other than float64
can be created when reading external data
that corresponds to such other types or by casting numbers to the desired
floating point type float32 or float16.
For backward compatibility with SQL, syntactic aliases for signed integers are defined as follows:
REALmaps tofloat32FLOATmaps tofloat64DOUBLE PRECISIONmaps tofloat64
Note
The
FLOAT(n)SQL types are not yet implemented by SuperSQL.
Decimal
Note
The
decimaltype is not yet implemented in SuperSQL.
Coercion
Mixed-type numeric values used in expressions are promoted via an implicit cast to the type that is best compatible with an operation or expected input type. This process is called coercion.
For example, in the expression
1::int8 + 1::int16
the 1::int8 value is cast to 1::int16 and the result is 2::int16.
Similarly, in
values 1::int8, 1::int16 | aggregate sum(this)
the input values to sum() are coerced to int64 and the result is
2::int64.
Note
Further details of coercion rules are forthcoming in a future version of this documentation.
Examples
Signed integers
# spq
values 1, 0, -1, 9223372036854775807
| values f"{this} is type {typeof(this)}"
# input
# expected output
"1 is type <int64>"
"0 is type <int64>"
"-1 is type <int64>"
"9223372036854775807 is type <int64>"
Other signed integer types
# spq
values 1, 200, 70000, 9223372036854775807
| values this::int8, this::int16, this::int32, this::int64
# input
# expected output
1::int8
1::int16
1::int32
1
error({message:"cannot cast to int8",on:200})
200::int16
200::int32
200
error({message:"cannot cast to int8",on:70000})
error({message:"cannot cast to int16",on:70000})
70000::int32
70000
error({message:"cannot cast to int8",on:9223372036854775807})
error({message:"cannot cast to int16",on:9223372036854775807})
error({message:"cannot cast to int32",on:9223372036854775807})
9223372036854775807
Unsigned integers
# spq
values 1, 200, 70000, 9223372036854775807
| values this::uint8, this::uint16, this::uint32, this::uint64
| values f"{this} is type {typeof(this)}"
# input
# expected output
"1 is type <uint8>"
"1 is type <uint16>"
"1 is type <uint32>"
"1 is type <uint64>"
"200 is type <uint8>"
"200 is type <uint16>"
"200 is type <uint32>"
"200 is type <uint64>"
error({message:"cannot cast to uint8",on:70000})
error({message:"cannot cast to uint16",on:70000})
"70000 is type <uint32>"
"70000 is type <uint64>"
error({message:"cannot cast to uint8",on:9223372036854775807})
error({message:"cannot cast to uint16",on:9223372036854775807})
error({message:"cannot cast to uint32",on:9223372036854775807})
"9223372036854775807 is type <uint64>"
Floating-point numbers
# spq
values 1., 1.23, 18446744073709551615., 1.e100, +Inf, -Inf, NaN
| values f"{this} is type {typeof(this)}"
# input
# expected output
"1 is type <float64>"
"1.23 is type <float64>"
"1.8446744073709552e+19 is type <float64>"
"1e+100 is type <float64>"
"+Inf is type <float64>"
"-Inf is type <float64>"
"NaN is type <float64>"
Other floating-point types
# spq
values 1., 1.23, 18446744073709551615., 1.e100, +Inf, -Inf, NaN
| values this::float16, this::float32, this::float64
| values f"{this} is type {typeof(this)}"
# input
# expected output
"1 is type <float16>"
"1 is type <float32>"
"1 is type <float64>"
"1.23046875 is type <float16>"
"1.2300000190734863 is type <float32>"
"1.23 is type <float64>"
"+Inf is type <float16>"
"1.8446744073709552e+19 is type <float32>"
"1.8446744073709552e+19 is type <float64>"
"+Inf is type <float16>"
"+Inf is type <float32>"
"1e+100 is type <float64>"
"+Inf is type <float16>"
"+Inf is type <float32>"
"+Inf is type <float64>"
"-Inf is type <float16>"
"-Inf is type <float32>"
"-Inf is type <float64>"
"NaN is type <float16>"
"NaN is type <float32>"
"NaN is type <float64>"
Strings
Strings
The string type represents any valid
UTF-8 string.
For backward compatibility with SQL, syntactic aliases for type string
are defined as follows:
CHARACTER VARYINGCHARACTERTEXTVARCHAR
A string is formed by enclosing the string’s unicode characters in quotation marks whereby the following escape sequences allowed:
| Sequence | Unicode Character |
|---|---|
\" | quotation mark U+0022 |
\' | apostrophe U+0008 |
\\ | reverse solidus U+005C |
\/ | solidus U+002F |
\b | backspace U+0008 |
\f | form feed U+000C |
\n | line feed U+000A |
\r | carriage return U+000D |
\t | tab U+0009 |
\uXXXX | U+XXXX |
The backslash character (\) and the control characters (U+0000 through U+001F)
must be escaped.
In SQL expressions, the quotation mark is a single quote character (')
and in pipe expressions, the quotation mark may be either single quote or
double quote (").
In single-quote strings, the single-quote character must be escaped and in double-quote strings, the double-quote character must be escaped.
Raw String
Raw strings or r-strings
are expressed as the character r followed by a single- or double-quoted
string, where any backslash characters are treated literally and not as an
escape sequence. For example, r'foo\bar' is equivalent to 'foo\\bar'.
Unlike other string syntaxes, raw strings may have embedded newlines, e.g.,
a literal newline inside of the string rather than the character sequence \n
as in
r"foo
bar"
Formatted Strings
Formatted strings or
f-strings are expressed
as the character f followed by a single- or double-quoted
string and may contain embedded expressions denoted within
curly braces { }.
Examples
Various strings
# spq
values 'hello, world', len('foo'), "SuperDB", "\"quoted\"", 'foo'+'bar'
# input
# expected output
"hello, world"
3
"SuperDB"
"\"quoted\""
"foobar"
String literal vs field identifier in a SQL SELECT statement
# spq
SELECT 'x' as s, "x" as x
# input
{x:1}
{x:2}
# expected output
{s:"x",x:1}
{s:"x",x:2}
Formatted strings
# spq
values f'{x} + {y} is {x+y}'
# input
{x:1,y:3}
{x:2,y:4}
# expected output
"1 + 3 is 4"
"2 + 4 is 6"
Raw strings
# spq
values r'foo\nbar\t'
# input
# expected output
"foo\\nbar\\t"
Raw strings with embedded newline
# spq
values r'foo
bar'
# input
# expected output
"foo\nbar"
Booleans
Booleans
The bool type represents a type that has the values true, false,
or null.
For backward compatibility with SQL, BOOLEAN is a syntactic alias for type bool.
Examples
Comparisons produces Boolean values
# spq
values 1==1, 1>2
# input
# expected output
true
false
Booleans are used as predicates
# spq
values 1==1, 1>2
# input
# expected output
true
false
Booleans operators perform logic on Booleans
# spq
values {and:a and b, or:a or b}
# input
{a:false,b:false}
{a:false,b:true}
{a:true,b:true}
# expected output
{and:false,or:false}
{and:false,or:true}
{and:true,or:true}
Boolean aggregate functions
# spq
aggregate andA:=and(a), andB:=and(b), orA:=or(a), orB:=or(b)
# input
{a:false,b:false}
{a:false,b:true}
# expected output
{andA:false,andB:false,orA:false,orB:true}
Bytes
Bytes
The bytes type represents an arbitrary sequence of 8-bit bytes.
The character sequence 0x followed by an even number of hexadecimal
digits forms a bytes type.
An empty bytes value is simply 0x followed by no digits.
For backward compatibility with SQL, BYTEA is a syntactic alias for type bytes.
Examples
# spq
values
0x0102beef,
'hello, world'::bytes,
len(0x010203),
0x,
null::bytes
# input
# expected output
0x0102beef
0x68656c6c6f2c20776f726c64
3
0x
null::bytes
Networks/IPs
Networks/IPs
The ip type represents an internet address and supports both
IPv4 and IPv6 variations. The net type represents an ip value
with a contiguous network mask as indicated by the number of bits
in the mask.
For backward compatibility with SQL, syntactic aliases for signed integers are defined as follows:
CIDRmaps tonetINETmaps toip
A 32-bit IPv4 address is formed using dotted-decimal notation, e.g.,
a string of base-256 decimal numbers separated by . as in
128.32.130.100 or 10.0.0.1.
A 128-bit IPv6 is formed from a sequence of eight groups of four
hexadecimal digits separated by colons (:).
For IPv6 addresses,
leading zeros in each group can be omitted (e.g., the sequence 2001:0db8
becomes 2001:db8) and consecutive groups of zeros can be compressed
using a double colon (::) but this can only be done once to avoid ambiguity, e.g.,
2001:0db8:0000:0000:0000:0000:0000:0001
can be expressed as 2001:db8::1.
A value of type net is formed as an IPv4 or IPv6 address followed by a slash (/)
followed by a decimal integer indicating the numbers of bits of contiguous network as
in 128.32.130.100/24 or fc00::/7.
Note that unlike other SQL dialects that require IP addresses and networks to be formatted inside quotation marks, SuperSQL treats these data types as first-class elements that need not be quoted.
Examples
# spq
values
128.32.130.100,
10.0.0.1,
::2001:0db8,
2001:0db8:0000:0000:0000:0000:0000:0001
| values this, typeof(this)
# input
# expected output
128.32.130.100
<ip>
10.0.0.1
<ip>
::2001:db8
<ip>
2001:db8::1
<ip>
# spq
values 128.32.130.100/24, fc00::/7
| values this, typeof(this)
# input
# expected output
128.32.130.0/24
<net>
fc00::/7
<net>
Date/Times
Date/Times
Warning
These data types are going to change in a forthcoming release of SuperSQL.
The time type represents an unsigned 64-bit number of nanoseconds since epoch.
The duration type represents a signed 64-bit number of nanoseconds.
For backward compatibility with SQL, INTERVAL is a syntactic alias for type duration.
These data types are incompatible with SQL data and time types. A future version
of SuperSQL will change the time type to be SQL compatible and add support for other
SQL date/time and interval types. At that time, detailed syntax and semantics
for these types will be documented here.
Type Values
Type Values
Types in SuperSQL are first class and conform
with the type type in the
super-structured data model.
The type type represents the type of a type value.
A type value is formed by enclosing a type specification in
angle brackets (< followed by the type followed by >).
For example, the integer type int64 is expressed as a value
with the syntax <int64>.
The syntax for primitive type names are listed in the data model specification and have the same syntax in SuperSQL. Complex types also follow the SUP syntax for types.
Note that the type of a type value is simply type.
Here are a few examples of complex types:
- a simple record type -
{x:int64,y:int64} - an array of integers -
[int64] - a set of strings -
|[string]| - a map of strings keys to integer values -
|{string,int64}| - a union of string and integer -
string|int64
Complex types may be composed in a nested fashion,
as in [{s:string}|{x:int64}] which is an array of type
union of two types of records.
The typeof function returns a value’s type as
a value, e.g., typeof(1) is <int64> and typeof(<int64>) is <type>.
Note the somewhat subtle difference between a record value with a field t of
type type whose value is type string
{t:<string>}
and a record type used as a value
<{t:string}>
First-class types are quite powerful because types can serve as grouping keys or be used in data shaping logic. A common workflow for data introspection is to first perform a search of exploratory data and then count the shapes of each type of data as follows:
search ... | count() by typeof(this)
Examples
Various type examples using f-string and typeof
# spq
values f"{this} has type {typeof(this)}"
# input
1
"foo"
1.5
192.168.1.1
192.168.1.0/24
[1,"bar"]
|[1,2,3]|
2025-08-21T21:22:18.046568Z
1d3h
<int64>
# expected output
"1 has type <int64>"
"foo has type <string>"
"1.5 has type <float64>"
"192.168.1.1 has type <ip>"
"192.168.1.0/24 has type <net>"
"[1,\"bar\"] has type <[int64|string]>"
"|[1,2,3]| has type <|[int64]|>"
"2025-08-21T21:22:18.046568Z has type <time>"
"1d3h has type <duration>"
"<int64> has type <type>"
Count the different types in the input
# spq
count() by typeof(this) | sort this
# input
1
2
"foo"
10.0.0.1
<string>
# expected output
{typeof:<int64>,count:2::uint64}
{typeof:<string>,count:1::uint64}
{typeof:<ip>,count:1::uint64}
{typeof:<type>,count:1::uint64}
Records
Records
Records conform to the record type in the super-structured data model and follow the syntax of records in the SUP format, i.e., a record type has the form
{ <name> : <type>, <name> : <type>, ... }
where <name> is an identifier or string
and <type> is any type.
Any SUP text defining a record value is a valid record literal in the SuperSQL language.
For example, this is a simple record value
{number:1,message:"hello,world"}
whose type is
{number:int64,message:string}
An empty record value and an empty record type are both represented as {}.
Records can be created by reading external data (SUP files, database data, Parquet values, JSON objects, etc) or by constructing instances using record expressions or other SuperSQL functions that produce records.
Record Expressions
Record values are constructed from a record expression that is comprised of zero or more comma-separated elements contained in braces:
{ <element>, <element>, ... }
where an <element> has one of three forms:
- a named field of the form
<name> : <expr>where<name>is an identifier or string and<expr>is any expression, - any expression by itself where the field name is derived from the expression text as defined below, or
- a spread expression of the form
...<expr>where<expr>is an arbitrary expression that should evaluate to a record value.
The spread form inserts all of the fields from the resulting record. If a spread expression results in a non-record type (e.g., errors), then that part of the record is simply elided. Note that the field names for the spread come from the constituent record values.
The fields of a record expression are evaluated left to right and when field names collide the rightmost instance of the name determines that field’s value.
Derived Field Names
When an expression is present without a field name, the field name is derived from the expression text as follows:
- for a dotted path expression, the name is the last element of the path;
- for a function or aggregate function, the name is the name of the function;
- for
this, the name isthat; - otherwise, the name is the expression text formatted in a canonical form.
Examples
A simple record literal
# spq
values {a:1,b:2,s:"hello"}
# input
# expected output
{a:1,b:2,s:"hello"}
A record expression with spreads operating on various input values
# spq
values {a:0},{x}, {...r}, {a:0,...r,b:3}
# input
{x:1,y:2,r:{a:1,b:2}}
# expected output
{a:0}
{x:1}
{a:1,b:2}
{a:1,b:3}
A record literal with casts
# spq
values {b:true,u:1::uint8,a:[1,2,3],s:"hello"::=CustomString}
# input
# expected output
{b:true,u:1::uint8,a:[1,2,3],s:"hello"::=CustomString}
A record expression with an unnamed expression
# spq
values {1+2*3}
# input
# expected output
{"1+2*3":7}
Selecting a record expression with an unnamed expression
# spq
select {1+2*3} as x
# input
# expected output
{x:{"1+2*3":7}}
Arrays
Arrays
Arrays conform to the array type in the super-structured data model and follow the syntax of arrays in the SUP format, i.e., an array type has the form
[ <type> ]
where <type> is any type.
Any SUP text defining an array value is a valid array literal in the SuperSQL language.
For example, this is a simple array value
[1,2,3]
whose type is
[int64]
An empty array value has the form [] and
an empty array type defaults to an array of type null, i.e., [null],
unless otherwise cast, e.g., []::[int64] represents an empty array
of integers.
Arrays can be created by reading external data (SUP files, database data, Parquet values, JSON objects, etc) or by constructing instances using array expressions or other SuperSQL functions that produce arrays.
Array Expressions
Array values are constructed from an array expression that is comprised of zero or more comma-separated elements contained in brackets:
[ <element>, <element>, ... ]
where an <element> has one of two forms:
<expr>
or
...<expr>
<expr> may be any valid expression.
The first form is simply an element in the array, the result of <expr>.
The second form is the array spread operator ...,
which expects an array or set value as
the result of <expr> and inserts all of the values from the result. If a spread
expression results in neither an array nor set, then the value is elided.
When the expressions result in values of non-uniform type, then the types of the array elements become a sum type of the types present, tied together with the corresponding union type.
Examples
# spq
values [1,2,3],["hello","world"]
# input
# expected output
[1,2,3]
["hello","world"]
Arrays can be concatenated using the spread operator
# spq
values [...a,...b,5]
# input
{a:[1,2],b:[3,4]}
# expected output
[1,2,3,4,5]
Arrays with mixed type are tied together with a union type
# spq
values typeof([1,"foo"])
# input
# expected output
<[int64|string]>
The collect aggregate function builds an array and uses a sum type for the mixed-type elements
# spq
collect(this) | values this, typeof(this)
# input
1
2
3
"hello"
"world"
# expected output
[1,2,3,"hello","world"]
<[int64|string]>
Sets
Sets
Sets conform to the set type in the super-structured data model and follow the syntax of sets in the SUP format, i.e., a set type has the form
|[ <type> ]|
where <type> is any type.
Any SUP text defining a set value is a valid set literal in the SuperSQL language.
For example, this is a simple set value
|[1,2,3]|
whose type is
|[int64]|
An empty set value has the form |[]| and
an empty set type defaults to a set of type null, i.e., |[null]|,
unless otherwise cast, e.g., |[]|::[int64] represents an empty set
of integers.
Sets can be created by reading external data (SUP files, database data, Parquet values, JSON objects, etc) or by constructing instances using set expressions or other SuperSQL functions that produce sets.
Set Expressions
Set values are constructed from a set expression that is comprised of zero or more comma-separated elements contained in pipe brackets:
|[ <element>, <element>, ... ]|
where an <element> has one of two forms:
<expr>
or
...<expr>
<expr> may be any valid expression.
The first form is simply an element in the set, the result of <expr>.
The second form is the set spread operator ...,
which expects an array or set value as
the result of <expr> and inserts all of the values from the result. If a spread
expression results in neither an array nor set, then the value is elided.
When the expressions result in values of non-uniform type, then the types of the set elements become a sum type of the types present, tied together with the corresponding union type.
Examples
# spq
values |[3,1,2]|,|["hello","world","hello"]|
# input
# expected output
|[1,2,3]|
|["hello","world"]|
Arrays and sets can be concatenated using the spread operator
# spq
values |[...a,...b,4]|
# input
{a:[1,2],b:|[2,3]|}
# expected output
|[1,2,3,4]|
# spq
values [1,2,3],["hello","world"]
# input
# expected output
[1,2,3]
["hello","world"]
Sets with mixed types are tied together with a union type
# spq
values typeof(|[1,"foo"]|)
# input
# expected output
<|[int64|string]|>
The union aggregate function builds a set using a sum type for the mixed-type elements
# spq
union(this) | values this, typeof(this)
# input
1
2
3
1
"hello"
"world"
"hello"
# expected output
|[1,2,3,"hello","world"]|
<|[int64|string]|>
Maps
Maps
Maps conform to the map type in the super-structured data model and follow the syntax of maps in the SUP format, i.e., a map type has the form
|{ <key> : <value> }|
where <key> and <value> are any types and represent the keys
and values types of the map.
Any SUP text defining a map value is a valid map literal in the SuperSQL language.
For example, this is a simple map value
|{"foo":1,"bar":2}|
whose type is
|{string:int64}|
An empty map value has the form |{}| and
an empty map type defaults to a map with null types, i.e., |{null:null}|,
unless otherwise cast, e.g., |{}|::|{string:int64}| represents an empty
map of string keys and integer values.
Maps can be created by reading external data (SUP files, database data, Parquet values, etc) or by constructing instances using map expressions or other SuperSQL functions that produce maps.
Map Expressions
Map values are constructed from a map expression that is comprised of zero or more comma-separated key-value pairs contained in pipe braces:
|{ <key> : <value>, <key> : <value> ... }|
where <key> and <value>
may be any valid expression.
Note
The map spread operator is not yet implemented.
When the expressions result in values of non-uniform type of either the keys or the values, then their types become a sum type of the types present, tied together with the corresponding union type.
Examples
# spq
values |{"foo":1,"bar"+"baz":2+3}|
# input
# expected output
|{"foo":1,"barbaz":5}|
Look up network of host in a map and annotate if present
# spq
const networks = |{
192.168.1.0/24:"private net 1",
192.168.2.0/24:"private net 2",
10.0.0.0/8:"net 10"
}|
note:=coalesce(networks[network_of(host)], f"unknown network for host {host}")
# input
{host:192.168.1.100}
{host:192.168.2.101}
{host:192.168.3.102}
{host:10.0.0.1}
# expected output
{host:192.168.1.100,note:"private net 1"}
{host:192.168.2.101,note:"private net 2"}
{host:192.168.3.102,note:"unknown network for host 192.168.3.102"}
{host:10.0.0.1,note:"net 10"}
Unions
Unions
The union type provides the foundation for sum types in SuperSQL.
Unions conform with the definition of the union type in the super-structured data model and follow the syntax of unions in the SUP format, i.e., a union type has the form
<type> | <type>, ...
where <type> is any type and the set of types are unique.
A union literal can be created by casting a literal that is a member of a union type to that union type, e.g.,
1::(int64|string)
When the type of the value cast to a union is not a member of that union, an attempt is made to coerce the value to one of available member types.
To precisely such control coercion, an explicit first cast may be used as in
1::int8::(int8|int64|string)
Union values can be created by reading external data (SUP files,
database data, JSON objects, etc),
by constructing instances with a type cast
as above, or with other SuperSQL functions or expressions that produce unions
like the fuse operator.
Union values are also created when array, set, and map expressions encounter mix-typed elements that automatically express as union values.
For example,
values typeof([1,"foo"])
results in <[int64|string]>.
Union Value Semantics
Internally, every union value includes a tag indicating which of its member types the value belongs to along with that actual value.
In many languages, such tags are explicit names called a discriminant
and the underlying value can be accessed with a dot operator, e.g.,
u.a where u is a union value and a is the discriminant. When
the instance of u does not correspond to the type indicated by a,
the result might be null.
In other languages, the discriminant is the type name, e.g.,
u.(int64).
However, SuperSQL is polymorphic so there is no requirement to explicitly discriminate the member type of a union value. When an expression operator or function references a union value in computation, then the underlying value in its member type is automatically expressed from the union value.
For example, this predicate is true
values 1::(int64|string)==1
because the union value is automatically expressed as 1::int64
by the comparison operator.
Likewise
values 1::(int64|string)+2::(int64|string)
results in 3::int64. Note that because of automatic expression,
the union type is not retained here.
Passing a union value to a function, however, does not involve evaluation and thus automatic expression does not occur here, e.g.,
values typeof(1::(int64|string))
is <int64|string> because the union value is not automatically
expressed as 1::int64 when it is passed to the
typeof function.
When desired, the under function may be
used to express the underlying value explicitly. For example,
values typeof(under(1::(int64|string)))
results in <int64>.
Union Dispatch
Languages with sum types often include a construct to dispatch the union to a case for each of its possible types.
Because SuperSQL is polymorphic, union dispatch is not generally needed. Instead, union values are simply operated upon and the “right thing happens”.
That said, union dispatch may be accomplished with the
switch operator or a
case expression.
For example, switch can be used to route union values to different
branches of a query:
values
{u:1::(int64|string|ip)},
{u:"foo"::(int64|string|ip)},
{u:192.168.1.1::(int64|string|ip)}
| switch typeof(under(u))
case <int64> ( ... )
case <string> ( ... )
case <ip> ( ... )
default ( values error({message: "unknown type", on:this}) )
| ...
Note
Note the presence of a default case above. In statically typed languages with sum types, the compiler can ensure that all possible cases for a union are covered and report an error otherwise. In this case, there would be no need for a default. A future version of SuperSQL will include more comprehensive compile-time type checking and will include a mechanism for explicit union dispatch with static type checking.
A case expression can also be used to dispatch union values inside of
an expression as in
values
{u:1::(int64|string|ip)},
{u:"foo"::(int64|string|ip)},
{u:192.168.1.1::(int64|string|ip)}
| values
case typeof(under(u))
when <int64> then u+1
when <string> then upper(u)
when <ip> then network_of(u)
else "unknown"
end
Examples
Cast primitive values to a union type
# spq
values this::(int64|string)
# input
1
"foo"
# expected output
1::(int64|string)
"foo"::(int64|string)
Explicitly express the underlying union value using
under
# spq
values under(this)
# input
1::(int64|string)
# expected output
1
Take the type of mixed-type array showing its union-typed construction
# spq
typeof(this)
# input
[1,"foo"]
# expected output
<[int64|string]>
Enums
Enums
The enum type represents a set of symbols, e.g., to represent
categories by name.
It conforms to the definition of the
enum type
in the super-structured data model and follows the
syntax
of enums in the SUP format, i.e.,
an enum type has the form
enum ( <name>, <name>, ... )
where <name> is an identifier or string.
For example, this is a simple enum type:
enum(hearts,diamonds,spades,clubs)
and this is a value of that type:
'hearts'::enum(hearts,diamonds,spades,clubs)
Enum serialization in the SUP format is fairly verbose as the set of symbols must be enumerated anywhere the type appears. In the binary formats of BSUP and CSUP, the enum symbols are encoded efficiently just once.
Examples
# spq
const suit = <enum(hearts,diamonds,spades,clubs)>
values f"The value {this} {is(this, suit)? 'is' : 'is not'} a suit enum"
# input
"hearts"
"diamonds"::enum(hearts,diamonds,spades,clubs)
# expected output
"The value hearts is not a suit enum"
"The value diamonds is a suit enum"
Errors
Errors
Errors in SuperSQL are first class and conform with the error type in the super-structured data model.
Error types have the form
error ( <type> )
where <type> is any type.
Error values can be created with the error function of the form
error ( <value > )
where <value> is any value.
Error values can also be created by reading external data (SUP files or database data) that contains serialized error values or they can arise when any operator or function encounters an error and produces an error value to describe the condition.
In general, expressions and functions that result in errors simply return
a value as an error type as a result. This encourages a powerful flow-style
of error handling where errors simply propagate from one operation to the
next and land in the output alongside non-error values to provide a very helpful
context and rich information for tracking down the source of errors. There is
no need to check for error conditions everywhere or look through auxiliary
logs to find out what happened.
The value underneath an error can be accessed using the
under function.
For example,
values [1,2,3] | put x:=1
produces the error
error({message:"put: not a record",on:[1,2,3]})
and the original value in the on field can be recovered with under, i.e.,
values [1,2,3] | put x:=1 | values under(this).on
produces
[1,2,3]
Structured Errors
First-class errors are particularly useful for creating structured errors. When a SuperSQL query encounters a problematic condition, instead of silently dropping the problematic error and logging an error obscurely into some hard-to-find system log as so many ETL pipelines do, the offending value can be wrapped as an error and propagated to its output.
For example, suppose a bad value shows up:
{kind:"bad", stuff:{foo:1,bar:2}}
A data-shaping query applied to ingested data
could catch the bad value (e.g., as a default
case in a switch) and propagate it as
an error using the expression:
values error({message:"unrecognized input type",on:this})
then such errors could be detected and searched for downstream with the
is_error function.
For example,
is_error(this)
on the wrapped error from above produces
error({message:"unrecognized input",input:{kind:"bad", stuff:{foo:1,bar:2}}})
There is no need to create special tables in a complex warehouse-style ETL to land such errors as they can simply land next to the output values themselves.
And when transformations cascade one into the next as different stages of an ETL pipeline, errors can be wrapped one by one forming a “stack trace” or lineage of where the error started and what stages it traversed before landing at the final output stage.
Errors will unfortunately and inevitably occur even in production, but having a first-class data type to manage them all while allowing them to peacefully coexist with valid production data is a novel and useful approach that SuperSQL enables.
Missing and Quiet
SuperDB’s heterogeneous data model allows for queries
that operate over different types of data whose structure and type
may not be known ahead of time, e.g., different
types of records with different field names and varying structure.
Thus, a reference to a field, e.g., this.x may be valid for some values
that include a field called x but not valid for those that do not.
What is the value of x when the field x does not exist?
A similar question faced SQL when it was adapted in various different forms
to operate on semi-structured data like JSON or XML. SQL already had the null value
so perhaps a reference to a missing value could simply be null.
But JSON also has null, so a reference to x in the JSON value
{"x":null}
and a reference to x in the JSON value
{}
would have the same value of null. Furthermore, an expression like
x is null
could not differentiate between these two cases.
To solve this problem, the missing value was proposed to represent the value that
results from accessing a field that is not present. Thus, x is null and
x is missing could disambiguate the two cases above.
SuperSQL, instead, recognizes that the SQL value missing is a paradox:
I’m here but I’m not.
In reality, a missing value is not a value. It’s an error condition
that resulted from trying to reference something that didn’t exist.
So why should we pretend that this is a bona fide value? SQL adopted this approach because it lacks first-class errors.
But SuperSQL has first-class errors so
a reference to something that does not exist is an error of type
error(string) whose value is error("missing"). For example,
# spq
values x
# input
{x:1}
{y:2}
# expected output
1
error("missing")
Sometimes you want missing errors to show up and sometimes you don’t.
The quiet function transforms missing errors into
“quiet errors”. A quiet error is the value error("quiet") and is ignored
by most operators, in particular,
values, e.g.,
values error("quiet")
produces no output.
Examples
Any value can be an error
# spq
error(this)
# input
0
"foo"
10.0.0.1
{x:1,y:2}
# expected output
error(0)
error("foo")
error(10.0.0.1)
error({x:1,y:2})
Divide by zero error
# spq
1/this
# input
0
# expected output
error("divide by zero")
The error type corresponding to an error value
# spq
typeof(1/this)
# input
0
# expected output
<error(string)>
The quiet function suppresses error values
# spq
values quiet(x)
# input
{x:1}
{y:2}
# expected output
1
Coalesce replaces error("missing") values with a default value
# spq
values coalesce(x, 0)
# input
{x:1}
{y:2}
# expected output
1
0
Named Types
Named Types
A named type provides a means to bind a symbolic name to a type and conforms to the named type in the super-structured data model. The named type syntax follows that of SUP format, i.e., a named type has the form
(<name>=<type>)
where <name> is an identifier or string and <type> is any type.
Named types may be defined in four ways:
- with a type declaration,
- with a cast,
- with a definition inside of another type, or
- by the input data itself.
For example, this expression
80::(port=uint16)
casts the integer 80 to a named type called port whose type is uint16.
Alternatively, named types can be declared with a type statement, e.g.,
type port = int16
values 80::port
produces the value 80::(port=uint16) as above.
Type name definitions can be embedded in another type, e.g.,
type socket = {addr:ip,port:(port=uint16)}
defines a named type socket that is a record with field addr of type ip
and field port of type port, where type port is a named type for type uint16 .
Named types may also be defined by the input data itself, as super-structured data is comprehensively self describing. When named types are defined in the input data, there is no need to declare their type in a query. In this case, a SuperSQL expression may refer to the type by the name that simply appears to the runtime as a side effect of operating upon the data.
When the same name is bound to different types, a reference to that name is undefined except for the definitions within a single nested value, in which case, the most recent binding in depth-first order is used to resolve a reference to a type name.
Examples
Filter on a type name defined in the input data
# spq
where typeof(this)==<foo>
# input
1::=foo
2::=bar
3::=foo
# expected output
1::=foo
3::=foo
Emit a type name defined in the input data
# spq
values <foo>
# input
1::=foo
# expected output
<foo=int64>
Emit a missing value for an unknown type name
# spq
values <foo>
# input
1
# expected output
error("missing")
Conflicting named types appear as distinct type values
# spq
count() by typeof(this) | sort this
# input
1::=foo
2::=bar
"hello"::=foo
3::=foo
# expected output
{typeof:<bar=int64>,count:1::uint64}
{typeof:<foo=int64>,count:2::uint64}
{typeof:<foo=string>,count:1::uint64}
Nulls
Nulls
The null type represents a type that has just one value:
the special value null.
A value of type null is formed simply from the keyword null
representing the null value, which by default, is type null.
While all types include a null value, e.g., null::int64 is the
null value whose type is int64, the null type has no other values
besides the null value.
In relational SQL, a null typically indicates an unknown value. Unfortunately, this concept is overloaded as unknown values may arise from runtime errors, missing data, or an intentional value of null.
Because SuperSQL has first-class errors (obviating the need to serialize error conditions as fixed-type nulls) and sum types (obviating the need to flatten sum types into columns and occupy the absent component types with nulls), the use of null values is discouraged.
That said, SuperSQL supports the null value for backward compatibility with their pervasive use in SQL, database systems, programming languages, and serialization formats.
As in SQL, to test if a value is null, it cannot be compared to another null
value, which by definition, is always false, i.e., two unknown values cannot
be known to be equal. Instead the IS NULL operator or
coalesce function should be used.
Examples
The null value
# spq
values typeof(null)
# input
# expected output
<null>
Test for null with IS NULL
# spq
values
this == null,
this != null,
this IS NULL,
this IS NOT NULL
# input
# expected output
null::bool
null::bool
true
false
Missing values are not null values
# spq
values {out:y}
# input
{x:1}
{x:2,y:3}
null
# expected output
{out:error("missing")}
{out:3}
{out:error("missing")}
Use coalesce to easily skip over nulls and missing values
# spq
const DEFAULT = 100
values coalesce(y,x,DEFAULT)
# input
{x:1}
{x:2,y:3}
{x:4,y:null}
null
# expected output
1
3
4
100
All types have a null value
# spq
values cast(null, this)
# input
<int64>
<string>
<int64|string>
<{x:int64,s:string}>
<[string]>
# expected output
null::int64
null::string
null::(int64|string)
null::{x:int64,s:string}
null::[string]
Operators
Operators
The components of a SuperSQL pipeline are called pipe operators. Each operator is identified by its name and performs a specific operation on a sequence of values.
Some operators, like
aggregate and sort,
read all of their input before producing output, though
aggregate can produce incremental results when the grouping key is
aligned with the order of the input.
For large queries that process all of their input, time may pass before seeing any output.
On the other hand, most operators produce incremental output by operating
on values as they are produced. For example, a long running query that
produces incremental output streams its results as produced, i.e.,
running super to standard output
will display results incrementally.
The search and where
operators “find” values in their input and drop
the ones that do not match what is being looked for.
The values operator emits one or more output values
for each input value based on arbitrary expressions,
providing a convenient means to derive arbitrary output values as a function
of each input value.
The fork operator copies its input to parallel
branches of a pipeline, while the switch operator
routes each input value to only one corresponding branch
(or drops the value) based on the switch clauses.
While the output order of parallel branches is undefined,
order may be reestablished by applying a sort at the merge point of
the switch or fork.
Field Assignment
Several pipe operators manipulate records by modifying fields or by creating new records from component expressions.
For example,
- the
putoperator adds or modifies fields, - the
cutoperator extracts a subset of fields, and - the
aggregateoperator forms new records from aggregate functions and grouping expressions.
In all of these cases, the SuperSQL language uses the syntax := to denote
field assignment and has the form:
<field> := <expr>
For example,
put x:=y+1
or
aggregate salary:=sum(income) by state:=lower(state)
This style of “assignment” to a record value is distinguished from the =
symbol, which denotes Boolean equality.
The field name and := symbol may also be omitted and replaced with just the expression,
as in
aggregate count() by upper(key)
or
put lower(s), a.b.c, x+1
In this case, the field name is derived using the same rules that determine the field name of an unnamed record field.
In the two examples above, the derived names are filled in as follows:
aggregate count:=count() by upper:=upper(key)
put lower:=lower(s), c:=a.b.c, `x+1`:=x+1
Call
In addition to the built-in operators, new operators can be declared that take parameters and operate on input just like the built-ins.
A declared operator is called using the call keyword:
call <id> [<arg> [, <arg> ...]]
where <id> is the name of the operator and each <arg> is an
expression or
function reference.
The number of arguments must match the number
of parameters appearing in the operator declaration.
The call keyword is optional when the operator name does not
syntactically conflict with other operator syntax.
Shortcuts
When interactively composing queries (e.g., within SuperDB Desktop), it is often convenient to use syntactic shortcuts to quickly craft queries for exploring data interactively as compared to a “coding style” of query writing.
Shortcuts allow certain operator names to be optionally omitted when they can be inferred from context and are available for:
For example, the SQL expression
SELECT count(),type GROUP BY type
is more concisely represented in pipe syntax as
aggregate count() by type
but even more succinctly expressed as
count() by type
Here, the syntax of the aggregate operator is unambiguous so
the aggregate keyword may be dropped.
Similarly, an expression situated in the position of a pipe operator implies a values shortcut, e.g.,
{a:x+1,b:y-1}
is shorthand for
values {a:x+1,b:y-1}
Note
The values shortcut means SuperSQL provides a calculator experience, e.g., the command
super -c '1+1'emits the value2.
When the expression is Boolean-valued, however, the shortcut is where instead of values providing a convenient means to filter values. For example
x >= 1
is shorthand for
where x >= 1
Finally the put operator can be used as a shortcut where a list
of field assignments may omit the put keyword.
For example, the operation
put a:=x+1,b:=y-1
can be expressed simply as
a:=x+1,b:=y-1
To confirm the interpretation of a shortcut, you can always check the compiler’s
actions by running super with the -C flag to print the parsed query
in a “canonical form”, e.g.,
super -C -c 'x >= 1'
super -C -c 'count() by type'
super -C -c '{a:x+1,b:y-1}'
super -C -c 'a:=x+1,b:=y-1'
produces
where x>=1
aggregate
count() by type
values {a:x+1,b:y-1}
put a:=x+1,b:=y-1
When composing long-form queries that are shared via SuperDB Desktop or managed in GitHub, it is best practice to include all operator names in the query text.
aggregate
Operator
aggregate — execute aggregate functions with optional grouping expressions
Synopsis
[aggregate] <agg> [, <agg> ... ] [ by <grouping> [, <grouping> ... ] ]
[aggregate] by <grouping> [, <grouping> ... ]
where <agg> references an aggregate function
optionally structured as a field assignment
having the form:
[ <field> := ] <agg-func> ( [ all | distinct ] <expr> ) [ where <pred> ]
and <grouping> is a grouping expression field assignment
having the form:
[ <field> := ] <expr>
Description
The aggregate operator applies
aggregate functions to
partitioned groups of its input values to reduce each group to one output value
where the result of each aggregate function appears as a field of the result.
Each group corresponds to the unique values of the <grouping> expressions.
When there are no <grouping> expressions, the aggregate functions are applied
to the entire input optionally filtered by <pred>.
In the first form, the aggregate operator consumes all of its input,
applies one or more aggregate functions <agg> to each input value
optionally filtered by a where clause and/or organized with the grouping
expressions specified after the by keyword, and at the end of input produces one
or more aggregations for each unique set of grouping key values.
In the second form, aggregate consumes all of its input, then outputs each
unique combination of values of the grouping expressions specified after the by
keyword without applying any aggregate functions.
The aggregate keyword is optional since it can be used as a
shortcut.
Each aggregate function <agg-func> may be optionally followed by a where clause,
which applies a Boolean expression <pred> that indicates, for each input value,
whether to include it in the values operated upon by the aggregate function.
where clauses are analogous
to the where operator but apply their filter to the input
argument stream to the aggregate function.
The output values are records formed from the field assignments first from the grouping expressions then from the aggregate functions in left-to-right order.
When the result of aggregate is a single value (e.g., a single aggregate
function without grouping expressions or a single grouping expression without aggregates)
and there is no field name specified, then
the output is that single value rather than a single-field record
containing that value.
If the cardinality of grouping expressions causes the memory footprint to exceed a limit, then each aggregate’s partial results are spilled to temporary storage and the results merged into final results using an external merge sort.
Note
Spilling is not yet implemented for the vectorized runtime.
Examples
Average the input sequence
# spq
aggregate avg(this)
# input
1
2
3
4
# expected output
2.5
To format the output of a single-valued aggregation into a record, simply specify an explicit field for the output
# spq
aggregate mean:=avg(this)
# input
1
2
3
4
# expected output
{mean:2.5}
When multiple aggregate functions are specified, even without explicit field names, a record result is generated with field names implied by the functions
# spq
aggregate avg(this),sum(this),count()
# input
1
2
3
4
# expected output
{avg:2.5,sum:10,count:4::uint64}
Sum the input sequence, leaving out the aggregate keyword
# spq
sum(this)
# input
1
2
3
4
# expected output
10
Create integer sets by key and sort the output to get a deterministic order
# spq
set:=union(v) by key:=k | sort
# input
{k:"foo",v:1}
{k:"bar",v:2}
{k:"foo",v:3}
{k:"baz",v:4}
# expected output
{key:"bar",set:|[2]|}
{key:"baz",set:|[4]|}
{key:"foo",set:|[1,3]|}
Use a where clause
# spq
set:=union(v) where v > 1 by key:=k | sort
# input
{k:"foo",v:1}
{k:"bar",v:2}
{k:"foo",v:3}
{k:"baz",v:4}
# expected output
{key:"bar",set:|[2]|}
{key:"baz",set:|[4]|}
{key:"foo",set:|[3]|}
Use a separate where clause on each aggregate function
# spq
set:=union(v) where v > 1,
array:=collect(v) where k=="foo"
by key:=k
| sort
# input
{k:"foo",v:1}
{k:"bar",v:2}
{k:"foo",v:3}
{k:"baz",v:4}
# expected output
{key:"bar",set:|[2]|,array:null}
{key:"baz",set:|[4]|,array:null}
{key:"foo",set:|[3]|,array:[1,3]}
Results are included for by groupings that generate null results when where
clauses are used inside aggregate
# spq
sum(v) where k=="bar" by key:=k | sort
# input
{k:"foo",v:1}
{k:"bar",v:2}
{k:"foo",v:3}
{k:"baz",v:4}
# expected output
{key:"bar",sum:2}
{key:"baz",sum:null}
{key:"foo",sum:null}
To avoid null results for by groupings as just shown, filter before aggregate
# spq
k=="bar" | sum(v) by key:=k | sort
# input
{k:"foo",v:1}
{k:"bar",v:2}
{k:"foo",v:3}
{k:"baz",v:4}
# expected output
{key:"bar",sum:2}
Output just the unique key values
# spq
by k | sort
# input
{k:"foo",v:1}
{k:"bar",v:2}
{k:"foo",v:3}
{k:"baz",v:4}
# expected output
"bar"
"baz"
"foo"
assert
Operator
assert — test a predicate and produce errors on failure
Synopsis
assert <expr>
Description
The assert operator evaluates the Boolean expression <expr> for each
input value, producing its input value if <expr> evaluates to true or a
structured error if it does not.
Examples
# spq
assert a > 0
# input
{a:1}
{a:-1}
# expected output
{a:1}
error({message:"assertion failed",expr:"a > 0",on:{a:-1}})
cut
Operator
cut — extract subsets of record fields into new records
Synopsis
cut <assignment> [, <assignment> ...]
where <assignment> is a field assignment
having the form:
[ <field> := ] <expr>
Description
The cut operator extracts values from each input record in the
form of one or more field assignments,
creating one field for each expression. Unlike the put operator,
which adds or modifies the fields of a record, cut retains only the
fields enumerated, much like a SQL SELECT clause.
Each left-hand side <field> term must be a field reference expressed as
a dotted path or sequence of constant index operations on this, e.g., a.b.
Each right-hand side <expr> can be any expression and is optional.
When the left-hand side assignments are omitted and the expressions are simple field references, the cut operation resembles the Unix shell command, e.g.,
... | cut a,c | ...
If an expression results in error("quiet"), the corresponding field is omitted
from the output. This allows you to wrap expressions in a quiet() function
to filter out missing errors.
If an input value to cut is not a record, then cut still operates as defined
resulting in error("missing") for expressions that reference fields of this.
Note that when the field references are all top level,
cut is a special case of
values with a
record expression having the form:
values {<field>:<expr> [, <field>:<expr>...]}
Examples
A simple Unix-like cut
# spq
cut a,c
# input
{a:1,b:2,c:3}
# expected output
{a:1,c:3}
Missing fields show up as missing errors
# spq
cut a,d
# input
{a:1,b:2,c:3}
# expected output
{a:1,d:error("missing")}
The missing fields can be ignored with quiet
# spq
cut a:=quiet(a),d:=quiet(d)
# input
{a:1,b:2,c:3}
# expected output
{a:1}
Non-record values generate missing errors for fields not present in a non-record this
# spq
cut a,b
# input
1
{a:1,b:2,c:3}
# expected output
{a:error("missing"),b:error("missing")}
{a:1,b:2}
Invoke a function while cutting to set a default value for a field
Tip
This can be helpful to transform data into a uniform record type, such as if the output will be exported in formats such as
csvorparquet(see also:fuse).
# spq
cut a,b:=coalesce(b, 0)
# input
{a:1,b:null}
{a:1,b:2}
# expected output
{a:1,b:0}
{a:1,b:2}
Field names are inferred when the left-hand side of assignment is omitted
# spq
cut a,coalesce(b, 0)
# input
{a:1,b:null}
{a:1,b:2}
# expected output
{a:1,coalesce:0}
{a:1,coalesce:2}
drop
Operator
drop — drop fields from record values
Synopsis
drop <field> [, <field> ...]
Description
The drop operator removes one or more fields from records in the input sequence
and copies the modified records to its output. If a field to be dropped
is not present, then no effect for the field occurs. In particular,
non-record values are copied unmodified.
Examples
Drop a field
# spq
drop b
# input
{a:1,b:2,c:3}
# expected output
{a:1,c:3}
Non-record values are copied to output
# spq
drop a,b
# input
1
{a:1,b:2,c:3}
# expected output
1
{c:3}
fork
Operator
fork — copy values to parallel pipeline branches
Synopsis
fork
( <branch> )
( <branch> )
...
Description
The fork operator copies each input value to multiple, parallel branches of
the pipeline.
The output of a fork consists of multiple branches that must be merged.
If the downstream operator expects a single input, then the output branches are
combined without preserving order. Order may be reestablished by applying a
sort at the merge point.
Examples
Copy input to two pipeline branches and merge
# spq
fork
( pass )
( pass )
| sort this
# input
1
2
# expected output
1
1
2
2
from
Operator
from — source data from databases, files, or URLs
Synopsis
from <file> [ ( format <fmt> ) ]
from <pool> [@<commit>]
from <url> [ ( format <fmt> method <method> headers <expr> body <string> ) ]
from eval(<expr>) [ ( format <fmt> method <id> headers <expr> body <string> ) ]
Description
The from operator identifies one or more sources of data as input to
a query and transmits that data to its output.
It has two forms:
- a
frompipe operator with pipe scoping as described here, or - a SQL
FROMclause with relational scoping.
As a pipe operator,
from preserves the order of the data within a file,
URL, or a sorted pool but when multiple sources are identified,
the data may be read in parallel and interleaved in an undefined order.
Optional arguments to from may be appended as a parenthesized concatenation
of arguments.
When reading from sources external to a database (e.g., URLs or files), the format of each data source is automatically detected using heuristics. To manually specify the format of a source and override the autodetection heuristic, a format argument may be appended as an argument and has the form
format <fmt>
where <fmt> is the name of a supported
serialization format and is
parsed as a text entity.
When from references a file or URL entity whose name ends in a
well-known extension
(e.g., .json, .sup, etc.), auto-detection is disabled and the
format is implied by the extension name.
File-System Operation
When running detached from a database, the target of from
is either a
text entity
or a file system glob.
If a text entity is parseable as an HTTP or HTTPS URL,
then the target is presumed to be a URL and is processed
accordingly. Otherwise, the target is assumed to be a file
in the file system whose path is relative to the directory
in which the super command is running.
If the target is a glob, then the glob is expanded and the files are processed in an undefined order. Any operator arguments specified after a glob target are applied to all of the matched files.
Here are a few examples illustrating file references:
from "file.sup"
from file.json
from file*.parq (format parquet)
Database Operation
When running attached to a database (i.e., using super db),
the target of from is either a
text entity
or a regular expression
or glob that matches pool names.
If a text entity is parseable as an HTTP or HTTPS URL, then the target is presumed to be a URL and is processed accordingly. Otherwise, the target is assumed to be the name of a pool in the attached database.
Local files are not accessible when attached to a database.
Note that pool names and file names have similar syntax in from but
their use is disambiguated by the presence or absence of an attached
database.
When multiple data pools are referenced with a glob or regular expression, they are scanned in an undefined order.
The reference string for a pool may also be appended with an @-style
commitish, which specifies that
data is sourced from a specific commit in a pool’s commit history.
When a single pool name is specified without an @ reference, or
when using a glob or regular expression, the tip of the main
branch of each pool is accessed.
The format argument is not valid with a database source.
Note
Metadata from database pools also may be sourced using
from. This will be documented in a future release of SuperDB.
URL
Data sources identified by URLs can be accessed either when attached or detached from a database.
When the <url> argument begins with http: or https:
and has the form of a valid URL, then the source is fetched remotely using the
indicated protocol.
As a text entity, typical URLs need not be quoted though URLs with special characters must be quoted.
A format argument may be appended to a URL reference.
Other valid operator arguments control the body and headers of the HTTP request that implement the data retrieval and include:
- method
<method> - headers
<expr> - body
<string>
where
<method>is one ofGET,PUT,POST, orDELETE,<expr>is a record expression that defines the names and values to be included as HTTP header options, and<body>is a text-entity string to be included as the body of the HTTP request.
Currently, the headers expression must evaluate to a compile-time constant though this may change to allow dynamic computation in a future version of SuperSQL.
Expression
The eval() form of from provides a means to read data programmatically from
sources based on the <expr> argument to eval, which should return
a value of type string.
In this case, from reads values from its parent, applies <expr> to each
value, and interprets the string result as a target to be processed.
Each string value is interpreted as a from target and must be a file path
(when running detached from a database), a pool name (when attached to a database),
or a URL forming a sequence of targets which are read and output by the
from operator in the order encountered.
Combining Data
To combine data from multiple sources using pipe operators, from may be
used in combination with other operators like fork and join.
For example, multiple pools can be accessed in parallel and combined in undefined order:
fork
( from PoolOne | op1 | op2 | ... )
( from PoolTwo | op1 | op2 | ... )
| ...
or joined according to a join condition:
fork
( from PoolOne | op1 | op2 | ... )
( from PoolTwo | op1 | op2 | ... )
| join as {left,right} on left.key=right.key
| ...
Alternatively, the right-hand leg of the join may be written as a subquery of join:
from PoolOne | op1 | op2 | ...
| join ( from PoolTwo | op1 | op2 | ... )
as {left,right} on left.key=right.key
| ...
File Examples
Source structured data from a local file
echo '{greeting:"hello world!"}' > hello.sup
super -s -c 'from hello.sup | values greeting'
=>
"hello world!"
Source data from a local file, but in “line” format
super -s -c 'from hello.sup (format line)'
=>
"{greeting:\"hello world!\"}"
HTTP Example
Source data from a URL
super -s -c 'from https://raw.githubusercontent.com/brimdata/super/main/package.json
| values name'
=>
"super"
Database Examples
The remaining examples below assume the existence of the SuperDB database created and populated by the following commands:
export SUPER_DB=example
super db -q init
super db -q create -orderby flip:desc coinflips
echo '{flip:1,result:"heads"} {flip:2,result:"tails"}' |
super db load -q -use coinflips -
super db branch -q -use coinflips trial
echo '{flip:3,result:"heads"}' | super db load -q -use coinflips@trial -
super db -q create numbers
echo '{number:1,word:"one"} {number:2,word:"two"} {number:3,word:"three"}' |
super db load -q -use numbers -
super db -f text -c '
from :branches
| values pool.name + "@" + branch.name
| sort'
The database then contains the two pools and three branches:
coinflips@main
coinflips@trial
numbers@main
The following file hello.sup is also used.
{greeting:"hello world!"}
Source data from the main branch of a pool
super db -db example -s -c 'from coinflips'
=>
{flip:2,result:"tails"}
{flip:1,result:"heads"}
Source data from a specific branch of a pool
super db -db example -s -c 'from coinflips@trial'
=>
{flip:3,result:"heads"}
{flip:2,result:"tails"}
{flip:1,result:"heads"}
Count the number of values in the main branch of all pools
super db -db example -f text -c 'from * | count()'
=>
5
Join the data from multiple pools
super db -db example -s -c '
from coinflips
| join ( from numbers ) on left.flip=right.number
| values {...left, word:right.word}
| sort'
=>
{flip:1,result:"heads",word:"one"}
{flip:2,result:"tails",word:"two"}
Use pass to combine our join output with data from yet another source
super db -db example -s -c '
from coinflips
| join ( from numbers ) on left.flip=right.number
| values {...left, word:right.word}
| fork
( pass )
( from coinflips@trial
| c:=count()
| values f"There were {c} flips" )
| sort this'
=>
"There were 3 flips"
{flip:1,result:"heads",word:"one"}
{flip:2,result:"tails",word:"two"}
Expression Example
Read from dynamically defined files and add a column
echo '{a:1}{a:2}' > a.sup
echo '{b:3}{b:4}' > b.sup
echo '"a.sup" "b.sup"' | super -s -c "from eval(this) | c:=coalesce(a,b)+1" -
=>
{a:1,c:2}
{a:2,c:3}
{b:3,c:4}
{b:4,c:5}
fuse
Operator
fuse — coerce all input values into a fused type
Synopsis
fuse
Description
The fuse operator computes a fused type
over all of its input then casts all values in the input to the fused type.
This is logically equivalent to:
from input | values cast(this, (from input | aggregate fuse(this)))
Because all values of the input must be read to compute the fused type,
fuse may spill its input to disk when memory limits are exceeded.
Note
Spilling is not yet implemented for the vectorized runtime.
Examples
Fuse two records
# spq
fuse
# input
{a:1}
{b:2}
# expected output
{a:1,b:null::int64}
{a:null::int64,b:2}
Fuse records with type variation
# spq
fuse
# input
{a:1}
{a:"foo"}
# expected output
{a:1::(int64|string)}
{a:"foo"::(int64|string)}
Fuse records with complex type variation
# spq
fuse
# input
{a:[1,2]}
{a:["foo","bar"],b:10.0.0.1}
# expected output
{a:[1,2]::[int64|string],b:null::ip}
{a:["foo","bar"]::[int64|string],b:10.0.0.1}
head
Operator
head — copy leading values of input sequence
Synopsis
head [ <const-expr> ]
limit [ <const-expr> ]
Description
The head operator copies the first N values from its input to its output and ends
the sequence thereafter. N is given by <const-expr>, a compile-time
constant expression that evaluates to a positive integer. If <const-expr>
is not provided, the value of N defaults to 1.
For compatibility with other pipe SQL dialects,
limit is an alias for the head operator.
Examples
Grab first two values of arbitrary sequence
# spq
head 2
# input
1
"foo"
[1,2,3]
# expected output
1
"foo"
Grab first two values of arbitrary sequence, using a different representation of two
# spq
const ONE = 1
limit ONE+1
# input
1
"foo"
[1,2,3]
# expected output
1
"foo"
Grab the first record of a record sequence
# spq
head
# input
{a:"hello"}
{a:"world"}
# expected output
{a:"hello"}
join
Operator
join — combine data from two inputs using a join predicate
Synopsis
<left-input>
| [anti|inner|left|right] join (
<right-input>
) [as { <left-name>,<right-name> }] [on <predicate> | using ( <field> )]
( <left-input> )
( <right-input> )
| [anti|inner|left|right] join [as { <left-name>,<right-name> }] [on <predicate> | using ( <field> )]
<left-input> cross join ( <right-input> ) [as { <left-name>,<right-name> }]
( <left-input> )
( <right-input> )
| cross join [as { <left-name>,<right-name> }]
Description
The join operator combines values from two inputs according to the Boolean-valued
<predicate> into two-field records, one field for each side of the join.
Logically, a cross product of all values is formed by taking each
value L from <left-input> and forming records with all of the values R from
the <right-input> of the form {<left-name>:L,<right-name>:R}. The result
of the join is the set of all such records that satisfy <predicate>.
A using clause may be specified instead of an on clause and is equivalent to an equi-join predicate of the form:
<left-name>.<field> = <right-name>.<field>
<field> must be an l-value, i.e., an expression composed of dot operators and
index operators.
If the as clause is omitted, then <left-name> defaults to “left” and
<right-name> defaults to “right”.
For a cross join, neither an on clause or a using clause may be present and the condition is presumed true for all values so that the entire cross product is produced.
The output order of the joined values is undefined.
The available join types are:
- inner - as described above
- left - the inner join plus a set of single-field records of the form
{<left-name>:L}for each valueLin<left-input>absent from the inner join - right - the inner join plus a set of single-field records of the form
{<right-name>:R}for each valueRin<right-input>absent from the inner join - anti - the set of records of the form
{<left-name>:L}for which there is no valueRin<right-input>where the combined record{<left-name>:L,<right-name>:R}satisfies<predicate> - cross - the entire cross product is computed
As compared to SQL relational scoping, which utilizes table aliases and column aliases within nested scopes, the pipeline join operator uses pipe scoping to join data. Here, all data is combined into joined records that can be operated upon like any other record without complex scoping logic.
If relational scoping is desired, a SQL JOIN clause
can be used instead.
Examples
Join some numbers
# spq
join (values 1,3) on left=right | sort
# input
1
2
3
# expected output
{left:1,right:1}
{left:3,right:3}
Join some records with scalar keys
# spq
join (
values "foo","baz"
) as {recs,key} on key=recs.key
| values recs.value
| sort
# input
{key:"foo",value:1}
{key:"bar",value:2}
{key:"baz",value:3}
# expected output
1
3
Join some records via a using clause
# spq
join (
values {num:1,word:'one'},{num:2,word:'two'}
) using (num)
| {word:right.word, parity:left.parity}
# input
{num:1, parity:"odd"}
{num:2, parity:"even"}
# expected output
{word:"one",parity:"odd"}
{word:"two",parity:"even"}
Anti-join some numbers
# spq
anti join (values 1,3) on left=right | sort
# input
1
2
3
# expected output
{left:2}
Cross-product join
# spq
cross join (values 4,5) as {a,b} | sort
# input
1
2
3
# expected output
{a:1,b:4}
{a:1,b:5}
{a:2,b:4}
{a:2,b:5}
{a:3,b:4}
{a:3,b:5}
load
Operator
load — add and commit data to a pool
Synopsis
load <pool>[@<branch>] [ ( [author <author>] [message <message>] [meta <meta>] ) ]
Note
The
loadoperator is exclusively for working with a database and is not available when runningsuperdetached from a database.
Description
The load operator populates the specified <pool> with the values it
receives as input. Much like how super db load
is used at the command line to populate a pool with data from files, streams,
and URIs, the load operator is used to save query results from your SuperSQL
query to a pool in the same database. <pool> is a string indicating the
name or ID of the destination pool.
If the optional @<branch> string is included then the data will be committed
to an existing branch of that name, otherwise the main branch is assumed.
The author, message, and meta strings may also be provided to further
describe the committed data, similar to the same super db load options.
Input Data
Examples below assume the existence of a database created and populated by the following commands:
export SUPER_DB=example
super db -q init
super db -q create -orderby flip:asc coinflips
super db branch -q -use coinflips onlytails
echo '{flip:1,result:"heads"} {flip:2,result:"tails"}' |
super db load -q -use coinflips -
super db -q create -orderby flip:asc bigflips
super db -f text -c '
from :branches
| values pool.name + "@" + branch.name
| sort'
The database then contains the two pools:
bigflips@main
coinflips@main
coinflips@onlytails
Examples
Modify some values, load them into the main branch of our empty bigflips pool, and see what was loaded
super db -db example -c '
from coinflips
| result:=upper(result)
| load bigflips
' > /dev/null
super db -db example -s -c 'from bigflips'
=>
{flip:1,result:"HEADS"}
{flip:2,result:"TAILS"}
Add a filtered subset of records to our onlytails branch, while also adding metadata
super db -db example -c '
from coinflips
| result=="tails"
| load coinflips@onlytails (
author "Steve"
message "A subset"
meta "\"Additional metadata\""
)
' > /dev/null
super db -db example -s -c 'from coinflips@onlytails'
=>
{flip:2,result:"tails"}
pass
Operator
pass — copy input values to output
Synopsis
pass
Description
The pass operator outputs a copy of each input value. It is typically used
with operators that handle multiple branches of the pipeline such as
fork and join.
Examples
Copy input to output
# spq
pass
# input
1
2
3
# expected output
1
2
3
Copy each input value to three parallel pipeline branches and leave the values unmodified on one of them
# spq
fork
( pass )
( upper(this) )
( lower(this) )
| sort
# input
"HeLlo, WoRlD!"
# expected output
"HELLO, WORLD!"
"HeLlo, WoRlD!"
"hello, world!"
put
Operator
put — add or modify fields of records
Synopsis
[put] <assignment> [, <assignment> ...]
where <assignment> is a field assignment
having the form:
[ <field> := ] <expr>
Description
The put operator modifies its input with
one or more field assignments.
Each expression <expr> is evaluated based on the input value
and the result is either assigned to a new field of the input record if it does not
exist, or the existing field is modified in its original location with the result.
New fields are appended in left-to-right order to the right of existing record fields while modified fields are mutated in place.
If multiple fields are written in a single put, all the new field values are
computed first and then they are all written simultaneously. As a result,
a computed value cannot be referenced in another expression. If you need
to re-use a computed result, this can be done by chaining multiple put operators.
The put keyword is optional since it can be used as a shortcut.
When used as a shortcut, the <field>:= portion of <assignment> is not optional.
Each left-hand side <field> term must be a field reference expressed as
a dotted path or sequence of constant index operations on this, e.g., a.b.
Each right-hand side <expr> can be any expression.
For any input value that is not a record, a structured error is emitted having the form:
error({message:"put: not a record",on:<value>})
where <value> is the offending input value.
Note that when the field references are all top level,
put is a special case of values
with a record expression
using a spread operator of the form:
values {...this, <field>:<expr> [, <field>:<expr>...]}
Examples
A simple put
# spq
put c:=3
# input
{a:1,b:2}
# expected output
{a:1,b:2,c:3}
The put keyword may be omitted
# spq
c:=3
# input
{a:1,b:2}
# expected output
{a:1,b:2,c:3}
A put operation can also be done with a record spread
# spq
values {...this, c:3}
# input
{a:1,b:2}
# expected output
{a:1,b:2,c:3}
Missing fields show up as missing errors
# spq
put d:=e
# input
{a:1,b:2,c:3}
# expected output
{a:1,b:2,c:3,d:error("missing")}
Non-record input values generate errors
# spq
b:=2
# input
{a:1}
1
# expected output
{a:1,b:2}
error({message:"put: not a record",on:1})
rename
Operator
rename — change the name of record fields
Synopsis
rename <newfield>:=<oldfield> [, <newfield>:=<oldfield> ...]
Description
The rename operator changes the names of one or more fields
in the input records from the right-hand side name to the left-hand side name
for each assignment listed. When <oldfield> references a field that does not
exist, there is no effect and the input is copied to the output.
Non-record input values are copied to the output without modification.
Each <field> must be a field reference as a dotted path and the old name
and new name must refer to the same record in the case of nested records.
That is, the dotted path prefix before the final field name must be the
same on the left- and right-hand sides. To perform more sophisticated
renaming of fields, you can use cut, put
or record expressions.
If a rename operation conflicts with an existing field name, then the offending record is wrapped in a structured error along with an error message and the error is emitted.
Examples
A simple rename
# spq
rename c:=b
# input
{a:1,b:2}
# expected output
{a:1,c:2}
Nested rename
# spq
rename r.a:=r.b
# input
{a:1,r:{b:2,c:3}}
# expected output
{a:1,r:{a:2,c:3}}
Trying to mutate records with rename produces a compile-time error
# spq
rename w:=r.b
# input
{a:1,r:{b:2,c:3}}
# expected output
left-hand side and right-hand side must have the same depth (w vs r.b) at line 1, column 8:
rename w:=r.b
~~~~~~
Record literals can be used instead of rename for mutation
# spq
values {a,r:{c:r.c},w:r.b}
# input
{a:1,r:{b:2,c:3}}
# expected output
{a:1,r:{c:3},w:2}
Alternatively, mutations can be more generic and use drop
# spq
values {a,r,w:r.b} | drop r.b
# input
{a:1,r:{b:2,c:3}}
# expected output
{a:1,r:{c:3},w:2}
Duplicate fields create structured errors
# spq
rename a:=b
# input
{b:1}
{a:1,b:1}
{c:1}
# expected output
{a:1}
error({message:"rename: duplicate field: \"a\"",on:{a:1,b:1}})
{c:1}
shapes
Operator
shapes — aggregate sample values by type
Synopsis
shapes [ <expr> ]
Description
The shapes operator aggregates the values computed by <expr>
by type and produces an arbitrary sample value for each unique type
in the input. It ignores null values and errors.
shapes is a shorthand for
where <expr> is not null
| aggregate sample:=any(<expr>) by typeof(this)
| values sample
If <expr> is not present, then this is presumed.
Examples
# spq
shapes | sort
# input
1
2
3
"foo"
"bar"
null
error(1)
# expected output
1
"foo"
# spq
shapes a | sort
# input
{a:1}
{b:2}
{a:"foo"}
# expected output
1
"foo"
search
Operator
search — select values based on a search expression
Synopsis
search <sexpr>
? <sexpr>
Description
The search operator provides a traditional keyword experience to SuperSQL
along the lines of web search, email search, or log search.
A search operation filters its input by applying the search expression <sexpr>
to each input value and emitting all values that match.
The search keyword can be abbreviated as ?.
Search Expressions
The search expression syntax is unique to the search operator and provides a hybrid syntax between keyword search and boolean expressions.
A search expression is a Boolean combination of search terms, where a search term is one of:
- a regular expression wrapped in
/instead of quotes, - a glob as described below,
- a textual keyword,
- any literal of a primitive type, or
- any expression predicate.
Regular Expression
A search term may be a regular expression.
To create a regular expression search term, the expression text is
prefixed and suffixed with a /. This distinguishes a regular
expression from a string literal search, e.g.,
/foo|bar/
searches for the string "foo" or "bar" inside of any string entity while
"foo|bar"
searches for the string "foo|bar".
Glob
A search term may be a glob.
Globs are distinguished from keywords by the presence of any wildcard
* character. To search for a string containing such a character,
use a string literal instead of a keyword or escape the character as \*
in a keyword.
For example,
? foo*baz*.com
Searches for any string that begins with foo, has the string
baz in it, and ends with .com.
Note that a glob may look like multiplication but context disambiguates these conditions, e.g.,
a*b
is a glob match for any matching string value in the input, but
a*b==c
is a Boolean comparison between the product a*b and c.
Keyword
Keywords and string literals are equivalent search terms so it is often easier to quote a string search term instead of using escapes in a keyword. Keywords are useful in interactive workflows where searches can be issued and modified quickly without having to type matching quotes.
Keyword search has the look and feel of Web search or email search.
Valid keyword characters include a through z, A through Z,
any valid string escape sequence
(along with escapes for *, =, +, -), and the unescaped characters:
_ . : / % # @ ~
A keyword must begin with one of these characters then may be
followed by any of these characters or digits 0 through 9.
A keyword search is equivalent to
grep(<keyword>, this)
where <keyword> is the quoted string-literal of the unquoted string.
For example,
search foo
is equivalent to
where grep("foo", this)
Note that the shorthand ? may be used in lieu of the “search” keyword.
For example, the simplest SuperSQL query is perhaps a single keyword search, e.g.,
? foo
As above, this query searches the implied input for values that contain the string “foo”.
Literal
Search terms representing non-string values search for both an exact match for the given value as well as a string search for the term exactly as it appears as typed. Such values include:
- integers,
- floating point numbers,
- time values,
- durations,
- IP addresses,
- networks,
- bytes values, and
- type values.
Search terms representing literal strings behave as a keyword search of the same text.
A search for a value <value> represented as the string <string> is
equivalent to
<value> in this or grep(<string>, this)
For example,
search 123 and 10.0.0.1
which can be abbreviated
? 123 10.0.0.1
is equivalent to
where (123 in this or grep("123", this))
and (10.0.0.1 in this or grep("10.0.0.1", this))
Complex values are not supported as search terms but may be queried with the in operator, e.g.,
{s:"foo"} in this
Expression Predicate
Any Boolean-valued function like
is,
has,
grep,
etc. and any comparison expression
may be used as a search term and mixed into a search expression.
For example,
? is(this, <foo>) has(bar) baz x==y+z timestamp > 2018-03-24T17:17:55Z
is a valid search expression but
? /foo.*/ x+1
is not.
Boolean Logic
Search terms may be combined into boolean expressions using logical operators
and, or, not, and !. and may be elided; i.e., concatenation of
search terms is a logical and. not (and its equivalent !) has highest
precedence and and has precedence over or. Parentheses may be used to
override natural precedence.
Note that the concatenation form of and is not valid in standard expressions and
is available only in search expressions.
For example,
? not foo bar or baz
means
((not grep("foo", this)) and grep("bar", this)) or grep("baz", this)
while
? foo (bar or baz)
means
grep("foo", this) and (grep("bar", this) or grep("baz", this))
Examples
A simple keyword search for “world”
# spq
search world
# input
"hello, world"
"say hello"
"goodbye, world"
# expected output
"hello, world"
"goodbye, world"
Search can utilize arithmetic comparisons
# spq
search this >= 2
# input
1
2
3
# expected output
2
3
The “search” keyword can be abbreviated as “?”
# spq
? 2 or 3
# input
1
2
3
# expected output
2
3
A search with Boolean logic
# spq
search this >= 2 AND this <= 2
# input
1
2
3
# expected output
2
The AND operator may be omitted through predicate concatenation
# spq
search this >= 2 this <= 2
# input
1
2
3
# expected output
2
Concatenation for keyword search
# spq
? foo bar
# input
"foo"
"foo bar"
"foo baz bar"
"baz"
# expected output
"foo bar"
"foo baz bar"
Search expressions match fields names too
# spq
? foo
# input
{foo:1}
{bar:2}
{foo:3}
# expected output
{foo:1}
{foo:3}
Boolean functions may be called
# spq
search is(this, <int64>)
# input
1
"foo"
10.0.0.1
# expected output
1
Boolean functions with Boolean logic
# spq
search is(this, <int64>) or is(this, <ip>)
# input
1
"foo"
10.0.0.1
# expected output
1
10.0.0.1
Search with regular expressions
# spq
? /(foo|bar)/
# input
"foo"
{s:"bar"}
{s:"baz"}
{foo:1}
# expected output
"foo"
{s:"bar"}
{foo:1}
A prefix match using a glob
# spq
? b*
# input
"foo"
{s:"bar"}
{s:"baz"}
{foo:1}
# expected output
{s:"bar"}
{s:"baz"}
A suffix match using a glob
# spq
? *z
# input
"foo"
{s:"bar"}
{s:"baz"}
{foo:1}
# expected output
{s:"baz"}
A glob with stars on both sides is like a string search
# spq
? *a*
# input
"foo"
{s:"bar"}
{s:"baz"}
{a:1}
# expected output
{s:"bar"}
{s:"baz"}
{a:1}
skip
Operator
skip — skip leading values of input sequence
Synopsis
skip <const-expr>
Description
The skip operator skips the first N values from its input. N is given by
<const-expr>, a compile-time constant expression that evaluates to a positive
integer.
Examples
Skip the first two values of an arbitrary sequence
# spq
skip 2
# input
1
"foo"
[1,2,3]
# expected output
[1,2,3]
sort
Operator
sort — sort values
Synopsis
sort [-r] [<expr> [asc|desc] [nulls {first|last}] [, <expr> [asc|desc] [nulls {first|last}] ...]]
order by [-r] [<expr> [asc|desc] [nulls {first|last}] [, <expr> [asc|desc] [nulls {first|last}] ...]]
Description
The sort operator sorts its input by reading all values until the end of input,
sorting the values according to the provided sort expression(s), and emitting
the values in the sorted order.
The sort operator can also be invoked as order by.
The sort expressions act as primary key, secondary key, and so forth. By
default, the sort order is ascending, from lowest value to highest. If
desc is specified in a sort expression, the sort order for that key is
descending.
SuperSQL follows the SQL convention that, by default, null values appear last
in either case of ascending or descending sort. This can be overridden
by specifying nulls first in a sort expression.
If no sort expression is provided, a sort key is guessed based on heuristics applied
to the values present.
The heuristic examines the first input record and finds the first field in
left-to-right order that is an integer, or if no integer field is found,
the first field that is floating point. If no such numeric field is found, sort finds
the first field in left-to-right order that is not of the time data type.
Note that there are some cases (such as the output of a grouped aggregation performed on heterogeneous data) where the first input record to sort
may vary even when the same query is executed repeatedly against the same data.
If you require a query to show deterministic output on repeated execution,
explicit sort expressions must be provided.
If -r is specified, the sort order for each key is reversed without altering
the position of nulls. For clarity
when sorting by named fields, specifying desc is recommended instead of -r,
particularly when multiple sort expressions are present. However, sort -r
provides a shorthand if the heuristics described above suffice but reversed
output is desired.
If not all data fits in memory, values are spilled to temporary storage and sorted with an external merge sort.
Note
Spilling is not yet implemented for the vectorized runtime.
SuperSQL’s sort is stable
such that values with identical sort keys always have the same relative order
in the output as they had in the input, such as provided by the -s option in
Unix’s “sort” command-line utility.
During sorting, values are compared via byte order. Between values of type
string, this is equivalent to
C/POSIX collation
as found in other SQL databases such as Postgres.
Note that a total order is defined over the space of all values even between values of different types so sort order is always well-defined even when comparing heterogeneously typed values.
Examples
A simple sort with a null
# spq
sort this
# input
2
null
1
3
# expected output
1
2
3
null
With no sort expression, sort will sort by this for non-records
# spq
sort
# input
2
null
1
3
# expected output
1
2
3
null
The “nulls last” default may be overridden
# spq
sort this nulls first
# input
2
null
1
3
# expected output
null
1
2
3
With no sort expression, sort’s heuristics will find a numeric key
# spq
sort
# input
{s:"bar",k:2}
{s:"bar",k:3}
{s:"foo",k:1}
# expected output
{s:"foo",k:1}
{s:"bar",k:2}
{s:"bar",k:3}
It’s best practice to provide the sort key
# spq
sort k
# input
{s:"bar",k:2}
{s:"bar",k:3}
{s:"foo",k:1}
# expected output
{s:"foo",k:1}
{s:"bar",k:2}
{s:"bar",k:3}
Sort with a secondary key
# spq
sort k,s
# input
{s:"bar",k:2}
{s:"bar",k:3}
{s:"foo",k:2}
# expected output
{s:"bar",k:2}
{s:"foo",k:2}
{s:"bar",k:3}
Sort by secondary key in reverse order when the primary keys are identical
# spq
sort k,s desc
# input
{s:"bar",k:2}
{s:"bar",k:3}
{s:"foo",k:2}
# expected output
{s:"foo",k:2}
{s:"bar",k:2}
{s:"bar",k:3}
Sort with a numeric expression
# spq
sort x+y
# input
{s:"sum 2",x:2,y:0}
{s:"sum 3",x:1,y:2}
{s:"sum 0",x:-1,y:-1}
# expected output
{s:"sum 0",x:-1,y:-1}
{s:"sum 2",x:2,y:0}
{s:"sum 3",x:1,y:2}
Case sensitivity affects sorting “lowest value to highest” in string values
# spq
sort
# input
{word:"hello"}
{word:"Hi"}
{word:"WORLD"}
# expected output
{word:"Hi"}
{word:"WORLD"}
{word:"hello"}
Case-insensitive sort by using a string expression
# spq
sort lower(word)
# input
{word:"hello"}
{word:"Hi"}
{word:"WORLD"}
# expected output
{word:"hello"}
{word:"Hi"}
{word:"WORLD"}
Shorthand to reverse the sort order for each key
# spq
sort -r
# input
2
null
1
3
# expected output
3
2
1
null
switch
Operator
switch — route values based on cases
Synopsis
switch <expr>
case <const> ( <branch> )
case <const> ( <branch> )
...
[ default ( <branch> ) ]
switch
case <bool-expr> ( <branch> )
case <bool-expr> ( <branch> )
...
[ default ( <branch> ) ]
Description
The switch operator routes input values to parallel pipe branches
based on case matching.
In this first form, the expression <expr> is evaluated for each input value
and its result is
compared with all of the case values, which must be distinct, compile-time constant
expressions. The value is propagated to the matching branch.
In the second form, each case is evaluated for each input value in the order that the cases appear. The first case to match causes the input value to propagate to the corresponding branch. Even if later cases match, only the first branch receives the value.
In either form, if no case matches, but a default is present, then the value is routed to the default branch. Otherwise, the value is dropped.
Only one default case is allowed and it may appear anywhere in the list of cases; where it appears does not influence the result.
The output of a switch consists of multiple branches that must be merged.
If the downstream operator expects a single input, then the output branches are
combined without preserving order. Order may be reestablished by applying a
sort at the merge point.
Examples
Split input into evens and odds
# spq
switch
case this%2==0 ( {even:this} )
case this%2==1 ( {odd:this} )
| sort odd,even
# input
1
2
3
4
# expected output
{odd:1}
{odd:3}
{even:2}
{even:4}
Switch on this with a constant case
# spq
switch this
case 1 ( values "1!" )
default ( values this::string )
| sort
# input
1
2
3
4
# expected output
"1!"
"2"
"3"
"4"
tail
Operator
tail — copy trailing values of input sequence
Synopsis
tail [ <const-expr> ]
Description
The tail operator copies the last N values from its input to its output and ends
the sequence thereafter. N is given by <const-expr>, a compile-time
constant expression that evaluates to a positive integer. If <const-expr>
is not provided, the value of N defaults to 1.
Examples
Grab last two values of arbitrary sequence
# spq
tail 2
# input
1
"foo"
[1,2,3]
# expected output
"foo"
[1,2,3]
Grab last two values of arbitrary sequence, using a different representation of two
# spq
const ONE = 1
tail ONE+1
# input
1
"foo"
[1,2,3]
# expected output
"foo"
[1,2,3]
Grab the last record of a record sequence
# spq
tail
# input
{a:"hello"}
{b:"world"}
# expected output
{b:"world"}
top
Operator
top — output the first N sorted values of input sequence
Synopsis
top [-r] [<const-expr> [<expr> [asc|desc] [nulls {first|last}] [, <expr> [asc|desc] [nulls {first|last}] ...]]]
Description
The top operator returns the first N values from a sequence sorted according
to the provided sort expressions. N is given by <const-expr>, a compile-time
constant expression that evaluates to a positive integer. If <const-expr> is
not provided, N defaults to 1.
The sort expressions <expr> and their parameters behave as they
do for sort. If no sort expression is provided, a sort key is
selected using the same heuristic as sort.
top is functionally similar to sort but is less resource
intensive because only the first N values are stored in memory (i.e., subsequent
values are discarded).
Examples
Grab the smallest two values from a sequence of integers
# spq
top 2
# input
1
5
3
9
23
7
# expected output
1
3
Find the two names most frequently referenced in a sequence of records
# spq
count() by name | top -r 2 count
# input
{name:"joe", age:22}
{name:"bob", age:37}
{name:"liz", age:25}
{name:"bob", age:18}
{name:"liz", age:34}
{name:"zoe", age:55}
{name:"ray", age:44}
{name:"sue", age:41}
{name:"liz", age:60}
# expected output
{name:"liz",count:3::uint64}
{name:"bob",count:2::uint64}
uniq
Operator
uniq — deduplicate adjacent values
Synopsis
uniq [-c]
Description
Inspired by the traditional Unix shell command of the same name,
the uniq operator copies its input to its output but removes duplicate values
that are adjacent to one another.
This operator is most often used with cut and sort to find and eliminate
duplicate values.
When run with the -c option, each value is output as a record with the
type signature {value:any,count:uint64}, where the value field contains the
unique value and the count field indicates the number of consecutive duplicates
that occurred in the input for that output value.
Examples
Simple deduplication
# spq
uniq
# input
1
2
2
3
# expected output
1
2
3
Simple deduplication with count of duplicate values
# spq
uniq -c
# input
1
2
2
3
# expected output
{value:1,count:1::uint64}
{value:2,count:2::uint64}
{value:3,count:1::uint64}
Use sort to deduplicate non-adjacent values
# spq
sort | uniq
# input
"hello"
"world"
"goodbye"
"world"
"hello"
"again"
# expected output
"again"
"goodbye"
"hello"
"world"
Complex values must match fully to be considered duplicate (e.g., every field/value pair in adjacent records)
# spq
uniq
# input
{ts:2024-09-10T21:12:33Z, action:"start"}
{ts:2024-09-10T21:12:34Z, action:"running"}
{ts:2024-09-10T21:12:34Z, action:"running"}
{ts:2024-09-10T21:12:35Z, action:"running"}
{ts:2024-09-10T21:12:36Z, action:"stop"}
# expected output
{ts:2024-09-10T21:12:33Z,action:"start"}
{ts:2024-09-10T21:12:34Z,action:"running"}
{ts:2024-09-10T21:12:35Z,action:"running"}
{ts:2024-09-10T21:12:36Z,action:"stop"}
unnest
Operator
unnest — expand nested array as values optionally into a subquery
Synopsis
unnest <expr> [ into ( <query> ) ]
Description
The unnest operator transforms the given expression
<expr> into a new ordered sequence of derived values.
When the optional argument <query> is present,
each unnested sequence of values is processed as a unit by that subquery,
which is shorthand for this pattern
unnest [unnest <expr> | <query>]
where the right-hand unnest is an
array subquery.
For example,
values [1,2],[3] | unnest this | sum(this)
produces
6
but
values [1,2],[3] | unnest this into (sum(this))
produces
3
3
If <expr> is an array, then the elements of that array form the derived sequence.
If <expr> is a record, it must have two fields of the form:
{<first>: <any>, <second>:<array>}
where <first> and <second> are arbitrary field names, <any> is any
SuperSQL value, and <array> is an array value. In this case, the derived
sequence has the form:
{<first>: <any>, <second>:<elem0>}
{<first>: <any>, <second>:<elem1>}
...
where the first field is copied to each derived value and the second field is
the unnested elements of the array elem0, elem1, etc.
To explode the fields of records or the key-value pairs of maps, use the
flatten function, which produces an array that
can be unnested.
For example, if this is a record, it can be unnested with unnest flatten(this).
Note
Support for map types in
flattenis not yet implemented.
Errors
If a value encountered by unnest does not have either of the forms defined
above, then an error results as follows:
error({message:"unnest: encountered non-array value",on:<value>})
where <value> is the offending value.
When a record value is encountered without the proper form, then the error is:
error({message:"unnest: encountered record without two fields",on:<value>})
or
error({message:"unnest: encountered record without an array/set type for second field",on:<value>})
Examples
unnest unrolls the elements of an array
# spq
unnest [1,2,"foo"]
# input
# expected output
1
2
"foo"
The unnest clause is evaluated once per each input value
# spq
unnest [1,2]
# input
null
null
# expected output
1
2
1
2
Unnest traversing an array inside a record
# spq
unnest a
# input
{a:[1,2,3]}
# expected output
1
2
3
Filter the unnested values
# spq
unnest a | this % 2 == 0
# input
{a:[6,5,4]}
{a:[3,2,1]}
# expected output
6
4
2
Aggregate the unnested values
# spq
unnest a | sum(this)
# input
{a:[1,2]}
{a:[3,4,5]}
# expected output
15
Aggregate the unnested values in a subquery
# spq
unnest a into ( sum(this) )
# input
{a:[1,2]}
{a:[3,4,5]}
# expected output
3
12
Access an outer value in a subquery
# spq
unnest {s,a} into ( sum(a) by s )
# input
{a:[1,2],s:"foo"}
{a:[3,4,5],s:"bar"}
# expected output
{s:"foo",sum:3}
{s:"bar",sum:12}
Unnested the elements of a record by flattening it
# spq
unnest {s,f:flatten(r)} into ( values {s,key:f.key[0],val:f.value} )
# input
{s:"foo",r:{a:1,b:2}}
{s:"bar",r:{a:3,b:4}}
# expected output
{s:"foo",key:"a",val:1}
{s:"foo",key:"b",val:2}
{s:"bar",key:"a",val:3}
{s:"bar",key:"b",val:4}
values
Operator
values — emit values from expressions
Synopsis
[values] <expr> [, <expr>...]
Description
The values operator produces output values by evaluating one or more
comma-separated
expressions on each input value and sending each result to the output
in left-to-right order. Each <expr> may be any valid
expression.
The input order convolved with left-to-right evaluation order is preserved at the output.
The values operator name is optional since it can be used as a
shortcut. When used as a shortcut, only one expression
may be present.
The values abstraction is also available as the SQL VALUES clause,
where the tuples that comprise
this form must all adhere to a common type signature.
The pipe form of values here is differentiated from the SQL form
by the absence of parenthesized expressions in the comma-separated list
of expressions, i.e., the expressions in a comma-separated list never
require top-level parentheses and the resulting values need not conform
to a common type.
The values operator is a go to tool in SuperSQL queries as it allows
the flexible creation of arbitrary values from its inputs while the
SQL VALUES clause is a go to building block for creating constant tables
to insert into or operate upon a database. That said, the SQL VALUES clause
can also be comprised of dynamic expressions though it is less often used
in this fashion. Nonetheless, this motivated the naming of the more general
SuperSQL values operator.
For example, this query uses SQL VALUES to
create a static table called points then operate upon
each row of points using expressions embodied in
dynamic VALUES subqueries placed in a lateral join as follows:
WITH points(x,y) AS (
VALUES (2,1),(4,2),(6,3)
)
SELECT vals
FROM points CROSS JOIN LATERAL (VALUES (x+y), (x-y)) t(vals)
which produces
3
1
6
2
9
3
Using the values pipe operator, this can be written simply as
values {x:2,y:1},{x:4,y:2},{x:6,y:3}
| values x+y, x-y
Examples
Hello, world
# spq
values "hello, world"
# input
# expected output
"hello, world"
Values evaluates each expression for every input value
# spq
values 1,2
# input
null
null
null
# expected output
1
2
1
2
1
2
Values typically operates on its input
# spq
values this*2+1
# input
1
2
3
# expected output
3
5
7
Values is often used to transform records
# spq
values [a,b],[b,a] | collect(this)
# input
{a:1,b:2}
{a:3,b:4}
# expected output
[[1,2],[2,1],[3,4],[4,3]]
where
Operator
where — select values based on a Boolean expression
Synopsis
[where] <expr>
Description
The where operator filters its input by applying a Boolean
expression <expr>
to each input value and dropping each value for which the expression evaluates
to false or to an error.
The where keyword is optional since it is a shortcut.
When SuperSQL queries are run interactively, it is highly convenient to be able to omit
the “where” keyword, but when where filters appear in query source files,
it is good practice to include the optional keyword.
Examples
An arithmetic comparison
# spq
where this >= 2
# input
1
2
3
# expected output
2
3
The “where” keyword may be dropped
# spq
this >= 2
# input
1
2
3
# expected output
2
3
A filter with Boolean logic
# spq
where this >= 2 AND this <= 2
# input
1
2
3
# expected output
2
A filter with array containment logic
# spq
where this in [1,4]
# input
1
2
3
4
# expected output
1
4
A filter with inverse containment logic
# spq
where ! (this in [1,4])
# input
1
2
3
4
# expected output
2
3
Boolean functions may be called
# spq
where has(a)
# input
{a:1}
{b:"foo"}
{a:10.0.0.1,b:"bar"}
# expected output
{a:1}
{a:10.0.0.1,b:"bar"}
Boolean functions with Boolean logic
# spq
where has(a) or has(b)
# input
{a:1}
{b:"foo"}
{a:10.0.0.1,b:"bar"}
# expected output
{a:1}
{b:"foo"}
{a:10.0.0.1,b:"bar"}
SQL Clauses
SQL Operators
TODO: document all the SQL clauses
XXX explain here how a SELECT query is Pipe operator XXX figure out how to document FROM query without select as this overlaps with the pipe operator form of FROM
Identifier Scope
TODO: seciton on scoping
see issue
CTE scoping
Input References
Accessing this
FROM
SELECT
WHERE
GROUP BY
HAVING
FILTER
VALUES
ORDER
LIMIT
JOIN
WITH
UNION
INTERSECT
Functions
Functions
An invocation of a built-in function may appear in any expression. A function takes zero or more positional arguments and always produces a single output value. There are no named function parameters.
A declared function whose name conflicts with a built-in function name overrides the built-in function.
Functions are generally polymorphic and can be called with values of varying type as their arguments. When type errors occur, functions will return structured errors reflecting the error.
Note
Static type checking of function arguments and return values is not yet implemented in SuperSQL but will be supported in a future version.
Throughout the function documentation, expected parameter types and the return type are indicated with type signatures having the form
<name> ( [ <formal> : <type> ] [ , <formal> : <type> ] ) -> <type>
where <name> is the function name, <formal> is an identifier representing
the formal name of a function parameter,
and <type> is either the name of an actual type
or a documentary pseudo-type indicating categories defined as follows:
- any - any SuperSQL data type
- float - any floating point type
- int - any signed or unsigned integer type
- number - any numeric type
- record - any record type
Generics
Generics
coalesce
Function
coalesce — return first value that is not null, a “missing” error, or a “quiet” error
Synopsis
coalesce(val: any [, ... val: any]) -> any
Description
The coalesce function returns the first of its arguments that is not null,
error("missing"), or error("quiet"). It returns null if all its arguments
are null, error("missing"), or error("quiet").
Examples
# spq
values coalesce(null, error("missing"), error("quiet"), this)
# input
1
# expected output
1
# spq
values coalesce(null, error("missing"), this)
# input
error("quiet")
# expected output
null
compare
Function
compare — compare values even when types vary
Synopsis
compare(a: any, b: any [, nullsMax: bool]) -> int64
Description
The compare function returns an integer comparing two values. The result will
be 0 if a is equal to b, +1 if a is greater than b, and -1 if a is less than b.
compare differs from comparison expressions in that it will work for any type (e.g., compare(1, "1")).
Values are compared via byte order. Between values of type string, this is
equivalent to C/POSIX collation
as found in other SQL databases such as Postgres.
Note
A future version of SuperSQL will collate values polymorphically using a well-defined total order that embodies the super-structured type order.
nullsMax is an optional value (true by default) that determines whether null
is treated as the minimum or maximum value.
Examples
# spq
values compare(a, b)
# input
{a:2,b:1}
{a:2,b:"1"}
# expected output
1
-1
has
Function
has — test existence of values
Synopsis
has(val: any [, ... val: any]) -> bool
Description
The has function returns false if any of its arguments are error("missing")
and otherwise returns true.
has(e) is a shortcut for !missing(e).
This function is often used to determine the existence of fields in a
record, e.g., has(a,b) is true when this is a record and has
the fields a and b, provided their values are not error("missing").
It’s also useful in shaping messy data when applying conditional logic based on the presence of certain fields:
switch
case has(a) ( ... )
case has(b) ( ... )
default ( ... )
Examples
# spq
values {yes:has(foo),no:has(bar)}
# input
{foo:10}
# expected output
{yes:true,no:false}
# spq
values {yes: has(foo[1]),no:has(foo[4])}
# input
{foo:[1,2,3]}
# expected output
{yes:true,no:false}
# spq
values {yes:has(foo.bar),no:has(foo.baz)}
# input
{foo:{bar:"value"}}
# expected output
{yes:true,no:false}
# spq
values {yes:has(foo+1),no:has(bar+1)}
# input
{foo:10}
# expected output
{yes:true,no:false}
# spq
values has(bar)
# input
1
# expected output
false
# spq
values has(x)
# input
{x:error("missing")}
# expected output
false
len
Function
len — the type-dependent length of a value
Synopsis
len(val: array|bytes|ip|map|net|null|record|set|string|type) -> int64
Description
The len function returns the length of its argument val.
The semantics of this length depend on the value’s type.
For values of each of the supported types listed below, len describes the
contents of val as indicated.
| Type | What len Returns |
|---|---|
array | Elements present |
bytes | Count of 8-bit bytes |
ip | Bytes in the address (4 for IPv4, 16 for IPv6) |
map | Key-value pairs present |
net | Bytes in the prefix and subnet mask (8 for IPv4, 32 for IPv6) |
null | 0 |
record | Fields present |
set | Elements present |
string | Count of unicode code points |
For values of the type type, len describes the
underlying type definition of val as indicated below.
Category of type Value | What len Returns |
|---|---|
array | len of the defined element type |
enum | Count of defined symbols |
error | len of the type of the defined wrapped values |
map | len of the defined value type |
primitive | 1 |
record | Count of defined fields |
set | len of the defined element type |
union | Count of defined member types |
Examples
The length of values of various types
# spq
values {this,kind:kind(this),type:typeof(this),len:len(this)}
# input
[1,2,3]
0x0102ffee
192.168.4.1
2001:0db8:85a3:0000:0000:8a2e:0370:7334
|{"APPL":145.03,"GOOG":87.07}|
192.168.4.0/24
2001:db8:abcd::/64
null
{a:1,b:2}
|["x","y","z"]|
"hello"
# expected output
{that:[1,2,3],kind:"array",type:<[int64]>,len:3}
{that:0x0102ffee,kind:"primitive",type:<bytes>,len:4}
{that:192.168.4.1,kind:"primitive",type:<ip>,len:4}
{that:2001:db8:85a3::8a2e:370:7334,kind:"primitive",type:<ip>,len:16}
{that:|{"APPL":145.03,"GOOG":87.07}|,kind:"map",type:<|{string:float64}|>,len:2}
{that:192.168.4.0/24,kind:"primitive",type:<net>,len:8}
{that:2001:db8:abcd::/64,kind:"primitive",type:<net>,len:32}
{that:null,kind:"primitive",type:<null>,len:0}
{that:{a:1,b:2},kind:"record",type:<{a:int64,b:int64}>,len:2}
{that:|["x","y","z"]|,kind:"set",type:<|[string]|>,len:3}
{that:"hello",kind:"primitive",type:<string>,len:5}
The length of various values of type type
# spq
values {this,kind:kind(this),type:typeof(this),len:len(this)}
# input
<[string]>
<[{a:int64,b:string,c:bool}]>
<enum(HEADS,TAILS)>
<error(string)>
<error({ts:time,msg:string})>
<|{string:float64}|>
<|{string:{x:int64,y:float64}}|>
<int8>
<{a:int64,b:string,c:bool}>
<|[string]|>
<|[{a:int64,b:string,c:bool}]|>
<(int64|float64|string)>
# expected output
{that:<[string]>,kind:"array",type:<type>,len:1}
{that:<[{a:int64,b:string,c:bool}]>,kind:"array",type:<type>,len:3}
{that:<enum(HEADS,TAILS)>,kind:"enum",type:<type>,len:2}
{that:<error(string)>,kind:"error",type:<type>,len:1}
{that:<error({ts:time,msg:string})>,kind:"error",type:<type>,len:2}
{that:<|{string:float64}|>,kind:"map",type:<type>,len:1}
{that:<|{string:{x:int64,y:float64}}|>,kind:"map",type:<type>,len:2}
{that:<int8>,kind:"primitive",type:<type>,len:1}
{that:<{a:int64,b:string,c:bool}>,kind:"record",type:<type>,len:3}
{that:<|[string]|>,kind:"set",type:<type>,len:1}
{that:<|[{a:int64,b:string,c:bool}]|>,kind:"set",type:<type>,len:3}
{that:<int64|float64|string>,kind:"union",type:<type>,len:3}
Unsupported values produce errors
# spq
values {this,kind:kind(this),type:typeof(this),len:len(this)}
# input
true
10m30s
error("hello")
1
2024-07-30T20:05:15.118252Z
# expected output
{that:true,kind:"primitive",type:<bool>,len:error({message:"len: bad type",on:true})}
{that:10m30s,kind:"primitive",type:<duration>,len:error({message:"len: bad type",on:10m30s})}
{that:error("hello"),kind:"error",type:<error(string)>,len:error({message:"len()",on:error("hello")})}
{that:1,kind:"primitive",type:<int64>,len:error({message:"len: bad type",on:1})}
{that:2024-07-30T20:05:15.118252Z,kind:"primitive",type:<time>,len:error({message:"len: bad type",on:2024-07-30T20:05:15.118252Z})}
map
Function
map — apply a function to each element of an array or set
Synopsis
map(v: array|set, f: function) -> array|set|error
Description
The map function applies a single-argument function f,
in the form of an existing function or a lambda expression,
to every element in array or set v and
returns an array or set of the results.
The function f may reference
a declared function or
a built-in function using the & syntax as in
&<name>
where <name> is an identifier.
Alternatively, f may be a lambda expression of the form
lambda x: <expr>
where <expr> is any expression depending only on the lambda argument.
Examples
Upper case each element of an array using & for a built-in
# spq
values map(this, &upper)
# input
["foo","bar","baz"]
# expected output
["FOO","BAR","BAZ"]
A user function to convert epoch floats to time values
# spq
fn floatToTime(x): (
cast(x*1000000000, <time>)
)
values map(this, &floatToTime)
# input
[1697151533.41415,1697151540.716529]
# expected output
[2023-10-12T22:58:53.414149888Z,2023-10-12T22:59:00.716528896Z]
Same as above but with a lambda expression
# spq
values map(this, lambda x:cast(x*1000000000, <time>))
# input
[1697151533.41415,1697151540.716529]
# expected output
[2023-10-12T22:58:53.414149888Z,2023-10-12T22:59:00.716528896Z]
under
Function
under — the underlying value
Synopsis
under(val: any) -> any
Description
The under function returns the value underlying the argument val:
- for unions, it returns the value as its elemental type of the union,
- for errors, it returns the value that the error wraps,
- for name-typed values, it returns the value with the named type’s underlying type,
- for type values, it removes a named type if one exists; otherwise,
- it returns
valunmodified.
Examples
Unions are unwrapped
# spq
values this
# input
1::(int64|string)
"foo"::(int64|string)
# expected output
1::(int64|string)
"foo"::(int64|string)
# spq
values under(this)
# input
1::(int64|string)
"foo"::(int64|string)
# expected output
1
"foo"
Errors are unwrapped
# spq
values this
# input
error("foo")
error({err:"message"})
# expected output
error("foo")
error({err:"message"})
# spq
values under(this)
# input
error("foo")
error({err:"message"})
# expected output
"foo"
{err:"message"}
Values of named types are unwrapped
# spq
values this
# input
80::(port=uint16)
# expected output
80::(port=uint16)
# spq
values under(this)
# input
80::(port=uint16)
# expected output
80::uint16
Values that are not wrapped are unmodified
# spq
values under(this)
# input
1
"foo"
<int16>
{x:1}
# expected output
1
"foo"
<int16>
{x:1}
Errors
error
Function
error — wrap a value as an error
Synopsis
error(val: any) -> error
Description
The error function returns an error version of any value.
It wraps the value val to turn it into an error type providing
a means to create structured and stacked errors.
Examples
Wrap a record as a structured error
# spq
values error({message:"bad value", value:this})
# input
{foo:"foo"}
# expected output
error({message:"bad value",value:{foo:"foo"}})
Wrap any value as an error
# spq
values error(this)
# input
1
"foo"
[1,2,3]
# expected output
error(1)
error("foo")
error([1,2,3])
Test if a value is an error and show its type “kind”
# spq
values {this,err:is_error(this),kind:kind(this)}
# input
error("exception")
"exception"
# expected output
{that:error("exception"),err:true,kind:"error"}
{that:"exception",err:false,kind:"primitive"}
Comparison of a missing error results in a missing error even if they are the same missing errors so as to not allow field comparisons of two missing fields to succeed
# spq
badfield:=x | values badfield==error("missing")
# input
{}
# expected output
error("missing")
has_error
Function
has_error — test if a value is or contains an error
Synopsis
has_error(val: any) -> bool
Description
The has_error function returns true if its argument is or contains an error.
has_error is different from
is_error in that has_error recursively
searches a value to determine if there is any error in a nested value.
Examples
# spq
values has_error(this)
# input
{a:{b:"foo"}}
# expected output
false
# spq
a.x := a.y + 1 | values has_error(this)
# input
{a:{b:"foo"}}
# expected output
true
is_error
Function
is_error — test if a value is an error
Synopsis
is_error(val: any) -> bool
Description
The is_error function returns true if its argument’s type is an error.
is_error(v) is shorthand for kind(v)=="error",
Examples
A simple value is not an error
# spq
values is_error(this)
# input
1
# expected output
false
An error value is an error
# spq
values is_error(this)
# input
error(1)
# expected output
true
Convert an error string into a record with an indicator and a message
# spq
values {err:is_error(this),message:under(this)}
# input
"not an error"
error("an error")
# expected output
{err:false,message:"not an error"}
{err:true,message:"an error"}
missing
Function
missing — test for the “missing” error
Synopsis
missing(val: any) -> bool
Description
The missing function returns true if its argument is error("missing")
and false otherwise.
This function is often used to test if certain fields do not appear as
expected in a record, e.g., missing(a) is true either when this is not a record
or when this is a record and the field a is not present in this.
It’s also useful for shaping messy data when applying conditional logic based on the absence of certain fields:
switch
case missing(a) ( ... )
case missing(b) ( ... )
default ( ... )
Examples
# spq
values {yes:missing(bar),no:missing(foo)}
# input
{foo:10}
# expected output
{yes:true,no:false}
# spq
values {yes:missing(foo.baz),no:missing(foo.bar)}
# input
{foo:{bar:"value"}}
# expected output
{yes:true,no:false}
# spq
values {yes:missing(bar+1),no:missing(foo+1)}
# input
{foo:10}
# expected output
{yes:true,no:false}
# spq
values missing(bar)
# input
1
# expected output
true
# spq
values missing(x)
# input
{x:error("missing")}
# expected output
true
quiet
Function
quiet — quiet “missing” errors
Synopsis
quiet(val: any) -> any
Description
The quiet function returns its argument val unless val is
error("missing"), in which case it returns error("quiet").
Various operators and functions treat quiet errors differently than
missing errors, in particular, dropping them instead of propagating them.
Quiet errors are ignored by operators aggregate, cut, and values.
Examples
A quiet error in values produces no output
# spq
values quiet(this)
# input
error("missing")
# expected output
Without quiet, values produces the missing error
# spq
values this
# input
error("missing")
# expected output
error("missing")
The cut operator drops quiet errors but retains missing errors
# spq
cut b:=x+1,c:=quiet(x+1),d:=quiet(a+1)
# input
{a:1}
# expected output
{b:error("missing"),d:2}
Math
abs
Function
abs — absolute value of a number
Synopsis
abs(n: number) -> number
Description
The abs function returns the absolute value of its argument n, which
must be a numeric type.
Examples
Absolute value of various numbers
# spq
values abs(this)
# input
1
-1
0
-1.0
-1::int8
1::uint8
"foo"
# expected output
1
1
0
1.
1::int8
1::uint8
error({message:"abs: not a number",on:"foo"})
ceil
Function
ceil — ceiling of a number
Synopsis
ceil(n: number) -> number
Description
The ceil function returns the smallest integer greater than or equal to its argument n,
which must be a numeric type. The return type retains the type of the argument.
Examples
The ceiling of various numbers
# spq
values ceil(this)
# input
1.5
-1.5
1::uint8
1.5::float32
# expected output
2.
-1.
1::uint8
2.::float32
floor
Function
floor — floor of a number
Synopsis
floor(n: number) -> number
Description
The floor function returns the greatest integer less than or equal to its argument n,
which must be a numeric type. The return type retains the type of the argument.
Examples
The floor of various numbers
# spq
values floor(this)
# input
1.5
-1.5
1::uint8
1.5::float32
# expected output
1.
-2.
1::uint8
1.::float32
log
Function
log — natural logarithm
Synopsis
log(val: number) -> float64
Description
The log function returns the natural logarithm of its argument val, which
takes a numeric type. The return value is a float64 or an error.
Negative values result in an error.
Examples
The logarithm of various numbers
# spq
values log(this)
# input
4
4.0
2.718
-1
# expected output
1.3862943611198906
1.3862943611198906
0.999896315728952
error({message:"log: illegal argument",on:-1})
The largest power of 10 smaller than the input
# spq
values (log(this)/log(10))::int64
# input
9
10
20
1000
1100
30000
# expected output
0
1
1
2
3
4
pow
Function
pow — exponential function of any base
Synopsis
pow(x: number, y: number) -> float64
Description
The pow function returns the value x raised to the power of y.
The return value is a float64 or an error.
Examples
# spq
values pow(this, 5)
# input
2
# expected output
32.
round
Function
round — round a number
Synopsis
round(val: number) -> number
Description
The round function returns the number val rounded to the nearest integer value.
which must be a numeric type. The return type retains the type of the argument.
Examples
# spq
values round(this)
# input
3.14
-1.5::float32
0
1
# expected output
3.
-2.::float32
0
1
sqrt
Function
sqrt — square root of a number
Synopsis
sqrt(val: number) -> float64
Description
The sqrt function returns the square root of its argument val, which
must be numeric. The return value is a float64. Negative values
result in NaN.
Examples
The square root of various numbers
# spq
values sqrt(this)
# input
4
2.
1e10
-1
# expected output
2.
1.4142135623730951
100000.
NaN
Network
cidr_match
Function
cidr_match — test if IP is in a network
Synopsis
cidr_match(network: net, val: any) -> bool
Description
The cidr_match function returns true if val contains an IP address that
falls within the network given by network. When val is a complex type, the
function traverses its nested structure to find any ip values.
If network is not type net, then an error is returned.
Examples
Test whether values are IP addresses in a network
# spq
values cidr_match(10.0.0.0/8, this)
# input
10.1.2.129
11.1.2.129
10
"foo"
# expected output
true
false
false
false
It also works for IPs in complex values
# spq
values cidr_match(10.0.0.0/8, this)
# input
[10.1.2.129,11.1.2.129]
{a:10.0.0.1}
{a:11.0.0.1}
# expected output
true
true
false
The first argument must be a network
# spq
values cidr_match([1,2,3], this)
# input
10.0.0.1
# expected output
error({message:"cidr_match: not a net",on:[1,2,3]})
network_of
Function
network_of — the network of an IP
Synopsis
network_of(val: ip [, mask: ip|int|uint]) -> net
Description
The network_of function returns the network of the IP address given
by val as determined by the optional mask. If mask is an integer rather
than an IP address, it is presumed to be a network prefix of the indicated length.
If mask is omitted, then a class A (8 bit), B (16 bit), or C (24 bit)
network is inferred from val, which in this case, must be an IPv4 address.
Examples
Compute the network address of an IP using an ip mask argument
# spq
values network_of(this, 255.255.255.128)
# input
10.1.2.129
# expected output
10.1.2.128/25
Compute the network address of an IP given an integer prefix argument
# spq
values network_of(this, 25)
# input
10.1.2.129
# expected output
10.1.2.128/25
Compute the network address implied by IP classful addressing
# spq
values network_of(this)
# input
10.1.2.129
# expected output
10.0.0.0/8
The network of a value that is not an IP is an error
# spq
values network_of(this)
# input
1
# expected output
error({message:"network_of: not an IP",on:1})
Network masks must be contiguous
# spq
values network_of(this, 255.255.128.255)
# input
10.1.2.129
# expected output
error({message:"network_of: mask is non-contiguous",on:255.255.128.255})
Parsing
base64
Function
base64 — encode/decode Base64 strings
Synopsis
base64(b: bytes) -> string
base64(s: string) -> bytes
Description
The base64 function encodes a bytes value b as
a Base64 string,
or decodes a Base64 string s into a bytes value.
Examples
Encode byte sequence 0x010203 into its Base64 string
# spq
values base64(this)
# input
0x010203
# expected output
"AQID"
Decode “AQID” into byte sequence 0x010203
# spq
values base64(this)
# input
"AQID"
# expected output
0x010203
Encode ASCII string into Base64-encoded string
# spq
values base64(this::bytes)
# input
"hello, world"
# expected output
"aGVsbG8sIHdvcmxk"
Decode a Base64 string and cast the decoded bytes to a string
# spq
values base64(this)::string
# input
"aGVsbG8gd29ybGQ="
# expected output
"hello world"
grok
Function
grok — parse a string using Grok patterns
Synopsis
grok(p: string, s: string) -> record
grok(p: string, s: string, definitions: string) -> record
Description
The grok function parses a string s using patterns in string p and
returns a record containing parsed fields.
The string p may contain one or more Grok patterns of syntax
%{pattern:field_name} where pattern is the name of the pattern to match in
s and field_name is the resultant field name of the captured value. If the
field_name portion is not included, the pattern must still match but the
captured value will not be present in the returned record. Any non-pattern
portions of p must also match against the contents of s. Non-pattern
portions of p may contain regular expressions.
When provided with three arguments, definitions is a string
of named patterns in the format PATTERN_NAME PATTERN each separated by
newlines (\n). The named patterns can then be referenced in argument p.
Included Patterns
The grok function by default includes a set of built-in named patterns
that can be referenced in any pattern. The included named patterns can be seen
here.
Comparison to Other Implementations
Although Grok functionality appears in many open source tools, it lacks a
formal specification. As a result, example parsing configurations found via
web searches may not all plug seamlessly into SuperSQL’s grok function without
modification.
Logstash was the first tool to widely
promote the approach via its
Grok filter plugin,
so it serves as the de facto reference implementation. Many articles have
been published by Elastic and others that provide helpful guidance on becoming
proficient in Grok. To help you adapt what you learn from these resources to
the use of the grok function, review the tips below.
Note
As these represent areas of possible future SuperSQL enhancement, links to open issues are provided. If you find a functional gap significantly impacts your ability to use the
grokfunction, please add a comment to the relevant issue describing your use case.
-
Logstash’s Grok offers an optional data type conversion syntax, e.g.,
%{NUMBER:num:int}to store
numas an integer type instead of as a string. SuperSQL currently accepts this trailing:typesyntax but effectively ignores it and stores all parsed values as strings. Downstream use of thecastfunction can be used instead for data type conversion (super/4928). -
Some Logstash Grok examples use an optional square bracket syntax for storing a parsed value in a nested field, e.g.,
%{GREEDYDATA:[nested][field]}to store a value into
{"nested": {"field": ... }}. In SuperSQL the more common dot-separated field naming conventionnested.fieldcan be combined with the downstream use of thenest_dottedfunction to store values in nested fields (super/4929). -
SuperSQL’s regular expressions syntax does not currently support the “named capture” syntax shown in the Logstash docs (super/4899).
Instead use the the approach shown later in that section of the Logstash docs by including a custom pattern in the
definitionsargument, e.g.,# spq values grok("%{SYSLOGBASE} %{POSTFIX_QUEUEID:queue_id}: %{GREEDYDATA:syslog_message}", this, "POSTFIX_QUEUEID [0-9A-F]{10,11}") # input "Jan 1 06:25:43 mailserver14 postfix/cleanup[21403]: BEF25A72965: message-id=<20130101142543.5828399CCAF@mailserver14.example.com>" # expected output {timestamp:"Jan 1 06:25:43",logsource:"mailserver14",program:"postfix/cleanup",pid:"21403",queue_id:"BEF25A72965",syslog_message:"message-id=<20130101142543.5828399CCAF@mailserver14.example.com>"}
- The Grok implementation for Logstash uses the
Oniguruma regular expressions library
while SuperSQL’s
grokuses Go’s regexp and RE2 syntax. These implementations share the same basic syntax which should suffice for most parsing needs. But per a detailed comparison, Oniguruma does provide some advanced syntax not available in RE2, such as recursion, look-ahead, look-behind, and backreferences. To avoid compatibility issues, we recommend building configurations starting from the RE2-based included patterns.
Note
If you absolutely require features of Logstash’s Grok that are not currently present in SuperSQL, you can create a Logstash-based preprocessing pipeline that uses its Grok filter plugin and send its output as JSON to SuperSQL. Issue super/3151 provides some tips for getting started. If you pursue this approach, please add a comment to the issue describing your use case or come talk to us on community Slack.
Debugging
Much like creating complex regular expressions, building sophisticated Grok configurations can be frustrating because single-character mistakes can make the difference between perfect parsing and total failure.
A recommended workflow is to start by successfully parsing a small/simple portion of your target data and incrementally adding more parsing logic and re-testing at each step.
To aid in this workflow, you may find an
interactive Grok debugger helpful. However, note
that these have their own
differences and limitations.
If you devise a working Grok config in such a tool be sure to incrementally
test it with SuperSQL’s grok. Be mindful of necessary adjustments such as those
described above and in the examples.
Need Help?
If you have difficulty with your Grok configurations, please come talk to us on the community Slack.
Examples
Parsing a simple log line using the built-in named patterns
# spq
values grok("%{TIMESTAMP_ISO8601:timestamp} %{LOGLEVEL:level} %{GREEDYDATA:message}",
this)
# input
"2020-09-16T04:20:42.45+01:00 DEBUG This is a sample debug log message"
# expected output
{timestamp:"2020-09-16T04:20:42.45+01:00",level:"DEBUG",message:"This is a sample debug log message"}
Parsing the log line using the same patterns but only capturing the log level
# spq
values grok("%{TIMESTAMP_ISO8601} %{LOGLEVEL:level} %{GREEDYDATA}",
this)
# input
"2020-09-16T04:20:42.45+01:00 DEBUG This is a sample debug log message"
# expected output
{level:"DEBUG"}
Raw strings may be useful for regular
expressions that contain escape sequences, such as when we repurpose the
included pattern for NUMTZ as a definitions argument here
# spq
values grok("%{MY_NUMTZ:tz}",
this,
r"MY_NUMTZ [+-]\d{4}")
# input
"+7000"
# expected output
{tz:"+7000"}
Raw strings also help express the newline separation in the definitions argument
# spq
const defs = r"
PH_PREFIX \d{3}
PH_LINE_NUM \d{4}"
values grok(r"\(%{PH_PREFIX:prefix}\)-%{PH_LINE_NUM:line_number}",
this,
defs)
# input
"(555)-1212"
# expected output
{prefix:"555",line_number:"1212"}
Failure to parse due to non-matching Grok pattern
# spq
grok("%{EMAILADDRESS:email}", this)
# input
"www.example.com"
# expected output
error({message:"grok: value does not match pattern",on:"www.example.com"})
Failure to parse due to mismatch outside of Grok patterns
# spq
grok("%{WORD:one} %{WORD:two}", this)
# input
"hello world"
# expected output
error({message:"grok: value does not match pattern",on:"hello world"})
Using a regular expression to match outside of Grok patterns
# spq
grok(r"%{WORD:one}\s+%{WORD:two}", this)
# input
"hello world"
# expected output
{one:"hello",two:"world"}
Successful parsing in the absence of any named fields in Grok patterns returns an empty record
# spq
grok(r"%{WORD}\s+%{WORD}", this)
# input
"hello world"
# expected output
{}
hex
Function
hex — encode/decode hexadecimal strings
Synopsis
hex(b: bytes) -> string
hex(s: string) -> bytes
Description
The hex function encodes a bytes value b as
a hexadecimal string or decodes a hexadecimal string s into a bytes value.
Examples
Encode a simple bytes sequence as a hexadecimal string
# spq
values hex(this)
# input
0x0102ff
# expected output
"0102ff"
Decode a simple hex string
# spq
values hex(this)
# input
"0102ff"
# expected output
0x0102ff
Encode the bytes of an ASCII string as a hexadecimal string
# spq
values hex(this::bytes)
# input
"hello, world"
# expected output
"68656c6c6f2c20776f726c64"
Decode hex string representing ASCII into its string form
# spq
values hex(this)::string
# input
"68656c6c6f20776f726c64"
# expected output
"hello world"
parse_sup
Function
parse_sup — parse SUP or JSON text into a value
Synopsis
parse_sup(s: string) -> any
Description
The parse_sup function parses the s argument that must be in the form
of SUP or JSON into a value of any type.
This is analogous to JavaScript’s JSON.parse() function.
Examples
Parse SUP text
# spq
foo := parse_sup(foo)
# input
{foo:"{a:\"1\",b:2}"}
# expected output
{foo:{a:"1",b:2}}
Parse JSON text
# spq
foo := parse_sup(foo)
# input
{"foo": "{\"a\": \"1\", \"b\": 2}"}
# expected output
{foo:{a:"1",b:2}}
parse_uri
Function
parse_uri — parse a string URI into a structured record
Synopsis
parse_uri(uri: string) -> record
Description
The parse_uri function parses the uri argument that must have the form of a
Universal Resource Identifier
into a structured URI comprising the parsed components as a record
with the following type signature:
{
scheme: string,
opaque: string,
user: string,
password: string,
host: string,
port: uint16,
path: string,
query: |{string:[string]}|,
fragment: string
}
Examples
# spq
values parse_uri(this)
# input
"scheme://user:password@host:12345/path?a=1&a=2&b=3&c=#fragment"
# expected output
{scheme:"scheme",opaque:null::string,user:"user",password:"password",host:"host",port:12345::uint16,path:"/path",query:|{"a":["1","2"],"b":["3"],"c":[""]}|,fragment:"fragment"}
regexp
Function
regexp — perform a regular expression search on a string
Synopsis
regexp(re: string, s: string) -> any
Description
The regexp function returns an array of strings holding the text
of the left most match of the regular expression re, which is
a regular expression,
and the matches of each parenthesized subexpression (also known as capturing
groups) if there are any. A null value indicates no match.
Examples
Regexp returns an array of the match and its subexpressions
# spq
values regexp(r'foo(.?) (\w+) fr.*', this)
# input
"seafood fool friend"
# expected output
["food fool friend","d","fool"]
A null is returned if there is no match
# spq
values regexp("bar", this)
# input
"foo"
# expected output
null::[string]
regexp_replace
Function
regexp_replace — replace regular expression matches in a string
Synopsis
regexp_replace(s: string, re: string, new: string) -> string
Description
The regexp_replace function substitutes all characters matching the
regular expression re in string s with
the string new.
Variables in new are replaced with corresponding matches drawn from s.
A variable is a substring of the form $name or ${name}, where name is a non-empty
sequence of letters, digits, and underscores. A purely numeric name like $1 refers
to the submatch with the corresponding index; other names refer to capturing
parentheses named with the (?P<name>...) syntax. A reference to an out of range or
unmatched index or a name that is not present in the regular expression is replaced
with an empty string.
In the $name form, name is taken to be as long as possible: $1x is equivalent to
${1x}, not ${1}x, and, $10 is equivalent to ${10}, not ${1}0.
To insert a literal $ in the output, use $$ in the template.
Examples
Replace regular expression matches with a letter
# spq
values regexp_replace(this, "ax*b", "T")
# input
"-ab-axxb-"
# expected output
"-T-T-"
Replace regular expression matches using numeric references to submatches
# spq
values regexp_replace(this,
r"(\w+):\s+(\w+)$",
"$1=$2")
# input
"option: value"
# expected output
"option=value"
Replace regular expression matches using named references
# spq
values regexp_replace(this,
r"(?P<key>\w+):\s+(?P<value>\w+)$",
"$key=$value")
# input
"option: value"
# expected output
"option=value"
Wrap a named reference in curly braces to avoid ambiguity
# spq
values regexp_replace(this,
r"(?P<key>\w+):\s+(?P<value>\w+)$",
"$key=${value}AppendedText")
# input
"option: value"
# expected output
"option=valueAppendedText"
Records
fields
Function
fields — return the flattened path names of a record
Synopsis
fields(r: record) -> [[string]]
Description
The fields function returns an array of string arrays of all the field names in record r.
A field’s path name is represented by an array of strings since the dot
separator is an unreliable indicator of field boundaries as . itself
can appear in a field name.
error("missing") is returned if r is not a record.
Examples
Extract the fields of a nested record
# spq
values fields(this)
# input
{a:1,b:2,c:{d:3,e:4}}
# expected output
[["a"],["b"],["c","d"],["c","e"]]
Easily convert to dotted names if you prefer
# spq
unnest fields(this) | values join(this,".")
# input
{a:1,b:2,c:{d:3,e:4}}
# expected output
"a"
"b"
"c.d"
"c.e"
A record is expected
# spq
values {f:fields(this)}
# input
1
# expected output
{f:error("missing")}
flatten
Function
flatten — transform a record into a flattened array
Synopsis
flatten(val: record) -> [{key:[string],value:<any>}]
Description
The flatten function returns an array of records [{key:[string],value:<any>}]
where key is a string array of the path of each record field of val and
value is the corresponding value of that field.
If there are multiple types for the leaf values in val, then the array value
inner type is a union of the record types present.
Note
A future version of
flattenwill support all nested data types (e.g., maps, sets, etc) where the array-of-strings value of key becomes a more general data structure representing all possible value types that comprise a path.
Examples
# spq
values flatten(this)
# input
{a:1,b:{c:"foo"}}
# expected output
[{key:["a"],value:1},{key:["b","c"],value:"foo"}]
nest_dotted
Function
nest_dotted — transform fields in a record with dotted names to nested records
Synopsis
nest_dotted(val: record) -> record
Description
The nest_dotted function returns a copy of val with all dotted field names
converted into nested records.
Examples
# spq
values nest_dotted(this)
# input
{"a.b.c":"foo"}
# expected output
{a:{b:{c:"foo"}}}
unflatten
Function
unflatten — transform an array of key/value records into a record
Synopsis
unflatten(val: [{key:string|[string],value:any}]) -> record
Description
The unflatten function converts the key/value records in array val into
a single record. unflatten is the inverse of flatten, i.e., unflatten(flatten(r))
will produce a record identical to r.
Examples
Unflatten a single simple record
# spq
values unflatten(this)
# input
[{key:"a",value:1},{key:["b"],value:2}]
# expected output
{a:1,b:2}
Flatten to unflatten
# spq
unnest flatten(this) into (
key[0] != "rm"
| collect(this)
)
| values unflatten(this)
# input
{a:1,rm:2}
# expected output
{a:1}
Strings
grep
Function
grep — search strings inside of values
Synopsis
grep(re: string e: any) -> bool
Description
The grep function searches all of the strings in its input value e
using the re argument, which is a
regular expression.
If the pattern matches for any string, then the result is true. Otherwise, it is false.
Note
String matches are case insensitive while regular expression and glob matches are case sensitive. In a forthcoming release, case sensitivity will be expressible for all three pattern types.
The entire input value is traversed:
- for records, each field name is traversed and each field value is traversed or descended if a complex type,
- for arrays and sets, each element is traversed or descended if a complex type, and
- for maps, each key and value is traversed or descended if a complex type.
Examples
Reach into nested records
# spq
grep("baz", this)
# input
{foo:10}
{bar:{s:"baz"}}
# expected output
{bar:{s:"baz"}}
It only matches string fields
# spq
grep("10", this)
# input
{foo:10}
{bar:{s:"baz"}}
# expected output
Match a field name
# spq
grep("foo", this)
# input
{foo:10}
{bar:{s:"baz"}}
# expected output
{foo:10}
Regular expression
# spq
grep("foo|baz", this)
# input
{foo:10}
{bar:{s:"baz"}}
# expected output
{foo:10}
{bar:{s:"baz"}}
Regular expression with a non-this argument
# spq
grep('b.*', s)
# input
{s:"bar"}
{s:"foo"}
{s:"baz"}
{t:"baz"}
# expected output
{s:"bar"}
{s:"baz"}
join
Function
join — concatenate array of strings with a separator
Synopsis
join(val: [string], sep: string) -> string
Description
The join function concatenates the elements of string array val to create a single
string. The string sep is placed between each value in the resulting string.
Examples
Join an array of strings with commas
# spq
values join(this, ",")
# input
["a","b","c"]
# expected output
"a,b,c"
Join non-string arrays by first casting
# spq
values join(cast(this, <[string]>), "...")
# input
[1,2,3]
[10.0.0.1,10.0.0.2]
# expected output
"1...2...3"
"10.0.0.1...10.0.0.2"
levenshtein
Function
levenshtein — Levenshtein distance
Synopsis
levenshtein(a: string, b: string) -> int64
Description
The levenshtein function computes the
Levenshtein distance
between strings a and b.
Examples
# spq
values levenshtein(a, b)
# input
{a:"kitten",b:"sitting"}
# expected output
3
lower
Function
lower — convert a string to lower case
Synopsis
lower(s: string) -> string
Description
The lower function converts all upper case Unicode characters in s
to lower case and returns the result.
Examples
# spq
values lower(this)
# input
"SuperDB"
# expected output
"superdb"
position
Function
position — find position of a substring
Synopsis
position(s: string, sub: string) -> int64
position(sub: string IN s:string) -> int64
Description
The position function returns the 1-based index where string sub first
occurs in string s. If sub is not a sub-string of s then 0 is returned.
Examples
# spq
values position(s, sub)
# input
{s:"foobar",sub:"bar"}
# expected output
4
replace
Function
replace — replace one string for another
Synopsis
replace(s: string, old: string, new: string) -> string
Description
The replace function substitutes all instances of the string old
that occur in string s with the string new.
Example
# spq
values replace(this, "oink", "moo")
# input
"oink oink oink"
# expected output
"moo moo moo"
split
Function
split — slice a string into an array of strings
Synopsis
split(s: string, sep: string) -> [string]
Description
The split function slices string s into all substrings separated by the
string sep appearing in s and returns an array of the substrings
spanning those separators.
Examples
Split a semi-colon delimited list of fruits
# spq
values split(this,";")
# input
"apple;banana;pear;peach"
# expected output
["apple","banana","pear","peach"]
Split a comma-separated list of IPs and cast the array of strings to an array of IPs
# spq
values cast(split(this,","),<[ip]>)
# input
"10.0.0.1,10.0.0.2,10.0.0.3"
# expected output
[10.0.0.1,10.0.0.2,10.0.0.3]
substring
Function
substring — slice strings with SQL substring function
Synopsis
substring(s: string [ FROM start: number ] [ FOR len: number ]) -> string
Description
The substring function returns a slice of a string using
the anachronistic SQL syntax which includes the FROM and FOR keywords
inside of the call arguments. The function returns a string of length len
comprising the unicode code points starting at offset start.
Indexing is 0-based by default but can be 1-based by the use of a pragma as with generalized indexing.
Tip
This function is implemented for backward compatibility with SQL. Slice expressions should be used instead and are best practice.
Examples
Simple substring call from in a SQL operator
# spq
SELECT SUBSTRING(this FROM 3 FOR 7) AS s
# input
" = SuperDB = "
# expected output
{s:"SuperDB"}
1-based indexing
# spq
pragma index_base = 1
SELECT SUBSTRING(this FROM 4 FOR 7) AS s
# input
" = SuperDB = "
# expected output
{s:"SuperDB"}
The length parameter is optional
# spq
SELECT SUBSTRING(this FROM 3) AS s
# input
" = SuperDB = "
# expected output
{s:"SuperDB = "}
trim
Function
trim — strip leading and trailing whitespace
Synopsis
trim(s: string) -> string
Description
The trim function strips all leading and trailing whitespace
from string argument s and returns the result.
Examples
# spq
values trim(this)
# input
" = SuperDB = "
# expected output
"= SuperDB ="
upper
Function
upper — convert a string to upper case
Synopsis
upper(s: string) -> string
Description
The upper function converts all lower case Unicode characters in s
to upper case and returns the result.
Examples
Simple call on upper
# spq
values upper(this)
# input
"Super format"
# expected output
"SUPER FORMAT"
Apply upper to a string slice
# spq
fn capitalize(str): (
upper(str[0:1]) + str[1:]
)
values capitalize(this)
# input
"super format"
# expected output
"Super format"
Time
bucket
Function
bucket — quantize a time or duration value into buckets of equal time spans
Synopsis
bucket(val: time, span: duration|number) -> time
bucket(val: duration, span: duration|number) -> duration
Description
The bucket function quantizes a time or duration val
(or value that can be coerced to time) into buckets that
are equally spaced as specified by span where the bucket boundary
aligns with 0.
Examples
Bucket a couple times to hour intervals
# spq
values bucket(this::time, 1h)
# input
2020-05-26T15:27:47Z
"5/26/2020 3:27pm"
# expected output
2020-05-26T15:00:00Z
2020-05-26T15:00:00Z
date_part
Function
date_part — return a specified part of a time value
Synopsis
date_part(part: string, ts: time) -> int64
Description
The date_part function accepts a string argument part and a time value ts and
returns an int64 representing the part of the date requested.
Valid values for part are:
part Value | Returned by date_part |
|---|---|
"day" | The day of the month (1-31) |
"dow""dayofweek" | The day of the week (0-6; Sunday is 0) |
"hour" | The hour field (0-23) |
"microseconds" | The seconds field but in microseconds including fractional parts (i.e., 1 second is 1,000,000 microseconds) |
"milliseconds" | The seconds field but in milliseconds including fractional parts (i.e., 1 second is 1,000 milliseconds) |
"minute" | The minute field (0-59) |
"month" | The month of the year (1-12) |
"second" | The seconds field (0-59) |
"year" | The year field |
Examples
Extract all individual parts from a time value
# spq
values date_part("day", this),
date_part("dow", this),
date_part("hour", this),
date_part("microseconds", this),
date_part("milliseconds", this),
date_part("minute", this),
date_part("month", this),
date_part("second", this),
date_part("year", this)
# input
2001-10-09T01:46:40.123456Z
# expected output
9
2
1
40123456
40123
46
10
40
2001
now
Function
now — the current time
Synopsis
now() -> time
Description
The now function takes no arguments and returns the current UTC time as a value of type time.
This is useful to timestamp events in a data pipeline, e.g., when generating errors that are marked with their time of occurrence:
switch (
...
default ( values error({ts:now(), ...}) )
)
Examples
Running this command
super -s -c 'values now()'
produces this value (as of the last time this document was updated)
2025-08-27T02:18:53.568913Z
strftime
Function
strftime — format time values
Synopsis
strftime(format: string, t: time) -> string
Description
The strftime function returns a string representation of time t
as specified by the provided string format. format is a string
containing format directives that dictate how the time string is
formatted.
These directives are supported:
| Directive | Explanation | Example |
|---|---|---|
| %A | Weekday as full name | Sunday, Monday, …, Saturday |
| %a | Weekday as abbreviated name | Sun, Mon, …, Sat |
| %B | Month as full name | January, February, …, December |
| %b | Month as abbreviated name | Jan, Feb, …, Dec |
| %C | Century number (year / 100) as a 2-digit integer | 20 |
| %c | Locale’s appropriate date and time representation | Tue Jul 30 14:30:15 2024 |
| %D | Equivalent to %m/%d/%y | 7/30/24 |
| %d | Day of the month as a zero-padded decimal number | 01, 02, …, 31 |
| %e | Day of the month as a decimal number (1-31); single digits are preceded by a blank | 1, 2, …, 31 |
| %F | Equivalent to %Y-%m-%d | 2024-07-30 |
| %H | Hour (24-hour clock) as a zero-padded decimal number | 00, 01, …, 23 |
| %I | Hour (12-hour clock) as a zero-padded decimal number | 00, 01, …, 12 |
| %j | Day of the year as a zero-padded decimal number | 001, 002, …, 366 |
| %k | Hour (24-hour clock) as a decimal number; single digits are preceded by a blank | 0, 1, …, 23 |
| %l | Hour (12-hour clock) as a decimal number; single digits are preceded by a blank | 0, 1, …, 12 |
| %M | Minute as a zero-padded decimal number | 00, 01, …, 59 |
| %m | Month as a zero-padded decimal number | 01, 02, …, 12 |
| %n | Newline character | \n |
| %p | “ante meridiem” (a.m.) or “post meridiem” (p.m.) | AM, PM |
| %R | Equivalent to %H:%M | 18:49 |
| %r | Equivalent to %I:%M:%S %p | 06:50:58 PM |
| %S | Second as a zero-padded decimal number | 00, 01, …, 59 |
| %T | Equivalent to %H:%M:%S | 18:50:58 |
| %t | Tab character | \t |
| %U | Week number of the year (Sunday as the first day of the week) | 00, 01, …, 53 |
| %u | Weekday as a decimal number, range 1 to 7, with Monday being 1 | 1, 2, …, 7 |
| %V | Week number of the year (Monday as the first day of the week) as a decimal number (01-53) | 01, 02, …, 53 |
| %v | Equivalent to %e-%b-%Y | 31-Jul-2024 |
| %W | Week number of the year (Monday as the first day of the week) | 00, 01, …, 53 |
| %w | Weekday as a decimal number, range 0 to 6, with Sunday being 0 | 0, 1, …, 6 |
| %X | Locale’s appropriate time representation | 14:30:15 |
| %x | Locale’s appropriate date representation | 07/30/24 |
| %Y | Year with century as a decimal number | 2024 |
| %y | Year without century as a decimal number | 24, 23 |
| %Z | Timezone name | UTC |
| %z | +hhmm or -hhmm numeric timezone (that is, the hour and minute offset from UTC) | +0000 |
| %% | A literal ‘%’ character | % |
Examples
Print the year number as a string
# spq
strftime("%Y", this)
# input
2024-07-30T20:05:15.118252Z
# expected output
"2024"
Print a date in European format with slashes
# spq
strftime("%d/%m/%Y", this)
# input
2024-07-30T20:05:15.118252Z
# expected output
"30/07/2024"
Types
cast
Function
cast — convert a value to a different type
Synopsis
cast(val: any, target: type) -> any
cast(val: any, name: string) -> any
Description
The cast function implements a cast where the target
of the cast is a type value instead of a type.
In the first form,
the function converts val to the type indicated by target in accordance
with the semantics of the expression cast.
In the second form, the target type is a named type
whose name is the name parameter and whose type is the type of val.
When a cast is successful, the return value of cast always has the target type.
If errors are encountered, then some or all of the resulting value will be embedded with structured errors and the result does not have the target type.
Examples
Cast primitives to type ip
# spq
cast(this, <ip>)
# input
"10.0.0.1"
1
"foo"
# expected output
10.0.0.1
error({message:"cannot cast to ip",on:1})
error({message:"cannot cast to ip",on:"foo"})
Cast a record to a different record type
# spq
cast(this, <{b:string}>)
# input
{a:1,b:2}
{a:3}
{b:4}
# expected output
{b:"2"}
{b:null::string}
{b:"4"}
Create a named type and cast value to the new type
# spq
cast(this, "foo")
# input
{a:1,b:2}
{a:3,b:4}
# expected output
{a:1,b:2}::=foo
{a:3,b:4}::=foo
Derive type names from the properties of data
# spq
values cast(this, has(x) ? "point" : "radius")
# input
{x:1,y:2}
{r:3}
{x:4,y:5}
# expected output
{x:1,y:2}::=point
{r:3}::=radius
{x:4,y:5}::=point
Cast using a computed type value
# spq
values cast(val, type)
# input
{val:"123",type:<int64>}
{val:"123",type:<float64>}
{val:["true","false"],type:<[bool]>}
# expected output
123
123.
[true,false]
is
Function
is — test a value’s type
Synopsis
is(val: any, t: type) -> bool
Description
The is function returns true if the argument val is of type t.
The is function is shorthand for typeof(val)==t.
Examples
Test simple types
# spq
values {yes:is(this, <float64>),no:is(this, <int64>)}
# input
1.
# expected output
{yes:true,no:false}
Test for a given input’s record type or “shape”
# spq
values is(this, <{s:string}>)
# input
{s:"hello"}
# expected output
true
If you test a named type with its underlying type, the types are different, but if you use the type name or typeof and under functions, there is a match
# spq
values is(this, <{s:string}>)
# input
{s:"hello"}::=foo
# expected output
false
# spq
values is(this, <foo>)
# input
{s:"hello"}::=foo
# expected output
true
To test the underlying type, just use ==
# spq
values under(typeof(this))==<{s:string}>
# input
{s:"hello"}::=foo
# expected output
true
kind
Function
kind — return a value’s type category
Synopsis
kind(val: any) -> string
Description
The kind function returns the category of the type of v as a string,
e.g., “record”, “set”, “primitive”, etc. If v is a type value,
then the type category of the referenced type is returned.
Examples
A primitive value’s kind is “primitive”
# spq
values kind(this)
# input
1
"a"
10.0.0.1
# expected output
"primitive"
"primitive"
"primitive"
A complex value’s kind is its complex type category. Try it on these empty values of various complex types
# spq
values kind(this)
# input
{}
[]
|[]|
|{}|
1::(int64|string)
# expected output
"record"
"array"
"set"
"map"
"union"
An error has kind “error”
# spq
values kind(1/this)
# input
0
# expected output
"error"
A type’s kind is the kind of the type
# spq
values kind(this)
# input
<{s:string}>
# expected output
"record"
nameof
Function
nameof — the name of a named type
Synopsis
nameof(val: any) -> string
Description
The nameof function returns the type name of val as a string if val is a named type.
Otherwise, it returns error("missing").
Examples
A named type yields its name and unnamed types values a missing error
# spq
values nameof(this)
# input
80::(port=int16)
80
# expected output
"port"
error("missing")
The missing value can be ignored with quiet
# spq
values quiet(nameof(this))
# input
80::(port=int16)
80
# expected output
"port"
typeof
Function
typeof — the type of a value
Synopsis
typeof(val: any) -> type
Description
The typeof function returns the type of
its argument val. Types are first class so the returned type is
also a value. The type of a type is type type.
Examples
The types of various values
# spq
values typeof(this)
# input
1
"foo"
10.0.0.1
[1,2,3]
{s:"foo"}
null
error("missing")
# expected output
<int64>
<string>
<ip>
<[int64]>
<{s:string}>
<null>
<error(string)>
The type of a type is type type
# spq
values typeof(typeof(this))
# input
# expected output
<type>
typename
Function
typename — look up and return a named type
Synopsis
typename(name: string) -> type
Description
The typename function returns the type of the
named type given by name if it exists. Otherwise,
error("missing") is returned.
Examples
Return a simple named type with a string constant argument
# spq
values typename("port")
# input
80::(port=int16)
# expected output
<port=int16>
Return a named type using an expression
# spq
values typename(name)
# input
{name:"port",p:80::(port=int16)}
# expected output
<port=int16>
The result is error("missing") if the type name does not exist
# spq
values typename("port")
# input
80
# expected output
error("missing")
Aggregate Functions
Aggregate Functions
Aggregate functions compute aggregated results from zero or more input values and have the form
<name> ( [ all | distinct ] <expr> ) [ where <pred> ]
where
<name>is an identifier naming the function,allanddistinctare optional keywords,<expr>is any expression that is type compatible with the particular function, and<pred>is an optional Boolean expression that filters inputs to the function.
Aggregate functions may appear in
- the aggregate operator,
- an aggregate shortcut, or
- in SQL operators when performing aggregations.
When aggregate functions appear in context of grouping
(e.g., the by clause of an aggregate operator or a
SQL operator with a GROUP BY clause),
then the aggregate function produces one output value for each
unique combination of grouping expressions.
If the case-insensitive distinct option is present, then the inputs
to the aggregate function are filtered to unique values for each
unique grouping.
If the case-insensitive all option is present, then all values
for each unique group are passed as input to the aggregate function.
Calling an aggregate function in a pipe-operator expression outside of an aggregating context is an error.
and
Aggregate Function
and — logical AND of input values
Synopsis
and(bool) -> bool
Description
The and aggregate function computes the logical AND over all of its input.
Examples
Anded value of simple sequence:
# spq
and(this)
# input
true
false
true
# expected output
false
Unrecognized types are ignored and not coerced for truthiness:
# spq
and(this)
# input
true
"foo"
0
false
true
# expected output
false
AND of values grouped by key:
# spq
and(a) by k | sort
# input
{a:true,k:1}
{a:true,k:1}
{a:true,k:2}
{a:false,k:2}
# expected output
{k:1,and:true}
{k:2,and:false}
any
Aggregate Function
any — select an arbitrary input value
Synopsis
any(any) -> any
Description
The any aggregate function returns an arbitrary element from its input. The semantics of how the item is selected is not defined.
Examples
Any picks the first one in this scenario but this behavior is undefined:
# spq
any(this)
# input
1
2
3
4
# expected output
1
Any is not sensitive to mixed types as it just picks one:
# spq
any(this)
# input
"foo"
1
2
3
# expected output
"foo"
Pick from groups bucketed by key:
# spq
any(a) by k | sort
# input
{a:1,k:1}
{a:2,k:1}
{a:3,k:2}
{a:4,k:2}
# expected output
{k:1,any:1}
{k:2,any:3}
avg
Aggregate Function
avg — average (arithmetic mean) value
Synopsis
avg(number) -> number
Description
The avg aggregate function computes the average (arithmetic mean) value of its input.
Examples
Average value of simple sequence:
# spq
avg(this)
# input
1
2
3
4
# expected output
2.5
Unrecognized types are ignored:
# spq
avg(this)
# input
1
2
3
4
"foo"
# expected output
2.5
Average of values bucketed by key:
# spq
avg(a) by k | sort
# input
{a:1,k:1}
{a:2,k:1}
{a:3,k:2}
{a:4,k:2}
# expected output
{k:1,avg:1.5}
{k:2,avg:3.5}
collect
Aggregate Function
collect — aggregate values into array
Synopsis
collect(any) -> [any]
Description
The collect aggregate function organizes its input into an array. If the input values vary in type, the return type will be an array of union of the types encountered.
Examples
Simple sequence collected into an array:
# spq
collect(this)
# input
1
2
3
4
# expected output
[1,2,3,4]
Mixed types create a union type for the array elements:
# spq
collect(this) | values this,typeof(this)
# input
1
2
3
4
"foo"
# expected output
[1,2,3,4,"foo"]
<[int64|string]>
Create arrays of values bucketed by key:
# spq
collect(a) by k | sort
# input
{a:1,k:1}
{a:2,k:1}
{a:3,k:2}
{a:4,k:2}
# expected output
{k:1,collect:[1,2]}
{k:2,collect:[3,4]}
collect_map
Aggregate Function
collect_map — aggregate map values into a single map
Synopsis
collect_map(|{any:any}|) -> |{any:any}|
Description
The collect_map aggregate function combines map inputs into a single map output. If collect_map receives multiple values for the same key, the last value received is retained. If the input keys or values vary in type, the return type will be a map of union of those types.
Examples
Combine a sequence of records into a map:
# spq
collect_map(|{stock:price}|)
# input
{stock:"APPL",price:145.03}
{stock:"GOOG",price:87.07}
# expected output
|{"APPL":145.03,"GOOG":87.07}|
Create maps by key:
# spq
collect_map(|{stock:price}|) by day | sort
# input
{stock:"APPL",price:145.03,day:0}
{stock:"GOOG",price:87.07,day:0}
{stock:"APPL",price:150.13,day:1}
{stock:"GOOG",price:89.15,day:1}
# expected output
{day:0,collect_map:|{"APPL":145.03,"GOOG":87.07}|}
{day:1,collect_map:|{"APPL":150.13,"GOOG":89.15}|}
count
Aggregate Function
count — count input values
Synopsis
count() -> uint64
Description
The count aggregate function computes the number of values in its input.
Examples
Count of values in a simple sequence:
# spq
count()
# input
1
2
3
# expected output
3::uint64
Mixed types are handled:
# spq
count()
# input
1
"foo"
10.0.0.1
# expected output
3::uint64
Count of values in buckets grouped by key:
# spq
count() by k | sort
# input
{a:1,k:1}
{a:2,k:1}
{a:3,k:2}
# expected output
{k:1,count:2::uint64}
{k:2,count:1::uint64}
A simple count with no input values returns no output:
# spq
where grep("bar", this) | count()
# input
1
"foo"
10.0.0.1
# expected output
Count can return an explicit zero when using a where clause in the aggregation:
# spq
count() where grep("bar", this)
# input
1
"foo"
10.0.0.1
# expected output
0::uint64
Note that the number of input values are counted, unlike the
len function
which counts the number of elements in a given value:
# spq
count()
# input
[1,2,3]
# expected output
1::uint64
dcount
Aggregate Function
dcount — count distinct input values
Synopsis
dcount(any) -> uint64
Description
The dcount aggregate function uses hyperloglog to estimate distinct values of the input in a memory efficient manner.
Examples
Count of values in a simple sequence:
# spq
dcount(this)
# input
1
2
2
3
# expected output
3::uint64
Mixed types are handled:
# spq
dcount(this)
# input
1
"foo"
10.0.0.1
# expected output
3::uint64
The estimated result may become less accurate with more unique input values:
seq 10000 | super -s -c 'dcount(this)' -
=>
9987::uint64
Count of values in buckets grouped by key:
# spq
dcount(a) by k | sort
# input
{a:1,k:1}
{a:2,k:1}
{a:3,k:2}
# expected output
{k:1,dcount:2::uint64}
{k:2,dcount:1::uint64}
fuse
Aggregate Function
fuse — compute a fused type of input values
Synopsis
fuse(any) -> type
Description
The fuse aggregate function applies type fusion to its input and returns the fused type.
It is useful with grouped aggregation for data exploration and discovery when searching for shaping rules to cluster a large number of varied input types to a smaller number of fused types each from a set of interrelated types.
Examples
Fuse two records:
# spq
fuse(this)
# input
{a:1,b:2}
{a:2,b:"foo"}
# expected output
<{a:int64,b:int64|string}>
Fuse records with a grouping key:
# spq
fuse(this) by b | sort
# input
{a:1,b:"bar"}
{a:2.1,b:"foo"}
{a:3,b:"bar"}
# expected output
{b:"bar",fuse:<{a:int64,b:string}>}
{b:"foo",fuse:<{a:float64,b:string}>}
max
Aggregate Function
max — maximum value of input values
Synopsis
max(number|string) -> number|string
Description
The max aggregate function computes the maximum value of its input.
When determining the max of string inputs, values are compared via byte order. This is equivalent to C/POSIX collation as found in other SQL databases such as Postgres.
Examples
Maximum value of simple numeric sequence:
# spq
max(this)
# input
1
2
3
4
# expected output
4
Maximum of several string values:
# spq
max(this)
# input
"bar"
"foo"
"baz"
# expected output
"foo"
A mix of string and numeric input values results in an error:
# spq
max(this)
# input
1
"foo"
2
# expected output
error("mixture of string and numeric values")
Other unrecognized types in mixed input are ignored:
# spq
max(this)
# input
1
2
3
4
127.0.0.1
# expected output
4
Maximum value within buckets grouped by key:
# spq
max(a) by k | sort
# input
{a:1,k:1}
{a:2,k:1}
{a:3,k:2}
{a:4,k:2}
# expected output
{k:1,max:2}
{k:2,max:4}
min
Aggregate Function
min — minimum value of input values
Synopsis
min(number|string) -> number|string
Description
The min aggregate function computes the minimum value of its input.
When determining the min of string inputs, values are compared via byte order. This is equivalent to C/POSIX collation as found in other SQL databases such as Postgres.
Examples
Minimum value of simple numeric sequence:
# spq
min(this)
# input
1
2
3
4
# expected output
1
Minimum of several string values:
# spq
min(this)
# input
"foo"
"bar"
"baz"
# expected output
"bar"
A mix of string and numeric input values results in an error:
# spq
min(this)
# input
1
"foo"
2
# expected output
error("mixture of string and numeric values")
Other unrecognized types in mixed input are ignored:
# spq
min(this)
# input
1
2
3
4
127.0.0.1
# expected output
1
Minimum value within buckets grouped by key:
# spq
min(a) by k | sort
# input
{a:1,k:1}
{a:2,k:1}
{a:3,k:2}
{a:4,k:2}
# expected output
{k:1,min:1}
{k:2,min:3}
or
Aggregate Function
or — logical OR of input values
Synopsis
or(bool) -> bool
Description
The or aggregate function computes the logical OR over all of its input.
Examples
Ored value of simple sequence:
# spq
or(this)
# input
false
true
false
# expected output
true
Unrecognized types are ignored and not coerced for truthiness:
# spq
or(this)
# input
false
"foo"
1
true
false
# expected output
true
OR of values grouped by key:
# spq
or(a) by k | sort
# input
{a:true,k:1}
{a:false,k:1}
{a:false,k:2}
{a:false,k:2}
# expected output
{k:1,or:true}
{k:2,or:false}
sum
Aggregate Function
sum — sum of input values
Synopsis
sum(number) -> number
Description
The sum aggregate function computes the mathematical sum of its input.
Examples
Sum of simple sequence:
# spq
sum(this)
# input
1
2
3
4
# expected output
10
Unrecognized types are ignored:
# spq
sum(this)
# input
1
2
3
4
127.0.0.1
# expected output
10
Sum of values bucketed by key:
# spq
sum(a) by k | sort
# input
{a:1,k:1}
{a:2,k:1}
{a:3,k:2}
{a:4,k:2}
# expected output
{k:1,sum:3}
{k:2,sum:7}
union
Aggregate Function
union — set union of input values
Synopsis
union(any) -> |[any]|
Description
The union aggregate function computes a set union of its input values. If the values are of uniform type, then the output is a set of that type. If the values are of mixed type, the the output is a set of union of the types encountered.
Examples
Create a set of values from a simple sequence:
# spq
union(this)
# input
1
2
3
3
# expected output
|[1,2,3]|
Mixed types create a union type for the set elements:
# spq
set:=union(this) | values this,typeof(set)
# input
1
2
3
"foo"
# expected output
{set:|[1,2,3,"foo"]|}
<|[int64|string]|>
Create sets of values bucketed by key:
# spq
union(a) by k | sort
# input
{a:1,k:1}
{a:2,k:1}
{a:3,k:2}
{a:4,k:2}
# expected output
{k:1,union:|[1,2]|}
{k:2,union:|[3,4]|}
Type Fusion
Type Fusion
Type fusion is a process by which a set of input types is merged together to form one output type where all values of the input types are subtypes of the output type such that any value of an input type is representable in a reasonable way (e.g., by inserting null values) as an equivalent value in the output type.
Type fusion is implemented by the fuse aggregate function.
A fused type determined by a collection of data can in turn be used in a cast operation over that collection of data to create a new collection of data comprising the single, fused type. The process of transforming data in this fashion is called data fusion.
Data fusion is implemented by the fuse operator.
The simplest fusion function is a
sum type, e.g., int64 and string fuse
into int64|string. More interesting fusion functions apply to record types, e.g.,
these two record types
{a:int64}
{b:string}
might fuse to type
{a:int64,b:string}
When the output type models a relational schema and the input types are derived from semi-structured data, then this technique resembles schema inference in other systems.
Note
Schema inference also involves the inference of particular primitive data types from string data when the strings represent dates, times, IP addresses, etc. This step is orthogonal to type fusion and can be applied to the input types of any type fusion algorithm.
A fused type computed over heterogeneous data represents a typical design pattern of a data warehouse, where a relational table with a single very-wide type-fused schema defines slots for all possible input values and the columns are sparsely populated by each row value with missing columns set to null.
While super-structured data natively represents heterogeneous data and fortunately does not require a fused schema to persist data, type fusion is nonetheless very useful:
- for data exploration, when sampling or filtering data to look at slices of raw data that are fused together;
- for exporting super-structured data to other systems and formats, where formats like Parquet or a tabular structure like CSV require fixed schemas; and,
- for ETL, where data might be gathered from APIs using SuperDB, transformed in a SuperDB pipeline, and written to another data warehouse.
Unfortunately, when data leaves a super-structured format using type fusion to accomplish this, the original data must be altered to fit into the rigid structure of these output formats.
The Mechanism
The foundation of type fusion is to merge record types by their field names while other types are generally merged with sum types.
For example, type {a:int64} merged with type {b:string}
would simply be type {a:int64,b:string}.
When fields overlap, {a:int64,c:bool} merged with type {b:string,c:bool}
would naturally be type {a:int64,b:string,c:bool}.
But when fields overlap and their types conflict, then a sum type is used to represent the overlapping types, e.g., the types
{a:int64,c:bool}
{b:string,c:time}
are fused as
{a:int64,c:string,c:bool|time}
Arrays, maps, and sets are merged by merging their constituent types, e.g., the element type of the array or set and the key/value types of a map.
For example, the types
{a:[int64],b:|[string]|}
{a:[string]}
are fused as
{a:[int64|string],b:|[string]|}
Detailed Algorithm
Type fusion may be formally defined as a function over types:
T = F(T1, T2, ... Tn)
where T is the fused type and T1...Tn are the input types.
When F() can be decomposed into an iterative application
of a merge function m(type, type) -> type
F(T1, T2, ... Tn) = m(m(m(T1, T2), T3), ... Tn)
then type fusion may be computing iteratively over a set of arbitrary types.
And when m() is commutative and associative, then the fused type can be computed
in parallel without respect to the input order of the data.
The merge function m(t1,t2) implemented by SuperSQL combines complex types
by merging their structure and combines otherwise incompatible types
with a union type as follows.
When t1 and t2 are different categories of types (e.g., record and array,
a set and a primitive type, two different primitive types, and so forth), then
the merged type is simply their sum type t1|t2.
When one type (e.g., t1) is a union type and the other (e.g., t2) is not,
then the t2 is added to the elements of t1 if not already a member of
the union.
When t1 and t2 are the same category of type, then they are merged as follows:
- for record types, the fields of
t1andt2are matched by name and for each matched field present in both types, the merged field is the recursive merge of the two field types, and any non-matching fields are simply appended to the resulting record type, - for array types, the result is an array type whose element type is the recursive merge of the two element types,
- for set types, the result is a set type whose element type is the recursive merge of the two element types,
- for map types, the result is a set type whose key type is the recursive merge of the two key types and whose value type is the recursive merge of the two value type,
- for union types, the result is a union type comprising all the elemental types of
t1andt2, - for error types, the result is an error type comprising the recursive merge of the two contained types,
- for enum types, the result is the sum type of the two types
t1|t2 - for named types, the result is the sum type of the two types
t1|t2.
For further information and examples of type fusion, see the documentation for the
fuse aggregate function and the
fuse pipe operator.
Tutorials
TODO: drop all this content for malibu, revisit when database is further along?
Super-structured Data
TODO: update this doc. drop TLDR. Lead with “does the world need another data model? emphasize it kinds of looks like other things but is subtly different and that subtle difference is the key to a unified model.
Note
TL;DR The super-structured data model defines a new and easy way to manage, store, and process data utilizing an collections of strongly yet heterogeneously typed values. The data model specification defines the type system and its semantics. which is embodied in a family of interoperable serialization formats, providing a unified approach to row, columnar, and human-readable formats. Together these represent a superset of both the dataframe/table model of relational systems and the semi-structured model that is used ubiquitously in development as JSON and by NoSQL data stores. The Super (SUP) format spec has a few examples.
Structured-ness
Modern data models are typically described in terms of their structured-ness:
- tabular-structured, often simply called “structured”, where a specific schema is defined to describe a table and values are enumerated that conform to that schema;
- semi-structured, where arbitrarily complex, hierarchical data structures define the data and values do not fit neatly into tables, e.g., JSON and XML; and
- unstructured, where arbitrary text is formatted in accordance with external, often vague, rules for its interpretation.
The Tabular-structured Pattern
CSV is arguably the simplest but most frustrating format that follows the tabular-structured pattern. It provides a bare bones schema consisting of the names of the columns as the first line of a file followed by a list of comma-separated, textual values whose types must be inferred from the text. The lack of a universally adopted specification for CSV is an all too common source of confusion and frustration.
The traditional relational database, on the other hand, offers the classic, comprehensive example of the tabular-structured pattern. The table columns have precise names and types. Yet, like CSV, there is no universal standard format for relational tables. The SQLite file format is arguably the de facto standard for relational data, but this format describes a whole, specific database — indexes and all — rather than a stand-alone table.
Instead, file formats like Avro, ORC, and Parquet arose to represent tabular data with an explicit schema followed by a sequence of values that conform to the schema. While Avro and Parquet schemas can also represent semi-structured data, all of the values in a given Avro or Parquet file must conform to the same schema. The Iceberg specification defines data types and metadata schemas for how large relational tables can be managed as a collection of Avro, ORC, and/or Parquet files.
The Semi-structured Pattern
JSON, on the other hand, is the ubiquitous example of the semi-structured pattern. Each JSON value is self-describing in terms of its structure and types, though the JSON type system is limited.
When a sequence of JSON objects is organized into a stream (perhaps separated by newlines) each value can take on any form. When all the values have the same form, the JSON sequence begins to look like a relational table, but the lack of a comprehensive type system, a union type, and precise semantics for columnar layout limits this interpretation.
BSON and Ion were created to provide a type-rich elaboration of the semi-structured model of JSON along with performant binary representations though there is no mechanism for precisely representing the type of a complex value like an object or an array other than calling it type “object” or type “array”, e.g., as compared to “object with field s of type string” or “array of number”.
JSON Schema addresses JSON’s lack of schemas with an approach to augment one or more JSON values with a schema definition itself expressed in JSON. This creates a parallel type system for JSON, which is useful and powerful in many contexts, but introduces schema-management complexity when simply trying to represent data in its natural form.
The Hybrid Pattern
As the utility and ease of the semi-structured design pattern emerged, relational system design, originally constrained by the tabular-structured design pattern, has embraced the semi-structured design pattern by adding support for semi-structured table columns. This leads to a temptation to “just put JSON in a column.”
SQL++ pioneered the extension of SQL to semi-structured data by adding support for referencing and unwinding complex, semi-structured values, and most modern SQL query engines have adopted variations of this model and have extended the relational model with a semi-structured column type, often referred to as a “JSON column”.
But once you have put a number of columns of JSON data into a relational table, is it still appropriately called “structured”? Instead, we call this approach the hybrid tabular-/semi-structured pattern, or more simply, “the hybrid pattern”.
A Super-structured Pattern
The super data model removes the tabular and schema concepts from the underlying data model altogether and replaces them with a granular and modern type system inspired by general-purpose programming languages. Instead of defining a single, composite schema to which all values must conform, the type system allows each value to freely express its type in accordance with the type system.
In this approach, data in SuperDB is neither tabular nor semi-structured but instead “super-structured”.
In particular, SuperDB’s record type looks like a schema but when values are serialized, the model is very different. A sequence of values need not comprise a record-type declaration followed by a sequence of homogeneously-typed record values, but instead, is a sequence of arbitrarily typed values which may or may not all be records.
Yet when a sequence of values in fact conforms to a uniform record type, then such a collection of records looks precisely like a relational table. Here, the record type of such a collection corresponds to a well-defined schema consisting of field names (i.e., column names) where each field has a specific type. Named types are also available, so by simply naming a particular record type (i.e., a schema), a relational table can be projected from a pool of data with a simple query for that named type.
But unlike traditional relational tables, these tables based on super-structured data can have arbitrary structure in each column as the model allows the fields of a record to have arbitrary types. This is very different compared to the hybrid pattern: here all data at all levels conforms to the same data model such that the tabular-structured and semi-structured patterns are representable in a single model. Unlike the hybrid pattern, systems based on super-structured data have no need to simultaneously support two very different data models.
In other words, SuperDB unifies the relational data model of SQL tables with the document model of JSON into a super-structured design pattern enabled by the type system. An explicit, uniquely-defined type of each value precisely defines its entire structure, i.e., its super-structure. There is no need to traverse each hierarchical value — as with JSON, BSON, or Ion — to discover each value’s structure.
And because SuperDB derives its design from the vast landscape of existing formats and data models, it was deliberately designed to be a superset of — and thus interoperable with — a broad range of formats including JSON, BSON, Ion, Avro, ORC, Parquet, CSV, JSON Schema, and XML.
As an example, most systems that are based on semi-structured data would say the JSON value
{"a":[1,"foo"]}
is of type object and the value of key a is type array.
In SuperDB, however, this value’s type is type record with field a
of type array of type union of int64 and string,
expressed succinctly in Super (SUP) format as
{a:[int64|string]}
This is super-structuredness in a nutshell.
Schemas
While the super data model removes the schema constraint, the implication here is not that schemas are unimportant; to the contrary, schemas are foundational. Schemas not only define agreement and semantics between communicating entities, but also serve as the cornerstone for organizing and modeling data for data engineering and business intelligence.
That said, schemas often create complexity in system designs where components might simply want to store and communicate data in some meaningful way. For example, an ETL pipeline should not break when upstream structural changes prevent data from fitting in downstream relational tables. Instead, the pipeline should continue to operate and the data should continue to land on the target system without having to fit into a predefined table, while also preserving its super-structure.
This is precisely what SuperDB enables. A system layer above and outside the scope of the data layer can decide how to adapt to the structural changes with or without administrative intervention.
To this end, whether all the values must conform to a schema and how schemas are managed, revised, and enforced is all outside the scope of the super data model; rather, the data model provides a flexible and rich foundation for schema interpretation and management.
Type Combinatorics
A common objection to using a type system to represent schemas is that diverse applications generating arbitrarily structured data can produce a combinatorial explosion of types for each shape of data.
In practice, this condition rarely arises. Applications generating “arbitrary” JSON data generally conform to a well-defined set of JSON object structures.
A few rare applications carry unique data values as JSON object keys, though this is considered bad practice.
Even so, this is all manageable in the super data model as types are localized in scope. The number of types that must be defined in a stream of values is linear in the input size. Since data is self-describing and there is no need for a global schema registry in SuperDB, this hypothetical problem is moot.
Analytics Performance
One might think that removing schemas from the super data model would conflict with an efficient columnar format, which is critical for high-performance analytics. After all, database tables and formats like Parquet and ORC all require schemas to organize values and then rely upon the natural mapping of schemas to columns.
Super-structure, on the other hand, provides an alternative approach to columnar structure. Instead of defining a schema and then fitting a sequence of values into their appropriate columns based on the schema, values self-organize into columns based on their super-structure. Here columns are created dynamically as data is analyzed and each top-level type induces a specific set of columns. When all of the values have the same top-level type (i.e., like a schema), then the columnar object is just as performant as a traditional schema-based columnar format like Parquet.
First-class Types
With first-class types, any type can also be a value, which means that in a properly designed query and analytics system based on the super data model, a type can appear anywhere that a value can appear. In particular, types can be grouping keys.
This is very powerful for data discovery and introspection. For example,
to count the different shapes of data, you might have a SuperSQL query,
operating on each input value as this, that has the form:
SELECT count(), typeof(this) AS shape GROUP BY shape, count
Likewise, you could select a sample value of each shape like this:
SELECT shape FROM (
SELECT any(this) AS sample, typeof(this) AS shape GROUP BY shape,sample
)
The SuperSQL language provides shortcuts that express such operations in ways that more directly leverage the nature of super-structured data. For example, the above two queries could be written as:
count() by shape:=typeof(this)
any(this) by typeof(this) | cut any
First-class Errors
In SQL based systems, errors typically result in cryptic messages or null values offering little insight as to the actual cause of the error.
By comparison, SuperDB includes first-class errors. When combined with the super data model, error values may appear anywhere in the output and operators can propagate or easily wrap errors so complicated analytics pipelines can be debugged by observing the location of errors in the output results.
The Data Model and Formats
The concept of super-structured data and first-class types and errors is solidified in the data model specification, which defines the model but not the serialization formats.
A set of companion documents define a family of tightly integrated serialization formats that all adhere to the same super data model, providing a unified approach to row, columnar, and human-readable formats:
- Super (SUP) is a human-readable format for super-structured data. All JSON documents are SUP values as the SUP format is a strict superset of the JSON syntax.
- Super Binary (BSUP) is a row-based, binary representation somewhat like Avro but leveraging the super data model to represent a sequence of arbitrarily-typed values.
- Super Column (CSUP) is columnar like Parquet or ORC but also embodies the super data model for heterogeneous and self-describing schemas.
- SUP over JSON defines a format for encapsulating SUP inside plain JSON for easy decoding by JSON-based clients, e.g., the JavaScript library used by SuperDB Desktop and the Python library.
Because all of the formats conform to the same super data model, conversions between a human-readable form, a row-based binary form, and a row-based columnar form can be trivially carried out with no loss of information. This is the best of both worlds: the same data can be easily expressed in and converted between a human-friendly and easy-to-program text form alongside efficient row and columnar formats.
Pipe Joins
Pipe Joins
TODO: updage this in light of progress on join and integrate SQL discussions
Joins in SuperSQL pipe context are a bit different than SQL joins. Pipe joins use multi-column records to combine data instead of SQL selections that combine data in a projection from different relations.
This document covers the SuperSQL join operator in some depth with a number example illustrating different join patterns.
Example Data
The first input data source for our usage examples is fruit.json, which describes
the characteristics of some fresh produce.
{"name":"apple","color":"red","flavor":"tart"}
{"name":"banana","color":"yellow","flavor":"sweet"}
{"name":"avocado","color":"green","flavor":"savory"}
{"name":"strawberry","color":"red","flavor":"sweet"}
{"name":"dates","color":"brown","flavor":"sweet","note":"in season"}
{"name":"figs","color":"brown","flavor":"plain"}
The other input data source is people.json, which describes the traits
and preferences of some potential eaters of fruit.
{"name":"morgan","age":61,"likes":"tart"}
{"name":"quinn","age":14,"likes":"sweet","note":"many kids enjoy sweets"}
{"name":"jessie","age":30,"likes":"plain"}
{"name":"chris","age":47,"likes":"tart"}
Inner Join
We’ll start by outputting only the fruits liked by at least one person. The name of the matching person is copied into a field of a different name in the joined results.
Because we’re performing an inner join (the default), the
explicit inner is not strictly necessary, but including it clarifies our intention.
The query inner-join.spq:
from fruit.json
| inner join (
from people.json
) as {f,p} on f.flavor=p.likes
| values {...f, eater:p.name}
| sort flavor
Executing the query:
super -s -I inner-join.spq
produces
{name:"figs",color:"brown",flavor:"plain",eater:"jessie"}
{name:"banana",color:"yellow",flavor:"sweet",eater:"quinn"}
{name:"strawberry",color:"red",flavor:"sweet",eater:"quinn"}
{name:"dates",color:"brown",flavor:"sweet",note:"in season",eater:"quinn"}
{name:"apple",color:"red",flavor:"tart",eater:"morgan"}
{name:"apple",color:"red",flavor:"tart",eater:"chris"}
Left Join
TODO: this statement is not aligned with our audience
By performing a left join that targets the same key fields, now all of our
fruits will be shown in the results even if no one likes them (e.g., avocado).
As another variation, we’ll also copy over the age of the matching person. By
referencing only the field name rather than using := for assignment, the
original field name age is maintained in the results.
The query left-join.spq:
from fruit.json
| left join (
from people.json
) as {f,p} on f.flavor=p.likes
| f.eater := p.name, f.age := p.age
| values f
| sort flavor
Executing the query:
super -s -I left-join.spq
produces
{name:"figs",color:"brown",flavor:"plain",eater:"jessie",age:30}
{name:"avocado",color:"green",flavor:"savory",eater:error("missing"),age:error("missing")}
{name:"banana",color:"yellow",flavor:"sweet",eater:"quinn",age:14}
{name:"strawberry",color:"red",flavor:"sweet",eater:"quinn",age:14}
{name:"dates",color:"brown",flavor:"sweet",note:"in season",eater:"quinn",age:14}
{name:"apple",color:"red",flavor:"tart",eater:"morgan",age:61}
{name:"apple",color:"red",flavor:"tart",eater:"chris",age:47}
Right join
TODO: this statement is not aligned with our audience
Note
In SQL, a right join is called a right outer join.
Next we’ll change the join type from left to right. Notice that this causes
the note field from the right-hand input to appear in the joined results.
The query right-join.spq:
from fruit.json
| right join (
from people.json
) as {f,p} on f.flavor=p.likes
| p.fruit:=f.name
| values p
| sort likes
Executing the query:
super -s -I right-join.spq
produces
{name:"jessie",age:30,likes:"plain",fruit:"figs"}
{name:"quinn",age:14,likes:"sweet",note:"many kids enjoy sweets",fruit:"banana"}
{name:"quinn",age:14,likes:"sweet",note:"many kids enjoy sweets",fruit:"strawberry"}
{name:"quinn",age:14,likes:"sweet",note:"many kids enjoy sweets",fruit:"dates"}
{name:"morgan",age:61,likes:"tart",fruit:"apple"}
{name:"chris",age:47,likes:"tart",fruit:"apple"}
Anti join
TODO: nibs brought up right anti join, which duckdb doesn’t seem to do
The join type anti allows us to see which fruits are not liked by anyone.
Note that with anti join only values from the left-hand input appear in the
results.
The query anti-join.spq:
from fruit.json
| anti join (
from people.json
) as {fruit,people} on fruit.flavor=people.likes
| values fruit
Executing the query:
super -s -I anti-join.spq
produces
{name:"avocado",color:"green",flavor:"savory"}
Inputs from Pools
TODO: no more file operator
In the examples above, we used the
file operator to read our respective inputs
from named file sources. However, if the inputs are stored in pools in a SuperDB data lake,
we would instead specify the sources as data pools using the
from operator.
Here we’ll load our input data to pools in a temporary data lake, then execute
our inner join using super db.
The query inner-join-pools.spq:
from fruit
| inner join (
from people
) as {fruit,people} on fruit.flavor=people.likes
| values {...fruit, eater:people.name}
Populating the pools, then executing the query:
export SUPER_DB=lake
super db init -q
super db create -q -orderby flavor:asc fruit
super db create -q -orderby likes:asc people
super db load -q -use fruit fruit.json
super db load -q -use people people.json
super db -s -I inner-join-pools.spq
produces
{name:"figs",color:"brown",flavor:"plain",eater:"jessie"}
{name:"dates",color:"brown",flavor:"sweet",note:"in season",eater:"quinn"}
{name:"banana",color:"yellow",flavor:"sweet",eater:"quinn"}
{name:"strawberry",color:"red",flavor:"sweet",eater:"quinn"}
{name:"apple",color:"red",flavor:"tart",eater:"chris"}
{name:"apple",color:"red",flavor:"tart",eater:"morgan"}
Alternate Syntax
In addition to the syntax shown so far, join supports an alternate syntax in
which left and right inputs are specified by the two branches of a preceding
fork,
from, or
switch operator.
Here we’ll use the alternate syntax to perform the same inner join shown earlier in the Inner Join section.
The query inner-join-alternate.spq:
fork
( from fruit.json )
( from people.json )
| inner join as {fruit,people} on fruit.flavor=people.likes
| values {...fruit, eater:people.name}
| sort flavor
Executing the query:
super -s -I inner-join-alternate.spq
produces
{name:"figs",color:"brown",flavor:"plain",eater:"jessie"}
{name:"banana",color:"yellow",flavor:"sweet",eater:"quinn"}
{name:"strawberry",color:"red",flavor:"sweet",eater:"quinn"}
{name:"dates",color:"brown",flavor:"sweet",note:"in season",eater:"quinn"}
{name:"apple",color:"red",flavor:"tart",eater:"morgan"}
{name:"apple",color:"red",flavor:"tart",eater:"chris"}
Self Joins
In addition to the named files and pools like we’ve used in the prior examples,
SuperSQL also works on a single sequence of data that is split and
joined to itself. Here we’ll combine our file sources into a stream that we’ll
pipe into super via stdin. Because join requires two separate inputs, here
we’ll use the has() function inside a switch operation to identify the
records in the stream that will be treated as the left and right sides. Then
we’ll use the alternate syntax for join to read those two
inputs.
The query inner-join-streamed.spq:
switch
case has(color) ( pass )
case has(age) ( pass )
| inner join as {fruit,people} on fruit.flavor=people.likes
| values {...fruit, eater:people.name}
| sort flavor
Executing the query:
cat fruit.json people.json | super -s -I inner-join-streamed.spq -
produces
{name:"figs",color:"brown",flavor:"plain",eater:"jessie"}
{name:"banana",color:"yellow",flavor:"sweet",eater:"quinn"}
{name:"strawberry",color:"red",flavor:"sweet",eater:"quinn"}
{name:"dates",color:"brown",flavor:"sweet",note:"in season",eater:"quinn"}
{name:"apple",color:"red",flavor:"tart",eater:"morgan"}
{name:"apple",color:"red",flavor:"tart",eater:"chris"}
Multi-value Joins
The equality test in a join accepts only one named key from each input.
However, joins on multiple matching values can still be performed by making the
values available in comparable complex types, such as embedded records.
To illustrate this, we’ll introduce some new input data inventory.json
that represents a vendor’s available quantity of fruit for sale. As the colors
indicate, they separately offer both ripe and unripe fruit.
{"name":"banana","color":"yellow","quantity":1000}
{"name":"banana","color":"green","quantity":5000}
{"name":"strawberry","color":"red","quantity":3000}
{"name":"strawberry","color":"white","quantity":6000}
Let’s assume we’re interested in seeing the available quantities of only the
ripe fruit in our fruit.json
records. In the query multi-value-join.spq, we create the keys as
embedded records inside each input record, using the same field names and data
types in each. We’ll leave the created fruitkey records intact to show what
they look like, but since it represents redundant data, in practice we’d
typically drop it after the join in our pipeline.
from fruit.json | put fruitkey:={name,color}
| inner join (
from inventory.json | put invkey:={name,color}
) on left.fruitkey=right.invkey
| values {...left, quantity:right.quantity}
Executing the query:
super -s -I multi-value-join.spq
produces
{name:"banana",color:"yellow",flavor:"sweet",fruitkey:{name:"banana",color:"yellow"},quantity:1000}
{name:"strawberry",color:"red",flavor:"sweet",fruitkey:{name:"strawberry",color:"red"},quantity:3000}
Joining More Than Two Inputs
While the join operator takes only two inputs, more inputs can be joined by
extending the pipeline.
To illustrate this, we’ll introduce some new input data in prices.json.
{"name":"apple","price":3.15}
{"name":"banana","price":4.01}
{"name":"avocado","price":2.50}
{"name":"strawberry","price":1.05}
{"name":"dates","price":6.70}
{"name":"figs","price": 1.60}
In our query three-way-join.spq we’ll extend the pipeline we used
previously for our inner join by piping its output to an additional join
against the price list.
from fruit.json
| inner join (
from people.json
) as {fruit,people} on fruit.flavor=people.likes
| values {...fruit, eater:people.name}
| inner join (
from prices.json
) as {fruit,prices} on fruit.name=prices.name
| values {...fruit, price:prices.price}
| sort name
Executing the query:
super -s -I three-way-join.spq
produces
{name:"apple",color:"red",flavor:"tart",eater:"morgan",price:3.15}
{name:"apple",color:"red",flavor:"tart",eater:"chris",price:3.15}
{name:"banana",color:"yellow",flavor:"sweet",eater:"quinn",price:4.01}
{name:"dates",color:"brown",flavor:"sweet",note:"in season",eater:"quinn",price:6.7}
{name:"figs",color:"brown",flavor:"plain",eater:"jessie",price:1.6}
{name:"strawberry",color:"red",flavor:"sweet",eater:"quinn",price:1.05}
Including the entire opposite record
In the current join implementation, explicit entries must be provided in the
[field-list] in order to copy values from the opposite input into the joined
results (a possible future enhancement super/2815
may improve upon this). This can be cumbersome if your goal is to copy over many
fields or you don’t know the names of all desired fields.
One way to work around this limitation is to specify this in the field list
to copy the contents of the entire opposite record into an embedded record
in the result.
The query embed-opposite.spq:
from fruit.json
| inner join (
from people.json
) as {fruit,people} on fruit.flavor=people.likes
| values {...fruit, eaterinfo:people}
| sort flavor
Executing the query:
super -s -I embed-opposite.spq
produces
{name:"figs",color:"brown",flavor:"plain",eaterinfo:{name:"jessie",age:30,likes:"plain"}}
{name:"banana",color:"yellow",flavor:"sweet",eaterinfo:{name:"quinn",age:14,likes:"sweet",note:"many kids enjoy sweets"}}
{name:"strawberry",color:"red",flavor:"sweet",eaterinfo:{name:"quinn",age:14,likes:"sweet",note:"many kids enjoy sweets"}}
{name:"dates",color:"brown",flavor:"sweet",note:"in season",eaterinfo:{name:"quinn",age:14,likes:"sweet",note:"many kids enjoy sweets"}}
{name:"apple",color:"red",flavor:"tart",eaterinfo:{name:"morgan",age:61,likes:"tart"}}
{name:"apple",color:"red",flavor:"tart",eaterinfo:{name:"chris",age:47,likes:"tart"}}
If embedding the opposite record is undesirable, the left and right
records can easily be merged with the
spread operator. Additional
processing may be necessary to handle conflicting field names, such as
in the example just shown where the name field is used differently in the
left and right inputs. We’ll demonstrate this by augmenting embed-opposite.spq
to produce merge-opposite.spq.
from fruit.json
| inner join (
from people.json
) as {fruit,people} on fruit.flavor=people.likes
| rename fruit.fruit:=fruit.name
| values {...fruit,...people}
| sort flavor
Executing the query:
super -s -I merge-opposite.spq
produces
{fruit:"figs",color:"brown",flavor:"plain",name:"jessie",age:30,likes:"plain"}
{fruit:"banana",color:"yellow",flavor:"sweet",name:"quinn",age:14,likes:"sweet",note:"many kids enjoy sweets"}
{fruit:"strawberry",color:"red",flavor:"sweet",name:"quinn",age:14,likes:"sweet",note:"many kids enjoy sweets"}
{fruit:"dates",color:"brown",flavor:"sweet",note:"many kids enjoy sweets",name:"quinn",age:14,likes:"sweet"}
{fruit:"apple",color:"red",flavor:"tart",name:"morgan",age:61,likes:"tart"}
{fruit:"apple",color:"red",flavor:"tart",name:"chris",age:47,likes:"tart"}
Shaping
TODO: update this to use cast, fuse, type fusion, etc? This should be more about why SuperSQL is good for data shaping in general, rather than focusing on particular operators.
Shaping
Data that originates from heterogeneous sources typically has inconsistent structure and is thus difficult to reason about or query. To unify disparate data sources, data is often cleaned up to fit into a well-defined set of schemas, which combines the data into a unified store like a data warehouse.
In Zed, this cleansing process is called “shaping” the data, and Zed leverages its rich, super-structured type system to perform core aspects of data transformation. In a data model with nesting and multiple scalar types (such as Zed or JSON), shaping includes converting the type of leaf fields, adding or removing fields to “fit” a given shape, and reordering fields.
While shaping remains an active area of development, the core functions in Zed that currently perform shaping are:
cast- coerce a value to a different type XXX
They all have the same signature, taking two parameters: the value to be transformed and a type value for the target type.
Note
Another type of transformation that’s needed for shaping is renaming fields, which is supported by the rename operator. Also, the values operator is handy for simply emitting new, arbitrary record literals based on input values and mixing in these shaping functions in an embedded record literal. The fuse aggregate function is also useful for fusing values into a common schema, though a type is returned rather than values.
In the examples below, we will use the following named type connection:
type socket = { addr:ip, port:port=uint16 }
type connection = {
kind: string,
client: socket,
server: socket,
vlan: uint16
}
We’ll also use this sample JSON input:
{
"kind": "dns",
"server": {
"addr": "10.0.0.100",
"port": 53
},
"client": {
"addr": "10.47.1.100",
"port": 41772
},
"uid": "C2zK5f13SbCtKcyiW5"
}
Cast
The cast function applies a cast operation to each leaf value that matches the
field path in the specified type.
In the following example we cast the address fields to type ip, the port fields to type port
(which is a named type for type uint16) and the address port pairs to
type socket without modifying the uid field or changing the
order of the server and client fields:
# spq
type socket = { addr:ip, port:port=uint16 }
type connection = {
kind: string,
client: socket,
server: socket,
vlan: uint16
}
cast(this, <connection>)
# input
{
"kind": "dns",
"server": {
"addr": "10.0.0.100",
"port": 53
},
"client": {
"addr": "10.47.1.100",
"port": 41772
},
"uid": "C2zK5f13SbCtKcyiW5"
}
# expected output
{kind:"dns",server:{addr:10.0.0.100,port:53::(port=uint16)}::=socket,client:{addr:10.47.1.100,port:41772}::socket,uid:"C2zK5f13SbCtKcyiW5"}
Crop
Cropping is useful when you want records to “fit” a schema tightly.
In the following example we remove the uid field since it is not in the connection type:
# spq
type socket = { addr:ip, port:port=uint16 }
type connection = {
kind: string,
client: socket,
server: socket,
vlan: uint16
}
crop(this, <connection>)
# input
{
"kind": "dns",
"server": {
"addr": "10.0.0.100",
"port": 53
},
"client": {
"addr": "10.47.1.100",
"port": 41772
},
"uid": "C2zK5f13SbCtKcyiW5"
}
# expected output
{kind:"dns",server:{addr:"10.0.0.100",port:53},client:{addr:"10.47.1.100",port:41772}}
Fill
Use fill when you want to fill out missing fields with nulls.
In the following example we add a null-valued vlan field since the input value is missing it and
the connection type has it:
# spq
type socket = { addr:ip, port:port=uint16 }
type connection = {
kind: string,
client: socket,
server: socket,
vlan: uint16
}
fill(this, <connection>)
# input
{
"kind": "dns",
"server": {
"addr": "10.0.0.100",
"port": 53
},
"client": {
"addr": "10.47.1.100",
"port": 41772
},
"uid": "C2zK5f13SbCtKcyiW5"
}
# expected output
{kind:"dns",server:{addr:"10.0.0.100",port:53},client:{addr:"10.47.1.100",port:41772},uid:"C2zK5f13SbCtKcyiW5",vlan:null::uint16}
Order
The order function changes the order of fields in its input to match the
order in the specified type, as field order is significant in Zed records.
The following example reorders the client and server fields to match
the input but does nothing about the uid field as it is not in the
connection type.
# spq
type socket = { addr:ip, port:port=uint16 }
type connection = {
kind: string,
client: socket,
server: socket,
vlan: uint16
}
order(this, <connection>)
# input
{
"kind": "dns",
"server": {
"addr": "10.0.0.100",
"port": 53
},
"client": {
"addr": "10.47.1.100",
"port": 41772
},
"uid": "C2zK5f13SbCtKcyiW5"
}
# expected output
{kind:"dns",client:{addr:"10.47.1.100",port:41772},server:{addr:"10.0.0.100",port:53},uid:"C2zK5f13SbCtKcyiW5"}
As an alternative to the order function,
record expressions
can be used to reorder fields without specifying types. For example:
# spq
type socket = { addr:ip, port:port=uint16 }
type connection = {
kind: string,
client: socket,
server: socket,
vlan: uint16
}
values {kind,client,server,...this}
# input
{
"kind": "dns",
"server": {
"addr": "10.0.0.100",
"port": 53
},
"client": {
"addr": "10.47.1.100",
"port": 41772
},
"uid": "C2zK5f13SbCtKcyiW5"
}
# expected output
{kind:"dns",client:{addr:"10.47.1.100",port:41772},server:{addr:"10.0.0.100",port:53},uid:"C2zK5f13SbCtKcyiW5"}
Shape
The shape function brings everything together by applying cast,
fill, and order all in one step.
In the following example we reorder the client and server fields to match
the input but do not impact the uid field as it is not in the connection type.
# spq
type socket = { addr:ip, port:port=uint16 }
type connection = {
kind: string,
client: socket,
server: socket,
vlan: uint16
}
shape(this, <connection>)
# input
{
"kind": "dns",
"server": {
"addr": "10.0.0.100",
"port": 53
},
"client": {
"addr": "10.47.1.100",
"port": 41772
},
"uid": "C2zK5f13SbCtKcyiW5"
}
# expected output
{kind:"dns",client:{addr:10.47.1.100,port:41772::(port=uint16)}::=socket,server:{addr:10.0.0.100,port:53}::socket,vlan:null::uint16,uid:"C2zK5f13SbCtKcyiW5"}
To get a tight shape of the target type,
apply crop to the output of shape, e.g.,
to dropping the uid after shaping:
# spq
type socket = { addr:ip, port:port=uint16 }
type connection = {
kind: string,
client: socket,
server: socket,
vlan: uint16
}
shape(this, <connection>)
| crop(this, <connection>)
# input
{
"kind": "dns",
"server": {
"addr": "10.0.0.100",
"port": 53
},
"client": {
"addr": "10.47.1.100",
"port": 41772
},
"uid": "C2zK5f13SbCtKcyiW5"
}
# expected output
{kind:"dns",client:{addr:10.47.1.100,port:41772::(port=uint16)}::=socket,server:{addr:10.0.0.100,port:53}::socket,vlan:null::uint16}
Error Handling
A failure during shaping produces an error value in the problematic leaf field.
In the next two examples, we use a malformed variation of our input data. When we apply our shaper to it, we now see two errors.
# spq
type socket = { addr:ip, port:port=uint16 }
type connection = {
kind: string,
client: socket,
server: socket,
vlan: uint16
}
shape(this, <connection>)
# input
{
"kind": "dns",
"server": {
"addr": "10.0.0.100",
"port": 53
},
"client": {
"addr": "39 Elm Street",
"port": 41772
},
"vlan": "available"
}
# expected output
{kind:"dns",client:{addr:error({message:"cannot cast to ip",on:"39 Elm Street"}),port:41772::(port=uint16)},server:{addr:10.0.0.100,port:53::port}::=socket,vlan:error({message:"cannot cast to uint16",on:"available"})}
Since these error values are nested inside an otherwise healthy record, adding
has_error(this) downstream in our pipeline
could help find or exclude such records. If the failure to shape any single
field is considered severe enough to render the entire input record unhealthy,
a conditional expression
could be applied to wrap the input record as an error while including detail
to debug the problem, e.g.,
# spq
type socket = { addr:ip, port:port=uint16 }
type connection = {
kind: string,
client: socket,
server: socket,
vlan: uint16
}
values {original: this, shaped: shape(this, <connection>)}
| values has_error(shaped)
? error({
msg: "shaper error (see inner errors for details)",
original,
shaped
})
: shaped
# input
{
"kind": "dns",
"server": {
"addr": "10.0.0.100",
"port": 53
},
"client": {
"addr": "39 Elm Street",
"port": 41772
},
"vlan": "available"
}
# expected output
error({msg:"shaper error (see inner errors for details)",original:{kind:"dns",server:{addr:"10.0.0.100",port:53},client:{addr:"39 Elm Street",port:41772},vlan:"available"},shaped:{kind:"dns",client:{addr:error({message:"cannot cast to ip",on:"39 Elm Street"}),port:41772::(port=uint16)},server:{addr:10.0.0.100,port:53::port}::=socket,vlan:error({message:"cannot cast to uint16",on:"available"})}})::error({msg:string,original:{kind:string,server:{addr:string,port:int64},client:{addr:string,port:int64},vlan:string},shaped:{kind:string,client:{addr:error({message:string,on:string}),port:port=uint16},server:socket={addr:ip,port:port},vlan:error({message:string,on:string})}})
If you require awareness about changes made by the shaping functions that aren’t surfaced as errors, a similar wrapping approach can be used with a general check for equality. For example, to treat cropped fields as an error, we can execute
# spq
type socket = { addr:ip, port:port=uint16 }
type connection = {
kind: string,
client: socket,
server: socket,
vlan: uint16
}
values {original: this, cropped: crop(this, <connection>)}
| values original==cropped
? original
: error({msg: "data was cropped", original, cropped})
# input
{
"kind": "dns",
"server": {
"addr": "10.0.0.100",
"port": 53
},
"client": {
"addr": "10.47.1.100",
"port": 41772
},
"uid": "C2zK5f13SbCtKcyiW5"
}
# expected output
error({msg:"data was cropped",original:{kind:"dns",server:{addr:"10.0.0.100",port:53},client:{addr:"10.47.1.100",port:41772},uid:"C2zK5f13SbCtKcyiW5"},cropped:{kind:"dns",server:{addr:"10.0.0.100",port:53},client:{addr:"10.47.1.100",port:41772}}})
Type Fusion
Type fusion is another important building block of data shaping. Here, types are operated upon by fusing them together, where the result is a single fused type. Some systems call a related process “schema inference” where a set of values, typically JSON, is analyzed to determine a relational schema that all the data will fit into. However, this is just a special case of type fusion as fusion is fine-grained and based on Zed’s type system rather than having the narrower goal of computing a schema for representations like relational tables, Parquet, Avro, etc.
Type fusion utilizes two key techniques.
The first technique is to simply combine types with a type union.
For example, an int64 and a string can be merged into a common
type of union int64|string, e.g., the value sequence 1 "foo"
can be fused into the single-type sequence:
1::(int64|string)
"foo"::(int64|string)
The second technique is to merge fields of records, analogous to a spread
expression. Here, the value sequence {a:1}{b:"foo"} may be
fused into the single-type sequence:
{a:1,b:null::string}
{a:null::int64,b:"foo"}
Of course, these two techniques can be powerfully combined,
e.g., where the value sequence {a:1}{a:"foo",b:2} may be
fused into the single-type sequence:
{a:1::(int64|string),b:null::int64}
{a:"foo"::(int64|string),b:2}
To perform fusion, Zed currently includes two key mechanisms (though this is an active area of development):
- the
fuseoperator, and - the
fuseaggregate function.
Fuse Operator
The fuse operator reads all of its input, computes a fused type using
the techniques above, and outputs the result, e.g.,
# spq
fuse
# input
{x:1}
{y:"foo"}
{x:2,y:"bar"}
# expected output
{x:1,y:null::string}
{x:null::int64,y:"foo"}
{x:2,y:"bar"}
Whereas a type union for field x is produced in the following:
# spq
fuse
# input
{x:1}
{x:"foo",y:"foo"}
{x:2,y:"bar"}
# expected output
{x:1::(int64|string),y:null::string}
{x:"foo"::(int64|string),y:"foo"}
{x:2::(int64|string),y:"bar"}
Fuse Aggregate Function
The fuse aggregate function is most often useful during data exploration and discovery
where you might interactively run queries to determine the shapes of some new
or unknown input data and how those various shapes relate to one another.
For example, in the example sequence above, we can use the fuse aggregate function to determine
the fused type rather than transforming the values, e.g.,
# spq
fuse(this)
# input
{x:1}
{x:"foo",y:"foo"}
{x:2,y:"bar"}
# expected output
<{x:int64|string,y:string}>
Since the fuse here is an aggregate function, it can also be used with
grouping keys. Supposing we want to divide records into categories and fuse
the records in each category, we can use a grouped aggregation. In this simple example, we
will fuse records based on their number of fields using the
len function:
# spq
fuse(this) by len(this) | sort len
# input
{x:1}
{x:"foo",y:"foo"}
{x:2,y:"bar"}
# expected output
{len:1,fuse:<{x:int64}>}
{len:2,fuse:<{x:int64|string,y:string}>}
Now, we can turn around and write a “shaper” for data that has the patterns we “discovered” above, e.g.,
# spq
switch len(this)
case 1 ( pass )
case 2 ( values shape(this, <{x:int64|string,y:string}>) )
default ( values error({kind:"unrecognized shape",value:this}) )
| sort this desc
# input
{x:1}
{x:"foo",y:"foo"}
{x:2,y:"bar"}
{a:1,b:2,c:3}
# expected output
error({kind:"unrecognized shape",value:{a:1,b:2,c:3}})
{x:"foo"::(int64|string),y:"foo"}
{x:2::(int64|string),y:"bar"}
{x:1}
Performance
Performance
TODO: update this?
You might think that the overhead involved in managing super-structured types
and the generality of heterogeneous data would confound the performance of
the super command, but it turns out that super can hold its own when
compared to other analytics systems.
To illustrate comparative performance, we’ll present some informal performance measurements among SuperDB, DuckDB, ClickHouse, and DataFusion.
We’ll use the Parquet format to compare apples to apples
and also report results for the custom columnar database format of DuckDB,
the new beta JSON type of ClickHouse,
and the BSUP format used by super.
The detailed steps shown below can be reproduced via
automated scripts.
As of this writing in December 2024, results were gathered on an AWS
m6idn.2xlarge instance
with the following software versions:
| Software | Version |
|---|---|
super | Commit 3900a40 |
duckdb | v1.1.3 19864453f7 |
datafusion-cli | datafusion-cli 43.0.0 |
clickhouse | ClickHouse local version 24.12.1.1614 (official build) |
The complete run logs are archived here.
The Test Data
These tests are based on the data and exemplary queries published by the DuckDB team on their blog Shredding Deeply Nested JSON, One Vector at a Time. We’ll follow their script starting at the GitHub Archive Examples.
If you want to reproduce these results for yourself, you can fetch the 2.2GB of gzipped JSON data:
wget https://data.gharchive.org/2023-02-08-0.json.gz
wget https://data.gharchive.org/2023-02-08-1.json.gz
...
wget https://data.gharchive.org/2023-02-08-23.json.gz
We downloaded these files into a directory called gharchive_gz
and created a DuckDB database file called gha.db and a table called gha
using this command:
duckdb gha.db -c "CREATE TABLE gha AS FROM read_json('gharchive_gz/*.json.gz', union_by_name=true)"
To create a relational table from the input JSON, we utilized DuckDB’s
union_by_name parameter to fuse all of the different shapes of JSON encountered
into a single monolithic schema.
We then created a Parquet file called gha.parquet with this command:
duckdb gha.db -c "COPY (from gha) TO 'gha.parquet'"
To create a ClickHouse table using their beta JSON type, after starting a ClickHouse server we defined the single-column schema before loading the data using this command:
clickhouse-client --query "
SET enable_json_type = 1;
CREATE TABLE gha (v JSON) ENGINE MergeTree() ORDER BY tuple();
INSERT INTO gha SELECT * FROM file('gharchive_gz/*.json.gz', JSONAsObject);"
To create a super-structed file for the super command, there is no need to
fuse the data into a single schema (though super can still work with the fused
schema in the Parquet file), and we simply ran this command to create a BSUP
file:
super gharchive_gz/*.json.gz > gha.bsup
This code path in super is not multi-threaded so not particularly performant,
but on our test machine it runs a bit faster than both the duckdb method of
creating a schema-fused table or loading the data to the clickhouse beta JSON type.
Here are the resulting file sizes:
% du -h gha.db gha.parquet gha.bsup gharchive_gz clickhouse/store
9.4G gha.db
4.7G gha.parquet
2.9G gha.bsup
2.3G gharchive_gz
11G clickhouse/store
The Test Queries
The test queries involve these patterns:
- simple search (single and multicolumn)
- count-where aggregation
- count by field aggregation
- rank over union of disparate field types
We will call these tests search, search+, count, agg, and union, respectively
Search
For the search test, we’ll search for the string pattern
"in case you have any feedback 😊"
in the field payload.pull_request.body
and we’ll just count the number of matches found.
The number of matches is small (2) so the query performance is dominated
by the search.
The SQL for this query is
SELECT count()
FROM 'gha.parquet' -- or gha
WHERE payload.pull_request.body LIKE '%in case you have any feedback 😊%'
To query the data stored with the ClickHouse JSON type, field
references needed to be rewritten relative to the named column v.
SELECT count()
FROM 'gha'
WHERE v.payload.pull_request.body LIKE '%in case you have any feedback 😊%'
SuperSQL supports LIKE and could run the plain SQL query, but it also has a
similar function called grep that can operate over specified fields or
default to all the string fields in any value. The SuperSQL query that uses
grep is
SELECT count()
FROM 'gha.bsup'
WHERE grep('in case you have any feedback 😊', payload.pull_request.body)
Search+
For search across multiple columns, SQL doesn’t have a grep function so
we must enumerate all the fields of such a query. The SQL for a string search
over our GitHub Archive dataset involves the following fields:
SELECT count() FROM gha
WHERE id LIKE '%in case you have any feedback 😊%'
OR type LIKE '%in case you have any feedback 😊%'
OR actor.login LIKE '%in case you have any feedback 😊%'
OR actor.display_login LIKE '%in case you have any feedback 😊%'
...
OR payload.member.type LIKE '%in case you have any feedback 😊%'
There are 486 such fields. You can review the entire query in
search+.sql.
To query the data stored with the ClickHouse JSON type, field
references needed to be rewritten relative to the named column v.
SELECT count()
FROM 'gha'
WHERE
v.id LIKE '%in case you have any feedback 😊%'
OR v.type LIKE '%in case you have any feedback 😊%'
...
In SuperSQL, grep allows for a much shorter query.
SELECT count()
FROM 'gha.bsup'
WHERE grep('in case you have any feedback 😊')
Count
In the count test, we filter the input with a WHERE clause and count the results.
We chose a random GitHub user name for the filter.
This query has the form:
SELECT count()
FROM 'gha.parquet' -- or gha or 'gha.bsup'
WHERE actor.login='johnbieren'"
To query the data stored with the ClickHouse JSON type, field
references needed to be rewritten relative to the named column v.
SELECT count()
FROM 'gha'
WHERE v.actor.login='johnbieren'
Agg
In the agg test, we filter the input and count the results grouped by the field type
as in the DuckDB blog.
This query has the form:
SELECT count(),type
FROM 'gha.parquet' -- or 'gha' or 'gha.bsup'
WHERE repo.name='duckdb/duckdb'
GROUP BY type
To query the data stored with the ClickHouse JSON type, field
references needed to be rewritten relative to the named column v.
SET allow_suspicious_types_in_group_by = 1;
SELECT count(),v.type
FROM 'gha'
WHERE v.repo.name='duckdb/duckdb'
GROUP BY v.type
Also, we had to enable the allow_suspicious_types_in_group_by setting as
shown above because an initial attempt to query with default settings
triggered the error:
Code: 44. DB::Exception: Received from localhost:9000. DB::Exception: Data
types Variant/Dynamic are not allowed in GROUP BY keys, because it can lead
to unexpected results. Consider using a subcolumn with a specific data type
instead (for example 'column.Int64' or 'json.some.path.:Int64' if its a JSON
path subcolumn) or casting this column to a specific data type. Set setting
allow_suspicious_types_in_group_by = 1 in order to allow it. (ILLEGAL_COLUMN)
Union
The union test is straight out of the DuckDB blog at the end of this section. This query computes the GitHub users that were assigned as a PR reviewer the most often and returns the top 5 such users. Because the assignees can appear in either a list of strings or within a single string field, the relational model requires that two different subqueries run for the two cases and the result unioned together; then, this intermediary table can be counted using the unnested assignee as the grouping key. This query is:
WITH assignees AS (
SELECT payload.pull_request.assignee.login assignee
FROM 'gha.parquet' -- or 'gha'
UNION ALL
SELECT unnest(payload.pull_request.assignees).login assignee
FROM 'gha.parquet' -- or 'gha'
)
SELECT assignee, count(*) count
FROM assignees
WHERE assignee IS NOT NULL
GROUP BY assignee
ORDER BY count DESC
LIMIT 5
For DataFusion, we needed to rewrite this SELECT
SELECT unnest(payload.pull_request.assignees).login
FROM 'gha.parquet'
as
SELECT object.login as assignee FROM (
SELECT unnest(payload.pull_request.assignees) object
FROM 'gha.parquet'
)
and for ClickHouse, we had to use arrayJoin instead of unnest.
Even with this change ClickHouse could only run the query successfully against
the Parquet data, as after rewriting the field references to attempt to
query the data stored with the ClickHouse JSON type it would not run. We
suspect this is likely due to some remaining work in ClickHouse for arrayJoin
to work with the new JSON type.
$ clickhouse-client --query "
WITH assignees AS (
SELECT v.payload.pull_request.assignee.login assignee
FROM 'gha'
UNION ALL
SELECT arrayJoin(v.payload.pull_request.assignees).login assignee
FROM 'gha'
)
SELECT assignee, count(*) count
FROM assignees
WHERE assignee IS NOT NULL
GROUP BY assignee
ORDER BY count DESC
LIMIT 5"
Received exception from server (version 24.11.1):
Code: 43. DB::Exception: Received from localhost:9000. DB::Exception: First
argument for function tupleElement must be tuple or array of tuple. Actual
Dynamic: In scope SELECT tupleElement(arrayJoin(v.payload.pull_request.assignees),
'login') AS assignee FROM gha. (ILLEGAL_TYPE_OF_ARGUMENT)
SuperSQL’s data model does not require these kinds of gymnastics as
everything does not have to be jammed into a table. Instead, we can use the
UNNEST pipe operator combined with the
spread operator
applied to the array of
string fields to easily produce a stream of string values representing the
assignees. Then we simply aggregate the assignee stream:
FROM 'gha.bsup'
| UNNEST [...payload.pull_request.assignees, payload.pull_request.assignee]
| WHERE this IS NOT NULL
| AGGREGATE count() BY assignee:=login
| ORDER BY count DESC
| LIMIT 5
The Test Results
The following table summarizes the query performance for each tool as recorded in the most recent archived run. The run time for each query in seconds is shown along with the speed-up factor in parentheses:
| Tool | Format | search | search+ | count | agg | union |
|---|---|---|---|---|---|---|
super | bsup | 6.4 (1.9x) | 12.5 (1.6x) | 5.8 (0.03x) | 5.6 (0.03x) | 8.2 (64x) |
super | parquet | 40.8 (0.3x) | 55.1 (0.4x) | 0.3 (0.6x) | 0.5 (0.3x) | 40 (13.2x) |
duckdb | db | 12.1 (1x) | 19.8 (1x) | 0.2 (1x) | 0.1 (1x) | 527 (1x) |
duckdb | parquet | 13.3 (0.9x) | 21.3 (0.9x) | 0.4 (0.4x) | 0.3 (0.4x) | 488 (1.1x) |
datafusion | parquet | 11.0 (1.1x) | 21.2 (0.9x) | 0.4 (0.5x) | 0.4 (0.4x) | 24.2 (22x) |
clickhouse | parquet | 70 (0.2x) | 829 (0.02x) | 1.0 (0.2x) | 0.9 (0.2x) | 71.4 (7x) |
clickhouse | db | 0.9 (14x) | 12.8 (1.6x) | 0.1 (2.2x) | 0.1 (1.2x) | note |
Note: we were not able to successfully run the union query with ClickHouse’s beta JSON type
Since DuckDB with its native format could successfully run all queries with decent performance, we used it as the baseline for all of the speed-up factors.
To summarize,
super with BSUP is substantially faster than multiple relational systems for
the search use cases, and with Parquet performs on par with the others for traditional OLAP queries,
except for the union query, where the super-structured data model trounces the relational
model (by over 60x!) for stitching together disparate data types for analysis in an aggregation.
Appendix 1: Preparing the Test Data
For our tests, we diverged a bit from the methodology in the DuckDB blog and wanted to put all the JSON data in a single table. It wasn’t obvious how to go about this and this section documents the difficulties we encountered trying to do so.
First, we simply tried this:
duckdb gha.db -c "CREATE TABLE gha AS FROM 'gharchive_gz/*.json.gz'"
which fails with
Invalid Input Error: JSON transform error in file "gharchive_gz/2023-02-08-10.json.gz", in line 4903: Object {"url":"https://api.github.com/repos/aws/aws-sam-c... has unknown key "reactions"
Try increasing 'sample_size', reducing 'maximum_depth', specifying 'columns', 'format' or 'records' manually, setting 'ignore_errors' to true, or setting 'union_by_name' to true when reading multiple files with a different structure.
Clearly the schema inference algorithm relies upon sampling and the sample doesn’t cover enough data to capture all of its variations.
Okay, maybe there is a reason the blog first explores the structure of
the data to specify columns arguments to read_json as suggested by the error
message above. To this end, you can run this query:
SELECT json_group_structure(json)
FROM (
SELECT *
FROM read_ndjson_objects('gharchive_gz/*.json.gz')
LIMIT 2048
);
Unfortunately, if you use the resulting structure to create the columns argument
then duckdb fails also because the first 2048 records don’t have enough coverage.
So let’s try removing the LIMIT clause:
SELECT json_group_structure(json)
FROM (
SELECT *
FROM read_ndjson_objects('gharchive_gz/*.json.gz')
);
Hmm, now duckdb runs out of memory.
We then thought we’d see if the sampling algorithm of read_json is more efficient,
so we tried this command with successively larger sample sizes:
duckdb scratch -c "CREATE TABLE gha AS FROM read_json('gharchive_gz/*.json.gz', sample_size=1000000)"
Even with a million rows as the sample, duckdb fails with
Invalid Input Error: JSON transform error in file "gharchive_gz/2023-02-08-14.json.gz", in line 49745: Object {"issues":"write","metadata":"read","pull_requests... has unknown key "repository_hooks"
Try increasing 'sample_size', reducing 'maximum_depth', specifying 'columns', 'format' or 'records' manually, setting 'ignore_errors' to true, or setting 'union_by_name' to true when reading multiple files with a different structure.
Ok, there 4,434,953 JSON objects in the input so let’s try this:
duckdb gha.db -c "CREATE TABLE gha AS FROM read_json('gharchive_gz/*.json.gz', sample_size=4434953)"
and again duckdb runs out of memory.
So we looked at the other options suggested by the error message and
union_by_name appeared promising. Enabling this option causes DuckDB
to combine all the JSON objects into a single fused schema.
Maybe this would work better?
Sure enough, this works:
duckdb gha.db -c "CREATE TABLE gha AS FROM read_json('gharchive_gz/*.json.gz', union_by_name=true)"
We now have the DuckDB database file for our GitHub Archive data called gha.db
containing a single table called gha embedded in that database.
What about the super-structured
format for the super command? There is no need to futz with sample sizes,
schema inference, or union by name. Just run this to create a BSUP file:
super gharchive_gz/*.json.gz > gha.bsup
Appendix 2: Running the Tests
This appendix provides the raw tests and output from the most recent archived run
of the tests via automated scripts
on an AWS m6idn.2xlarge instance.
Search Test
About to execute
================
clickhouse-client --queries-file /mnt/tmpdir/tmp.NlvDgOOmnG
With query
==========
SELECT count()
FROM 'gha'
WHERE v.payload.pull_request.body LIKE '%in case you have any feedback 😊%'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'clickhouse-client --queries-file /mnt/tmpdir/tmp.NlvDgOOmnG'
Benchmark 1: clickhouse-client --queries-file /mnt/tmpdir/tmp.NlvDgOOmnG
2
Time (abs ≡): 0.870 s [User: 0.045 s, System: 0.023 s]
About to execute
================
clickhouse --queries-file /mnt/tmpdir/tmp.0bwhkb0l9n
With query
==========
SELECT count()
FROM '/mnt/gha.parquet'
WHERE payload.pull_request.body LIKE '%in case you have any feedback 😊%'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'clickhouse --queries-file /mnt/tmpdir/tmp.0bwhkb0l9n'
Benchmark 1: clickhouse --queries-file /mnt/tmpdir/tmp.0bwhkb0l9n
2
Time (abs ≡): 69.650 s [User: 69.485 s, System: 3.096 s]
About to execute
================
datafusion-cli --file /mnt/tmpdir/tmp.S0ITz1nHQG
With query
==========
SELECT count()
FROM '/mnt/gha.parquet'
WHERE payload.pull_request.body LIKE '%in case you have any feedback 😊%'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'datafusion-cli --file /mnt/tmpdir/tmp.S0ITz1nHQG'
Benchmark 1: datafusion-cli --file /mnt/tmpdir/tmp.S0ITz1nHQG
DataFusion CLI v43.0.0
+---------+
| count() |
+---------+
| 2 |
+---------+
1 row(s) fetched.
Elapsed 10.811 seconds.
Time (abs ≡): 11.041 s [User: 65.647 s, System: 11.209 s]
About to execute
================
duckdb /mnt/gha.db < /mnt/tmpdir/tmp.wsNTlXhTTF
With query
==========
SELECT count()
FROM 'gha'
WHERE payload.pull_request.body LIKE '%in case you have any feedback 😊%'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'duckdb /mnt/gha.db < /mnt/tmpdir/tmp.wsNTlXhTTF'
Benchmark 1: duckdb /mnt/gha.db < /mnt/tmpdir/tmp.wsNTlXhTTF
┌──────────────┐
│ count_star() │
│ int64 │
├──────────────┤
│ 2 │
└──────────────┘
Time (abs ≡): 12.051 s [User: 78.680 s, System: 8.891 s]
About to execute
================
duckdb < /mnt/tmpdir/tmp.hPiKS1Qi1A
With query
==========
SELECT count()
FROM '/mnt/gha.parquet'
WHERE payload.pull_request.body LIKE '%in case you have any feedback 😊%'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'duckdb < /mnt/tmpdir/tmp.hPiKS1Qi1A'
Benchmark 1: duckdb < /mnt/tmpdir/tmp.hPiKS1Qi1A
┌──────────────┐
│ count_star() │
│ int64 │
├──────────────┤
│ 2 │
└──────────────┘
Time (abs ≡): 13.267 s [User: 90.148 s, System: 6.506 s]
About to execute
================
super -s -I /mnt/tmpdir/tmp.pDeSZCTa2V
With query
==========
SELECT count()
FROM '/mnt/gha.bsup'
WHERE grep('in case you have any feedback 😊', payload.pull_request.body)
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'super -s -I /mnt/tmpdir/tmp.pDeSZCTa2V'
Benchmark 1: super -s -I /mnt/tmpdir/tmp.pDeSZCTa2V
{count:2::uint64}
Time (abs ≡): 6.371 s [User: 23.178 s, System: 1.700 s]
About to execute
================
SUPER_VAM=1 super -s -I /mnt/tmpdir/tmp.AYZIh6yi2s
With query
==========
SELECT count()
FROM '/mnt/gha.parquet'
WHERE grep('in case you have any feedback 😊', payload.pull_request.body)
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'SUPER_VAM=1 super -s -I /mnt/tmpdir/tmp.AYZIh6yi2s'
Benchmark 1: SUPER_VAM=1 super -s -I /mnt/tmpdir/tmp.AYZIh6yi2s
{count:2::uint64}
Time (abs ≡): 40.838 s [User: 292.674 s, System: 18.797 s]
Search+ Test
About to execute
================
clickhouse-client --queries-file /mnt/tmpdir/tmp.PFNN1fKojv
With query
==========
SELECT count()
FROM 'gha'
WHERE
v.id LIKE '%in case you have any feedback 😊%'
OR v.type LIKE '%in case you have any feedback 😊%'
...
OR v.payload.member.type LIKE '%in case you have any feedback 😊%'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'clickhouse-client --queries-file /mnt/tmpdir/tmp.PFNN1fKojv'
Benchmark 1: clickhouse-client --queries-file /mnt/tmpdir/tmp.PFNN1fKojv
3
Time (abs ≡): 12.773 s [User: 0.061 s, System: 0.025 s]
About to execute
================
clickhouse --queries-file /mnt/tmpdir/tmp.PTRkZ4ZIXX
With query
==========
SELECT count()
FROM '/mnt/gha.parquet'
WHERE
id LIKE '%in case you have any feedback 😊%'
OR type LIKE '%in case you have any feedback 😊%'
...
OR payload.member.type LIKE '%in case you have any feedback 😊%'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'clickhouse --queries-file /mnt/tmpdir/tmp.PTRkZ4ZIXX'
Benchmark 1: clickhouse --queries-file /mnt/tmpdir/tmp.PTRkZ4ZIXX
3
Time (abs ≡): 828.691 s [User: 908.452 s, System: 17.692 s]
About to execute
================
datafusion-cli --file /mnt/tmpdir/tmp.SCtJ9sNeBA
With query
==========
SELECT count()
FROM '/mnt/gha.parquet'
WHERE
id LIKE '%in case you have any feedback 😊%'
OR type LIKE '%in case you have any feedback 😊%'
...
OR payload.member.type LIKE '%in case you have any feedback 😊%'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'datafusion-cli --file /mnt/tmpdir/tmp.SCtJ9sNeBA'
Benchmark 1: datafusion-cli --file /mnt/tmpdir/tmp.SCtJ9sNeBA
DataFusion CLI v43.0.0
+---------+
| count() |
+---------+
| 3 |
+---------+
1 row(s) fetched.
Elapsed 20.990 seconds.
Time (abs ≡): 21.228 s [User: 127.034 s, System: 19.513 s]
About to execute
================
duckdb /mnt/gha.db < /mnt/tmpdir/tmp.SXkIoC2XJo
With query
==========
SELECT count()
FROM 'gha'
WHERE
id LIKE '%in case you have any feedback 😊%'
OR type LIKE '%in case you have any feedback 😊%'
...
OR payload.member.type LIKE '%in case you have any feedback 😊%'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'duckdb /mnt/gha.db < /mnt/tmpdir/tmp.SXkIoC2XJo'
Benchmark 1: duckdb /mnt/gha.db < /mnt/tmpdir/tmp.SXkIoC2XJo
┌──────────────┐
│ count_star() │
│ int64 │
├──────────────┤
│ 3 │
└──────────────┘
Time (abs ≡): 19.814 s [User: 140.302 s, System: 9.875 s]
About to execute
================
duckdb < /mnt/tmpdir/tmp.k6yVjzT4cu
With query
==========
SELECT count()
FROM '/mnt/gha.parquet'
WHERE
id LIKE '%in case you have any feedback 😊%'
OR type LIKE '%in case you have any feedback 😊%'
...
OR payload.member.type LIKE '%in case you have any feedback 😊%'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'duckdb < /mnt/tmpdir/tmp.k6yVjzT4cu'
Benchmark 1: duckdb < /mnt/tmpdir/tmp.k6yVjzT4cu
┌──────────────┐
│ count_star() │
│ int64 │
├──────────────┤
│ 3 │
└──────────────┘
Time (abs ≡): 21.286 s [User: 145.120 s, System: 8.677 s]
About to execute
================
super -s -I /mnt/tmpdir/tmp.jJSibCjp8r
With query
==========
SELECT count()
FROM '/mnt/gha.bsup'
WHERE grep('in case you have any feedback 😊')
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'super -s -I /mnt/tmpdir/tmp.jJSibCjp8r'
Benchmark 1: super -s -I /mnt/tmpdir/tmp.jJSibCjp8r
{count:3::uint64}
Time (abs ≡): 12.492 s [User: 88.901 s, System: 1.672 s]
About to execute
================
SUPER_VAM=1 super -s -I /mnt/tmpdir/tmp.evXq1mxkI0
With query
==========
SELECT count()
FROM '/mnt/gha.parquet'
WHERE grep('in case you have any feedback 😊')
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'SUPER_VAM=1 super -s -I /mnt/tmpdir/tmp.evXq1mxkI0'
Benchmark 1: SUPER_VAM=1 super -s -I /mnt/tmpdir/tmp.evXq1mxkI0
{count:3::uint64}
Time (abs ≡): 55.081 s [User: 408.337 s, System: 18.597 s]
Count Test
About to execute
================
clickhouse-client --queries-file /mnt/tmpdir/tmp.Wqytp5T3II
With query
==========
SELECT count()
FROM 'gha'
WHERE v.actor.login='johnbieren'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'clickhouse-client --queries-file /mnt/tmpdir/tmp.Wqytp5T3II'
Benchmark 1: clickhouse-client --queries-file /mnt/tmpdir/tmp.Wqytp5T3II
879
Time (abs ≡): 0.081 s [User: 0.021 s, System: 0.023 s]
About to execute
================
clickhouse --queries-file /mnt/tmpdir/tmp.O95s9fJprP
With query
==========
SELECT count()
FROM '/mnt/gha.parquet'
WHERE actor.login='johnbieren'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'clickhouse --queries-file /mnt/tmpdir/tmp.O95s9fJprP'
Benchmark 1: clickhouse --queries-file /mnt/tmpdir/tmp.O95s9fJprP
879
Time (abs ≡): 0.972 s [User: 0.836 s, System: 0.156 s]
About to execute
================
datafusion-cli --file /mnt/tmpdir/tmp.CHTPCdHbaG
With query
==========
SELECT count()
FROM '/mnt/gha.parquet'
WHERE actor.login='johnbieren'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'datafusion-cli --file /mnt/tmpdir/tmp.CHTPCdHbaG'
Benchmark 1: datafusion-cli --file /mnt/tmpdir/tmp.CHTPCdHbaG
DataFusion CLI v43.0.0
+---------+
| count() |
+---------+
| 879 |
+---------+
1 row(s) fetched.
Elapsed 0.340 seconds.
Time (abs ≡): 0.384 s [User: 1.600 s, System: 0.409 s]
About to execute
================
duckdb /mnt/gha.db < /mnt/tmpdir/tmp.VQ2IgDaeUO
With query
==========
SELECT count()
FROM 'gha'
WHERE actor.login='johnbieren'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'duckdb /mnt/gha.db < /mnt/tmpdir/tmp.VQ2IgDaeUO'
Benchmark 1: duckdb /mnt/gha.db < /mnt/tmpdir/tmp.VQ2IgDaeUO
┌──────────────┐
│ count_star() │
│ int64 │
├──────────────┤
│ 879 │
└──────────────┘
Time (abs ≡): 0.178 s [User: 1.070 s, System: 0.131 s]
About to execute
================
duckdb < /mnt/tmpdir/tmp.rjFqrZFUtF
With query
==========
SELECT count()
FROM '/mnt/gha.parquet'
WHERE actor.login='johnbieren'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'duckdb < /mnt/tmpdir/tmp.rjFqrZFUtF'
Benchmark 1: duckdb < /mnt/tmpdir/tmp.rjFqrZFUtF
┌──────────────┐
│ count_star() │
│ int64 │
├──────────────┤
│ 879 │
└──────────────┘
Time (abs ≡): 0.426 s [User: 2.252 s, System: 0.194 s]
About to execute
================
super -s -I /mnt/tmpdir/tmp.AbeKpBbYW8
With query
==========
SELECT count()
FROM '/mnt/gha.bsup'
WHERE actor.login='johnbieren'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'super -s -I /mnt/tmpdir/tmp.AbeKpBbYW8'
Benchmark 1: super -s -I /mnt/tmpdir/tmp.AbeKpBbYW8
{count:879::uint64}
Time (abs ≡): 5.786 s [User: 17.405 s, System: 1.637 s]
About to execute
================
SUPER_VAM=1 super -s -I /mnt/tmpdir/tmp.5xTnB02WgG
With query
==========
SELECT count()
FROM '/mnt/gha.parquet'
WHERE actor.login='johnbieren'
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'SUPER_VAM=1 super -s -I /mnt/tmpdir/tmp.5xTnB02WgG'
Benchmark 1: SUPER_VAM=1 super -s -I /mnt/tmpdir/tmp.5xTnB02WgG
{count:879::uint64}
Time (abs ≡): 0.303 s [User: 0.792 s, System: 0.240 s]
Agg Test
About to execute
================
clickhouse --queries-file /mnt/tmpdir/tmp.k2UT3NLBd6
With query
==========
SELECT count(),type
FROM '/mnt/gha.parquet'
WHERE repo.name='duckdb/duckdb'
GROUP BY type
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'clickhouse --queries-file /mnt/tmpdir/tmp.k2UT3NLBd6'
Benchmark 1: clickhouse --queries-file /mnt/tmpdir/tmp.k2UT3NLBd6
30 IssueCommentEvent
14 PullRequestReviewEvent
29 WatchEvent
15 PushEvent
7 PullRequestReviewCommentEvent
9 IssuesEvent
3 ForkEvent
35 PullRequestEvent
Time (abs ≡): 0.860 s [User: 0.757 s, System: 0.172 s]
About to execute
================
clickhouse-client --queries-file /mnt/tmpdir/tmp.MqFw3Iihza
With query
==========
SET allow_suspicious_types_in_group_by = 1;
SELECT count(),v.type
FROM 'gha'
WHERE v.repo.name='duckdb/duckdb'
GROUP BY v.type
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'clickhouse-client --queries-file /mnt/tmpdir/tmp.MqFw3Iihza'
Benchmark 1: clickhouse-client --queries-file /mnt/tmpdir/tmp.MqFw3Iihza
14 PullRequestReviewEvent
15 PushEvent
9 IssuesEvent
3 ForkEvent
7 PullRequestReviewCommentEvent
29 WatchEvent
30 IssueCommentEvent
35 PullRequestEvent
Time (abs ≡): 0.122 s [User: 0.032 s, System: 0.019 s]
About to execute
================
datafusion-cli --file /mnt/tmpdir/tmp.Rf1BJWypeQ
With query
==========
SELECT count(),type
FROM '/mnt/gha.parquet'
WHERE repo.name='duckdb/duckdb'
GROUP BY type
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'datafusion-cli --file /mnt/tmpdir/tmp.Rf1BJWypeQ'
Benchmark 1: datafusion-cli --file /mnt/tmpdir/tmp.Rf1BJWypeQ
DataFusion CLI v43.0.0
+---------+-------------------------------+
| count() | type |
+---------+-------------------------------+
| 29 | WatchEvent |
| 3 | ForkEvent |
| 35 | PullRequestEvent |
| 14 | PullRequestReviewEvent |
| 7 | PullRequestReviewCommentEvent |
| 30 | IssueCommentEvent |
| 9 | IssuesEvent |
| 15 | PushEvent |
+---------+-------------------------------+
8 row(s) fetched.
Elapsed 0.320 seconds.
Time (abs ≡): 0.365 s [User: 1.399 s, System: 0.399 s]
About to execute
================
duckdb /mnt/gha.db < /mnt/tmpdir/tmp.pEWjK5q2sA
With query
==========
SELECT count(),type
FROM 'gha'
WHERE repo.name='duckdb/duckdb'
GROUP BY type
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'duckdb /mnt/gha.db < /mnt/tmpdir/tmp.pEWjK5q2sA'
Benchmark 1: duckdb /mnt/gha.db < /mnt/tmpdir/tmp.pEWjK5q2sA
┌──────────────┬───────────────────────────────┐
│ count_star() │ type │
│ int64 │ varchar │
├──────────────┼───────────────────────────────┤
│ 14 │ PullRequestReviewEvent │
│ 29 │ WatchEvent │
│ 30 │ IssueCommentEvent │
│ 15 │ PushEvent │
│ 9 │ IssuesEvent │
│ 7 │ PullRequestReviewCommentEvent │
│ 3 │ ForkEvent │
│ 35 │ PullRequestEvent │
└──────────────┴───────────────────────────────┘
Time (abs ≡): 0.141 s [User: 0.756 s, System: 0.147 s]
About to execute
================
duckdb < /mnt/tmpdir/tmp.cC0xpHh2ee
With query
==========
SELECT count(),type
FROM '/mnt/gha.parquet'
WHERE repo.name='duckdb/duckdb'
GROUP BY type
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'duckdb < /mnt/tmpdir/tmp.cC0xpHh2ee'
Benchmark 1: duckdb < /mnt/tmpdir/tmp.cC0xpHh2ee
┌──────────────┬───────────────────────────────┐
│ count_star() │ type │
│ int64 │ varchar │
├──────────────┼───────────────────────────────┤
│ 3 │ ForkEvent │
│ 14 │ PullRequestReviewEvent │
│ 15 │ PushEvent │
│ 9 │ IssuesEvent │
│ 7 │ PullRequestReviewCommentEvent │
│ 29 │ WatchEvent │
│ 30 │ IssueCommentEvent │
│ 35 │ PullRequestEvent │
└──────────────┴───────────────────────────────┘
Time (abs ≡): 0.320 s [User: 1.529 s, System: 0.175 s]
About to execute
================
super -s -I /mnt/tmpdir/tmp.QMhaBvUi2y
With query
==========
SELECT count(),type
FROM '/mnt/gha.bsup'
WHERE repo.name='duckdb/duckdb'
GROUP BY type
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'super -s -I /mnt/tmpdir/tmp.QMhaBvUi2y'
Benchmark 1: super -s -I /mnt/tmpdir/tmp.QMhaBvUi2y
{type:"PullRequestReviewCommentEvent",count:7::uint64}
{type:"PullRequestReviewEvent",count:14::uint64}
{type:"IssueCommentEvent",count:30::uint64}
{type:"WatchEvent",count:29::uint64}
{type:"PullRequestEvent",count:35::uint64}
{type:"PushEvent",count:15::uint64}
{type:"IssuesEvent",count:9::uint64}
{type:"ForkEvent",count:3::uint64}
Time (abs ≡): 5.626 s [User: 15.509 s, System: 1.552 s]
About to execute
================
SUPER_VAM=1 super -s -I /mnt/tmpdir/tmp.yfAdMeskPR
With query
==========
SELECT count(),type
FROM '/mnt/gha.parquet'
WHERE repo.name='duckdb/duckdb'
GROUP BY type
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'SUPER_VAM=1 super -s -I /mnt/tmpdir/tmp.yfAdMeskPR'
Benchmark 1: SUPER_VAM=1 super -s -I /mnt/tmpdir/tmp.yfAdMeskPR
{type:"PushEvent",count:15::uint64}
{type:"IssuesEvent",count:9::uint64}
{type:"WatchEvent",count:29::uint64}
{type:"PullRequestEvent",count:35::uint64}
{type:"ForkEvent",count:3::uint64}
{type:"PullRequestReviewCommentEvent",count:7::uint64}
{type:"PullRequestReviewEvent",count:14::uint64}
{type:"IssueCommentEvent",count:30::uint64}
Time (abs ≡): 0.491 s [User: 2.049 s, System: 0.357 s]
Union Test
About to execute
================
clickhouse --queries-file /mnt/tmpdir/tmp.6r4kTKMn1T
With query
==========
WITH assignees AS (
SELECT payload.pull_request.assignee.login assignee
FROM '/mnt/gha.parquet'
UNION ALL
SELECT arrayJoin(payload.pull_request.assignees).login assignee
FROM '/mnt/gha.parquet'
)
SELECT assignee, count(*) count
FROM assignees
WHERE assignee IS NOT NULL
GROUP BY assignee
ORDER BY count DESC
LIMIT 5
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'clickhouse --queries-file /mnt/tmpdir/tmp.6r4kTKMn1T'
Benchmark 1: clickhouse --queries-file /mnt/tmpdir/tmp.6r4kTKMn1T
poad 1966
vinayakkulkarni 508
tmtmtmtm 356
AMatutat 260
danwinship 208
Time (abs ≡): 71.372 s [User: 142.043 s, System: 6.278 s]
About to execute
================
datafusion-cli --file /mnt/tmpdir/tmp.GgJzlAtf6a
With query
==========
WITH assignees AS (
SELECT payload.pull_request.assignee.login assignee
FROM '/mnt/gha.parquet'
UNION ALL
SELECT object.login as assignee FROM (
SELECT unnest(payload.pull_request.assignees) object
FROM '/mnt/gha.parquet'
)
)
SELECT assignee, count() count
FROM assignees
WHERE assignee IS NOT NULL
GROUP BY assignee
ORDER BY count DESC
LIMIT 5
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'datafusion-cli --file /mnt/tmpdir/tmp.GgJzlAtf6a'
Benchmark 1: datafusion-cli --file /mnt/tmpdir/tmp.GgJzlAtf6a
DataFusion CLI v43.0.0
+-----------------+-------+
| assignee | count |
+-----------------+-------+
| poad | 1966 |
| vinayakkulkarni | 508 |
| tmtmtmtm | 356 |
| AMatutat | 260 |
| danwinship | 208 |
+-----------------+-------+
5 row(s) fetched.
Elapsed 23.907 seconds.
Time (abs ≡): 24.215 s [User: 163.583 s, System: 24.973 s]
About to execute
================
duckdb /mnt/gha.db < /mnt/tmpdir/tmp.Q49a92Gvr5
With query
==========
WITH assignees AS (
SELECT payload.pull_request.assignee.login assignee
FROM 'gha'
UNION ALL
SELECT unnest(payload.pull_request.assignees).login assignee
FROM 'gha'
)
SELECT assignee, count(*) count
FROM assignees
WHERE assignee IS NOT NULL
GROUP BY assignee
ORDER BY count DESC
LIMIT 5
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'duckdb /mnt/gha.db < /mnt/tmpdir/tmp.Q49a92Gvr5'
Benchmark 1: duckdb /mnt/gha.db < /mnt/tmpdir/tmp.Q49a92Gvr5
┌─────────────────┬───────┐
│ assignee │ count │
│ varchar │ int64 │
├─────────────────┼───────┤
│ poad │ 1966 │
│ vinayakkulkarni │ 508 │
│ tmtmtmtm │ 356 │
│ AMatutat │ 260 │
│ danwinship │ 208 │
└─────────────────┴───────┘
Time (abs ≡): 527.130 s [User: 4056.419 s, System: 15.145 s]
About to execute
================
duckdb < /mnt/tmpdir/tmp.VQYM2LCNeB
With query
==========
WITH assignees AS (
SELECT payload.pull_request.assignee.login assignee
FROM '/mnt/gha.parquet'
UNION ALL
SELECT unnest(payload.pull_request.assignees).login assignee
FROM '/mnt/gha.parquet'
)
SELECT assignee, count(*) count
FROM assignees
WHERE assignee IS NOT NULL
GROUP BY assignee
ORDER BY count DESC
LIMIT 5
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'duckdb < /mnt/tmpdir/tmp.VQYM2LCNeB'
Benchmark 1: duckdb < /mnt/tmpdir/tmp.VQYM2LCNeB
┌─────────────────┬───────┐
│ assignee │ count │
│ varchar │ int64 │
├─────────────────┼───────┤
│ poad │ 1966 │
│ vinayakkulkarni │ 508 │
│ tmtmtmtm │ 356 │
│ AMatutat │ 260 │
│ danwinship │ 208 │
└─────────────────┴───────┘
Time (abs ≡): 488.127 s [User: 3660.271 s, System: 10.031 s]
About to execute
================
super -s -I /mnt/tmpdir/tmp.JzRx6IABuv
With query
==========
FROM '/mnt/gha.bsup'
| UNNEST [...payload.pull_request.assignees, payload.pull_request.assignee]
| WHERE this IS NOT NULL
| AGGREGATE count() BY assignee:=login
| ORDER BY count DESC
| LIMIT 5
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'super -s -I /mnt/tmpdir/tmp.JzRx6IABuv'
Benchmark 1: super -s -I /mnt/tmpdir/tmp.JzRx6IABuv
{assignee:"poad",count:1966::uint64}
{assignee:"vinayakkulkarni",count:508::uint64}
{assignee:"tmtmtmtm",count:356::uint64}
{assignee:"AMatutat",count:260::uint64}
{assignee:"danwinship",count:208::uint64}
Time (abs ≡): 8.245 s [User: 17.489 s, System: 1.938 s]
About to execute
================
SUPER_VAM=1 super -s -I /mnt/tmpdir/tmp.djiUKncZ0T
With query
==========
FROM '/mnt/gha.parquet'
| UNNEST [...payload.pull_request.assignees, payload.pull_request.assignee]
| WHERE this IS NOT NULL
| AGGREGATE count() BY assignee:=login
| ORDER BY count DESC
| LIMIT 5
+ hyperfine --show-output --warmup 1 --runs 1 --time-unit second 'SUPER_VAM=1 super -s -I /mnt/tmpdir/tmp.djiUKncZ0T'
Benchmark 1: SUPER_VAM=1 super -s -I /mnt/tmpdir/tmp.djiUKncZ0T
{assignee:"poad",count:1966::uint64}
{assignee:"vinayakkulkarni",count:508::uint64}
{assignee:"tmtmtmtm",count:356::uint64}
{assignee:"AMatutat",count:260::uint64}
{assignee:"danwinship",count:208::uint64}
Time (abs ≡): 40.014 s [User: 291.269 s, System: 17.516 s]
For jq Users
For jq Users
TODO: update more for malibu. position as a way to do jq-like stuff with the performance and strong typing of an analytics database.
SuperSQL’s pipes and shortcuts provide a flexible and powerful way for SQL
users to query their JSON data while leveraging their existing skills.
However, users who’ve traditionally wrangled their JSON with other tools such
as jq will find super equally powerful
even if they don’t know SQL or just prefer to work primarily in shortcuts. This tour
walks through a number of examples using super at
the command line with jq as a
reference point.
We’ll start with some simple one-liners where we feed some data to super
with echo and specify - as input to indicate that standard input
should be used, e.g.,
echo '"hello, world"' | super -
Then, toward the end of the tour, we’ll experiment with some real-world GitHub data pulled from the GitHub API.
Of course, if SQL is your preference, you can write many of the examples shown using the SQL equivalent, e.g.,
super -c "SELECT VALUE 'hello, world'"
or a mix of SQL and pipeline extensions as suits your preferences. However,
to make this tutorial relevant to jq users, we’ll lean heavily on SuperSQL’s
use of pipes and shortcuts.
If you want to follow along on the command line,
just make sure the super command is installed
as well as jq.
But JSON
While super is based on a new type of data model,
its human-readable format Super (SUP) just so
happens to be a superset of JSON.
So if all you ever use super for is manipulating JSON data,
it can serve you well as a handy, go-to tool. In this way, super is kind of
like jq. As you probably know, jq
is a popular command-line tool for taking a sequence of JSON values as input,
doing interesting things on that input, and emitting results, of course, as JSON.
jq is awesome and powerful, but its syntax and computational model can
sometimes be daunting and difficult. We tried to make super really easy and intuitive,
and it is usually faster, sometimes
much faster than jq.
To this end, if you want full JSON compatibility without having to delve into the
details of SUP, just use the -j option with super and this will tell it to
expect JSON values as input and produce JSON values as output, much like jq.
Note
If your downstream JSON tooling expects only a single JSON value, we can use
-jalong with collect to aggregate multiple input values into an array.
this vs .
For example, to add 1 to some numbers with jq, you say:
echo '1 2 3' | jq '.+1'
and you get
2
3
4
With super, the mysterious jq value . is instead called
the almost-as-mysterious value
this
and you say:
echo '1 2 3' | super -s -c 'this+1' -
which also gives
2
3
4
Note
We are using the
-soption withsuperin all of the examples, which formats the output as SUP.
Search vs Transformation
Generally, jq leads with transformation. By comparison, super leads with search, but
transformation is also pretty easy. Let’s show what we mean here with an
example.
Let’s start from a minimal example. If we run this jq command,
echo '1 2 3' | jq 2
we get
2
2
2
Hmm, that’s a little odd, but it did what we told it to do. In jq, the
expression 2 is evaluated for each input value, and the value 2
is produced each time, so three copies of 2 are emitted.
In super, a lonely 2 all by itself is not a valid query, but adding a
leading ? (shorthand for the search operator)
echo '1 2 3' | super -s -c '? 2' -
produces this “search result”:
2
In fact, this search syntax generalizes, and if we search over a more complex input:
echo '1 2 [1,2,3] [4,5,6] {r:{x:1,y:2}} {r:{x:3,y:4}} "hello" "Number 2"' |
super -s -c '? 2' -
we naturally find all the places 2 appears whether as a value, inside a value, or inside a string:
2
[1,2,3]
{r:{x:1,y:2}}
"Number 2"
You can also do keyword-text search, e.g.,
echo '1 2 [1,2,3] [4,5,6] {r:{x:1,y:2}} {r:{x:3,y:4}} "hello" "Number 2"' |
super -s -c '? hello or Number' -
produces
"hello"
"Number 2"
Doing searches like this in jq would be hard.
That said, we can emulate the jq transformation stance by explicitly
indicating that we want to values
the result of the expression evaluated for each input value, e.g.,
echo '1 2 3' | super -s -c 'values 2' -
now gives the same answer as jq:
2
2
2
Cool, but doesn’t it seem like search is a better disposition for shorthand syntax? What do you think?
On to SUP
JSON is super easy and ubiquitous, but it can be limiting and frustrating when trying to do high-precision stuff with data.
When using super, it’s handy to operate in the
domain of super-structured data and only output to
JSON when needed. Providing human-readability without losing detail is what
SUP is all about.
SUP is nice because it has a comprehensive type system and you can go from SUP to an efficient binary row format (Super Binary, BSUP) and columnar (Super Columnar, CSUP) — and vice versa — with complete fidelity and no loss of information. In this tour, we’ll stick to SUP, though for large data sets BSUP is much faster.
The first thing you’ll notice about SUP is that you don’t need
quotations around field names. We can see this by taking some JSON
as input (the JSON format is auto-detected by super) and formatting
it as pretty-printed SUP with -S:
echo '{"s":"hello","val":1,"a":[1,2],"b":true}' | super -S -
which gives
{
s: "hello",
val: 1,
a: [
1,
2
],
b: true
}
s, val, a, and b all appear as unquoted identifiers here.
Of course if you have funny characters in a field name, SUP can handle
it with quotes just like JSON:
echo '{"funny@name":1}' | super -s -
produces
{"funny@name":1}
Moreover, SUP is fully compatible with all of JSON’s corner cases like empty string as a field name and empty object as a value, e.g.,
echo '{"":{}}' | super -s -
produces
{"":{}}
Comprehensive Types
SUP also has a comprehensive type system.
For example, here is a SUP “record” with a taste of different types of values as record fields:
{
v1: 1.5,
v2: 1,
v3: 1 (uint8),
v4: 2018-03-24T17:30:20.600852Z,
v5: 2m30s,
v6: 192.168.1.1,
v7: 192.168.1.0/24,
v8: [
1,
2,
3
],
v9: |[
"GET",
"PUT",
"POST"
]|,
v10: |{
"key1": 123,
"key2": 456
}|,
v11: {
a: 1,
r: {
s1: "hello",
s2: "world"
}
}
}
The first seven values are all primitive types in the super data model.
Here, v1 is a 64-bit IEEE floating-point value just like JSON.
Unlike JSON, v2 is a 64-bit integer. And there are other integer
types as with v3,
which utilizes a SUP type decorator,
in this case,
to clarify its specific type of integer as unsigned 8 bits.
v4 has type time and v5 type duration.
v6 is type ip and v7 type net.
v8 is an array of elements of type int64,
which is a type written as [int64].
v9 is a “set of strings”, which is written like an array but with the
enclosing syntax |[ and ]|.
v10 is a “map” type, which in other languages is often called a “table”
or a “dictionary”. In the super data model, a value of any type can be used for key or
value in a map though all of the keys and all of the values must have the same type.
Finally, v11 is a “record”, which is similar to a JSON “object”, but the
keys are called “fields” and the order of the fields is significant and
is always preserved.
Records
As is often the case with semi-structured systems, you deal with nested values all the time: in JSON, data is nested with objects and arrays, while in super-structured data, data is nested with “records” and arrays (as well as other complex types).
Record expressions
are rather flexible with super and look a bit like JavaScript
or jq syntax, e.g.,
echo '1 2 3' | super -s -c 'values {kind:"counter",val:this}' -
produces
{kind:"counter",val:1}
{kind:"counter",val:2}
{kind:"counter",val:3}
Note that like the search shortcut, you can also drop the values keyword
here because the record literal implies
the values operator, e.g.,
echo '1 2 3' | super -s -c '{kind:"counter",val:this}' -
also produces
{kind:"counter",val:1}
{kind:"counter",val:2}
{kind:"counter",val:3}
super can also use a spread operator like JavaScript, e.g.,
echo '{a:{s:"foo", val:1}}{b:{s:"bar"}}' | super -s -c '{...a,s:"baz"}' -
produces
{s:"baz",val:1}
{s:"baz"}
while
echo '{a:{s:"foo", val:1}}{b:{s:"bar"}}' | super -s -c '{d:2,...a,...b}' -
produces
{d:2,s:"foo",val:1}
{d:2,s:"bar"}
Record Mutation
Sometimes you just want to extract or mutate certain fields of records.
Similar to the Unix cut command, the cut operator
extracts fields, e.g.,
echo '{s:"foo", val:1}{s:"bar"}' | super -s -c 'cut s' -
produces
{s:"foo"}
{s:"bar"}
while the put operator mutates existing fields
or adds new fields, e.g.,
echo '{s:"foo", val:1}{s:"bar"}' | super -s -c 'put val:=123,pi:=3.14' -
produces
{s:"foo",val:123,pi:3.14}
{s:"bar",val:123,pi:3.14}
Note that put is also an implied operator so the command with put omitted
echo '{s:"foo", val:1}{s:"bar"}' | super -s -c 'val:=123,pi:=3.14' -
produces the very same output:
{s:"foo",val:123,pi:3.14}
{s:"bar",val:123,pi:3.14}
Finally, it’s worth mentioning that errors in the super data model are
first class.
This means they can just show up in the data as values. In particular,
a common error is error("missing") which occurs most often when referencing
a field that does not exist, e.g.,
echo '{s:"foo", val:1}{s:"bar"}' | super -s -c 'cut val' -
produces
{val:1}
{val:error("missing")}
Sometimes you expect “missing” errors to occur sporadically and just want to ignore them, which can you easily do with the quiet function, e.g.,
echo '{s:"foo", val:1}{s:"bar"}' | super -s -c 'cut quiet(val)' -
produces
{val:1}
Union Types
One of the tricks super uses to represent JSON data in its structured type system
is union types.
Most of the time, you don’t need to worry about unions
but they show up from time to time. Even when
they show up, super just tries to “do the right thing” so you usually
don’t have to worry about them even when they show up.
For example, this query is perfectly happy to operate on the union values that are implied by a mixed-type array:
echo '[1, "foo", 2, "bar"]' | super -s -c 'values this[3],this[2]' -
produces
"bar"
2
but under the covers, the elements of the array have a union type of
int64 and string, which is written int64|string, e.g.,
echo '[1, "foo", 2, "bar"]' | super -s -c 'values typeof(this)' -
produces
<[int64|string]>
which is a type value representing an array of union values.
As you learn more about super-structured data and want to use super to do data discovery and
preparation, union types are really quite powerful. They allow records
with fields of different types or mixed-type arrays to be easily expressed
while also having a very precise type definition. This is the essence
of the new
super-structured data model.
First-class Types
Note that in the type value above, the type is wrapped in angle brackets. This is how SUP represents types when expressed as values. In other words, the super data model has first-class types.
The type of any value in super can be accessed via the
typeof function, e.g.,
echo '1 "foo" 10.0.0.1' | super -s -c 'values typeof(this)' -
produces
<int64>
<string>
<ip>
What’s the big deal here? We can print out the type of something. Yawn.
Au contraire, this is really quite powerful because we can use types as values to functions, e.g., as a dynamic argument to the cast function:
echo '{a:0,b:"2"}{a:0,b:"3"}' | super -s -c 'values cast(b, typeof(a))' -
produces
2
3
But more powerfully, types can be used anywhere a value can be used and in particular, they can be grouping keys, e.g.,
echo '{x:1,y:2}{s:"foo"}{x:3,y:4}' |
super -f table -c "count() by this['shape']:=typeof(this) | sort count" -
produces
shape count
<{s:string}> 1
<{x:int64,y:int64}> 2
When run over large data sets, this gives you an insightful count of each “shape” of data in the input. This is a powerful building block for data discovery.
It’s worth mentioning jq also has a type operator, but it produces a
simple string instead of first-class types, and arrays and objects have
no detail about their structure, e.g.,
echo '1 true [1,2,3] {"s":"foo"}' | jq type
produces
"number"
"boolean"
"array"
"object"
Moreover, if we compare types of different objects
echo '{"a":{"s":"foo"},"b":{"x":1,"y":2}}' | jq '(.a|type)==(.b|type)'
we get “object” here for each type and thus the result:
true
i.e., they match even though their underlying shape is different.
With super of course, these are different super-structured types so
the result is false, e.g.,
echo '{"a":{"s":"foo"},"b":{"x":1,"y":2}}' |
super -s -c 'values typeof(a)==typeof(b)' -
produces
false
Shapes
Sometimes you’d like to see a sample value of each shape, not its type. This is easy to do with the any aggregate function, e.g.,
echo '{x:1,y:2}{s:"foo"}{x:3,y:4}' |
super -s -c 'val:=any(this) by typeof(this) | sort val | values val' -
produces
{s:"foo"}
{x:1,y:2}
We like this pattern so much there is a shortcut shapes operator, e.g.,
echo '{x:1,y:2}{s:"foo"}{x:3,y:4}' | super -s -c 'shapes this | sort this' -
emits the same result:
{s:"foo"}
{x:1,y:2}
Fuse
Sometimes JSON data can get really messy with lots of variations in fields, with null values appearing sometimes and sometimes not, and with the same fields having different data types. Most annoyingly, when you see a JSON object like this in isolation:
{a:1,b:null}
you have no idea what the expected data type of b will be. Maybe it’s another
number? Or maybe a string? Or maybe an array or an embedded object?
super and SUP don’t have this problem because every value (even null) is
comprehensively typed. However, super in fact must deal with this thorny problem
when reading JSON and converting it to super-structured data.
This is where you might have to spend a little bit of time coding up the right query logic to disentangle a JSON mess. But once the data is cleaned up, you can leave it in a super-structured format and not worry again.
To do so, the fuse operator comes in handy.
Let’s say you have this sequence of data:
{a:1,b:null}
{a:null,b:[2,3,4]}
As we said,
you can’t tell by looking at either value what the types of both a and b
should be. But if you merge the values into a common type, things begin to make
sense, e.g.,
echo '{a:1,b:null}{a:null,b:[2,3,4]}' | super -s -c fuse -
produces this transformed and comprehensively-typed SUP output:
{a:1,b:null::[int64]}
{a:null::int64,b:[2,3,4]}
Now you can see all the detail.
This turns out to be so useful, especially with large amounts of messy input data, you will often find yourself fusing data then sampling it, e.g.,
echo '{a:1,b:null}{a:null,b:[2,3,4]}' | super -S -c 'fuse | shapes' -
produces a comprehensively-typed shape:
{
a: 1,
b: null::[int64]
}
As you explore data in this fashion, you will often type various searches
to slice and dice the data as you get a feel for it all while sending
your interactive search results to fuse | shapes.
To appreciate all this, let’s have a look next at some real-world data…
Real-world GitHub Data
Now that we’ve covered the basics of super and its query language, let’s
use the query patterns from above to explore some GitHub data.
First, we need to grab the data. You can use curl for this or you can
just use super as it can take URLs in addition to file name arguments.
This command will grab descriptions of first 30 PRs created in the
public super repository and place it in a file called prs.json:
super -f json \
https://api.github.com/repos/brimdata/super/pulls\?state\=all\&sort\=desc\&per_page=30 \
> prs.json
Note
As we get into the exercise below, we’ll reach a step where we encounter some unexpected empty objects in the original data. It seems the GitHub API must have been having a bad day when we first ran this exercise, as these empty records no longer appear if the download is repeated today using the same URL shown above. But taming glitchy data is a big part of data discovery, so to relive the magic of our original experience, you can download this archived copy of the
prs.jsonwe originally saw.
Now that you have this JSON file on your local file system, how would you query it
with super?
Data Discovery
Before you can do anything, you need to know its structure but you generally don’t know anything after pulling some random data from an API.
So, let’s poke around a bit and figure it out. This process of data introspection is often called data discovery.
You could start by using jq to pretty-print the JSON data,
jq . prs.json
That’s 10,592 lines. Ugh, quite a challenge to sift through.
Instead, let’s start out by figuring out how many values are in the input, e.g.,
super -f text -c 'count()' prs.json
produces
1
Hmm, there’s just one value. It’s probably a big JSON array but let’s check with the kind function, and as expected:
super -s -c 'kind(this)' prs.json
produces
"array"
Ok got it. But, how many items are in the array?
super -s -c 'len(this)' prs.json
produces
30
Of course! We asked GitHub to return 30 items and the API returns the pull-request objects as elements of one array representing a single JSON value.
Let’s see what sorts of things are in this array. Here, we need to enumerate the items from the array and do something with them. So how about we use the unnest to traverse the array and count the array items by their “kind”,
super -s -c 'unnest this | count() by kind(this)' prs.json
produces
{kind:"record",count:30::uint64}
Ok, they’re all records. Good, this should be easy!
The records were all originally JSON objects. Maybe we can just use “shapes” to have a deeper look…
super -S -c 'unnest this | shapes' prs.json
Note
Here we are using
-S, which is like-s, but instead of formatting each SUP value on its own line, it pretty-prints with vertical formatting likejqdoes for JSON.
Ugh, that output is still pretty big. It’s not 10k lines but it’s still more than 700 lines of pretty-printed SUP.
Ok, maybe it’s not so bad. Let’s check how many shapes there are with shapes…
super -s -c 'unnest this | shapes | count()' prs.json
produces
3::uint64
All that data across the samples and only three shapes. They must each be really big. Let’s check that out.
We can use the len function on the records to see the size of each of the four records:
super -s -c 'unnest this | shapes | len(this) | sort this' prs.json
and we get
0
36
36
Ok, this isn’t so bad… two shapes each have 36 fields but one is length zero?! That outlier could only be the empty record. Let’s check:
super -s -c 'unnest this | shapes | len(this)==0' prs.json
produces
{}
Sure enough, there it is. We could also double check with jq that there are
blank records in the GitHub results, and sure enough
jq '.[] | select(length==0)' prs.json
produces
{}
{}
Try opening your editor on that JSON file to look for the empty objects. Who knows why they are there? No fun. Real-world data is messy.
How about we fuse the 3 shapes together and have a look at the result:
super -S -c 'unnest this | fuse | shapes' prs.json
We won’t display the result here as it’s still pretty big. But you can give it a try. It’s 379 lines.
But let’s break down what’s taking up all this space.
We can take the output from fuse | shapes and list the fields with
and their “kind”. Note that when we do an unnest this with records as
input, we get a new record value for each field structured as a key/value pair:
super -f table -c '
unnest this
| fuse
| shapes
| unnest flatten(this)
| {field:key[1],kind:kind(value)}
' prs.json
produces
field kind
url primitive
id primitive
node_id primitive
html_url primitive
diff_url primitive
patch_url primitive
issue_url primitive
number primitive
state primitive
locked primitive
title primitive
user record
body primitive
created_at primitive
updated_at primitive
closed_at primitive
merged_at primitive
merge_commit_sha primitive
assignee primitive
assignees array
requested_reviewers array
requested_teams array
labels array
milestone primitive
draft primitive
commits_url primitive
review_comments_url primitive
review_comment_url primitive
comments_url primitive
statuses_url primitive
head record
base record
_links record
author_association primitive
auto_merge primitive
active_lock_reason primitive
With this list of top-level fields, we can easily explore the different
pieces of their structure with shapes. Let’s have a look at a few of the
record fields by giving these one-liners each a try and looking at the output:
super -S -c 'unnest this | shapes head' prs.json
super -S -c 'unnest this | shapes base' prs.json
super -S -c 'unnest this | shapes _links' prs.json
While these fields have some useful information, we’ll decide to drop them here and focus on other top-level fields. To do this, we can use the drop operator to whittle down the data:
super -S -c 'unnest this | fuse | drop head,base,_link | shapes' prs.json
Ok, this looks more reasonable and is now only 120 lines of pretty-printed SUP.
One more annoying detail here about JSON: time values are stored as strings, in this case, in ISO format, e.g., we can pull this value out with this query:
super -s -c 'unnest this | head 1 | values created_at' prs.json
which produces this string:
"2019-11-11T19:50:46Z"
Since the super data model has a native time type and we might want to do native date comparisons
on these time fields, we can easily translate the string to a time with a cast, e.g.,
super -s -c 'unnest this | head 1 | values created_at::time' prs.json
produces the native time value:
2019-11-11T19:50:46Z
To be sure, you can check any value’s type with the typeof function, e.g.,
super -s -c 'unnest this | head 1 | values created_at::time | typeof(this)' prs.json
produces the native time value:
<time>
Cleaning up the Messy JSON
Okay, now that we’ve explored the data, we have a sense of it and can “clean it up” with some transformative queries. We’ll do this one step at a time, then put it all together.
First, let’s get rid of the outer array and generate elements of an array as a sequence of records that have been fused and let’s filter out the empty records:
super -c 'unnest this | len(this) != 0 | fuse' prs.json > prs1.bsup
We can check that worked with count:
super -s -c 'count()' prs1.bsup
super -s -c 'sample | count()' prs1.bsup
produces
{count:28::uint64}
{count:1::uint64}
Okay, good. There are 28 values (the 30 requested less the two empty records) and exactly one shape since the data was fused.
Now, let’s drop the fields we aren’t interested in:
super -c 'drop head,base,_links' prs1.bsup > prs2.bsup
Finally, let’s clean up those dates. To track down all the candidates, we can run this query to group field names by their type and limit the output to primitive types:
super -s -c '
unnest flatten(this)
| kind(value)=="primitive"
| fields:=union(key[0]) by type:=typeof(value)
' prs2.bsup
which gives
{type:<string>,fields:|["url","body","state","title","node_id","diff_url","html_url","closed_at","issue_url","merged_at","patch_url","created_at","updated_at","commits_url","comments_url","statuses_url","merge_commit_sha","author_association","review_comment_url","review_comments_url"]|}
{type:<int64>,fields:|["id","number"]|}
{type:<bool>,fields:|["draft","locked"]|}
{type:<null>,fields:|["assignee","milestone","auto_merge","active_lock_reason"]|}
Note
This use of
unnestwithflattentraverses each record and generates a key-value pair for each field in each record.
Looking through the fields that are strings, the candidates for ISO dates appear to be
closed_at,merged_at,created_at, andupdated_at.
You can do a quick check of the theory by running…
super -s -c '{closed_at,merged_at,created_at,updated_at}' prs2.bsup
and you will get strings that are all ISO dates:
{closed_at:"2019-11-11T20:00:22Z",merged_at:"2019-11-11T20:00:22Z",created_at:"2019-11-11T19:50:46Z",updated_at:"2019-11-11T20:00:25Z"}
{closed_at:"2019-11-11T21:00:15Z",merged_at:"2019-11-11T21:00:15Z",created_at:"2019-11-11T20:57:12Z",updated_at:"2019-11-11T21:00:26Z"}
...
To fix those strings, we simply transform the fields in place using the
(implied) put operator and redirect the final
output as BSUP to the file prs.bsup:
super -c '
closed_at:=closed_at::time,
merged_at:=merged_at::time,
created_at:=created_at::time,
updated_at:=updated_at::time
' prs2.bsup > prs.bsup
We can check the result with our type analysis:
super -s -c '
over this
| kind(value)=="primitive"
| fields:=union(key[1]) by type:=typeof(value)
| sort type
' prs.bsup
which now gives:
{type:<int64>,fields:|["id","number"]|}
{type:<time>,fields:|["closed_at","merged_at","created_at","updated_at"]|}
{type:<bool>,fields:|["draft","locked"]|}
{type:<string>,fields:|["url","body","state","title","node_id","diff_url","html_url","issue_url","patch_url","commits_url","comments_url","statuses_url","merge_commit_sha","author_association","review_comment_url","review_comments_url"]|}
{type:<null>,fields:|["assignee","milestone","auto_merge","active_lock_reason"]|}
and we can see that the date fields are correctly typed as type time!
Note
We sorted the output values here using the sort operator to produce a consistent output order since aggregations can be run in parallel to achieve scale and do not guarantee their output order.
Putting It All Together
Instead of running each step above into a temporary file, we can put all the transformations together in a single pipeline, where the full query text might look like this:
unnest this -- traverse the array of objects
| len(this) != 0 -- skip empty objects
| fuse -- fuse objects into records of a combined type
| drop head,base,_links -- drop fields that we don't need
| closed_at:=closed_at::time, -- transform string dates to type time
merged_at:=merged_at::time,
created_at:=created_at::time,
updated_at:=updated_at::time
Note
The
--syntax indicates a single-line comment.
We can then put this in a file, called say transform.spq, and use the -I
argument to run all the transformations in one fell swoop:
super -I transform.spq prs.json > prs.bsup
Running Analytics
Now that we’ve cleaned up our data, we can reliably and easily run analytics
on the finalized BSUP file prs.bsup.
Super-structured data gives us the best of both worlds of JSON and relational tables: we have the structure and clarity of the relational model while retaining the flexibility of JSON’s document model. No need to create tables then issue SQL insert commands to put your clean data into all the right places.
Let’s start with something simple. How about we output a “PR Report” listing the title of each PR along with its PR number and creation date:
super -f table -c '{DATE:created_at,NUMBER:f"PR #{number}",TITLE:title}' prs.bsup
and you’ll see this output…
DATE NUMBER TITLE
2019-11-11T19:50:46Z PR #1 Make "make" work in zq
2019-11-11T20:57:12Z PR #2 fix install target
2019-11-11T23:24:00Z PR #3 import github.com/looky-cloud/lookytalk
2019-11-12T16:25:46Z PR #5 Make zq -f work
2019-11-12T16:49:07Z PR #6 a few clarifications to the zson spec
...
Note that we used a formatted string literal
to convert the field number into a string and format it with surrounding text.
Instead of old PRs, we can get the latest list of PRs using the
tail operator since we know the data is sorted
chronologically. This command retrieves the last five PRs in the dataset:
super -f table -c '
tail 5
| {DATE:created_at,"NUMBER":f"PR #{number}",TITLE:title}
' prs.bsup
and the output is:
DATE NUMBER TITLE
2019-11-18T22:14:08Z PR #26 ndjson writer
2019-11-18T22:43:07Z PR #27 Add reader for ndjson input
2019-11-19T00:11:46Z PR #28 fix TS_ISO8601, TS_MILLIS handling in NewRawAndTsFromJSON
2019-11-19T21:14:46Z PR #29 Return count of "dropped" fields from zson.NewRawAndTsFromJSON
2019-11-20T00:36:30Z PR #30 zval.sizeBytes incorrect
How about some aggregations? We can count the number of PRs and sort by the count highest first:
super -s -c "count() by user:=user.login | sort count desc" prs.bsup
produces
{user:"mattnibs",count:10::uint64}
{user:"aswan",count:7::uint64}
{user:"mccanne",count:6::uint64}
{user:"nwt",count:4::uint64}
{user:"henridf",count:1::uint64}
How about getting a list of all of the reviewers? To do this, we need to
traverse the records in the requested_reviewers array and collect up
the login field from each record:
super -s -c 'unnest requested_reviewers | collect(login)' prs.bsup
Oops, this gives us an array of the reviewer logins
with repetitions since collect
collects each item that it encounters into an array:
["mccanne","nwt","henridf","mccanne","nwt","mccanne","mattnibs","henridf","mccanne","mattnibs","henridf","mccanne","mattnibs","henridf","mccanne","nwt","aswan","henridf","mccanne","nwt","aswan","philrz","mccanne","mccanne","aswan","henridf","aswan","mccanne","nwt","aswan","mikesbrown","henridf","aswan","mattnibs","henridf","mccanne","aswan","nwt","henridf","mattnibs","aswan","aswan","mattnibs","aswan","henridf","aswan","henridf","mccanne","aswan","aswan","mccanne","nwt","aswan","henridf","aswan"]
What we’d prefer is a set of reviewers where each reviewer appears only once. This
is easily done with the union aggregate function
(not to be confused with union types) which
computes the set-wise union of its input and produces a set type as its
output. In this case, the output is a set of strings, written |[string]|
in the query language. For example:
super -s -c 'unnest requested_reviewers | reviewers:=union(login)' prs.bsup
produces
{reviewers:|["nwt","aswan","philrz","henridf","mccanne","mattnibs","mikesbrown"]|}
Ok, that’s pretty neat.
Let’s close with an analysis that’s a bit more sophisticated. Suppose we want to look at the reviewers that each user tends to ask for. We can think about this question as a “graph problem” where the user requesting reviews is one node in the graph and each set of reviewers is another node.
So as a first step, let’s figure out how to create each edge, where an edge is a relation between the requesting user and the set of reviewers. We can create this with a [“lateral subquery”] TODO: FIX. Instead of computing a set-union over all the reviewers across all PRs, we instead want to compute the set-union over the reviewers in each PR. We can do this as follows:
super -s -c 'unnest requested_reviewers into ( reviewers:=union(login) )' prs.bsup
which produces an output like this:
{reviewers:|["nwt","mccanne"]|}
{reviewers:|["nwt","henridf","mccanne"]|}
{reviewers:|["mccanne","mattnibs"]|}
{reviewers:|["henridf","mccanne","mattnibs"]|}
{reviewers:|["henridf","mccanne","mattnibs"]|}
...
Note that the syntax into ( ... ) defines a lateral scope TODO: FIX/LINK
where any subquery can
run in isolation over the input values created from the sequence of values
traversed by the outer over.
But we need a “graph edge” between the requesting user and the reviewers.
To do this, we need to reference the user.login from the top-level scope within the
lateral scope. This can be done by
bringing that value into the scope using a with clause appended to the
over expression and returning a
record literal
with the desired value:
super -s -c '
unnest {user:user.login,reviewer:requested_reviewers} into (
reviewers:=union(reviewer.login) by user
)
| sort user,len(reviewers)
' prs.bsup
which gives us
{user:"aswan",reviewers:|["mccanne"]|}
{user:"aswan",reviewers:|["nwt","mccanne"]|}
{user:"aswan",reviewers:|["nwt","henridf","mccanne"]|}
{user:"aswan",reviewers:|["henridf","mccanne","mattnibs"]|}
{user:"aswan",reviewers:|["henridf","mccanne","mattnibs"]|}
{user:"henridf",reviewers:|["nwt","aswan","mccanne"]|}
{user:"mattnibs",reviewers:|["aswan","mccanne"]|}
{user:"mattnibs",reviewers:|["aswan","henridf"]|}
...
The final step is to simply aggregate the “reviewer sets” with the user field
as the grouping key:
super -S -c '
unnest {user:user.login,reviewer:requested_reviewers} into (
reviewers:=union(reviewer.login) by user
)
| groups:=union(reviewers) by user
| sort user,len(groups)
' prs.bsup
and we get
{
user: "aswan",
groups: |[
|[
"mccanne"
]|,
|[
"nwt",
"mccanne"
]|,
|[
"nwt",
"henridf",
"mccanne"
]|,
|[
"henridf",
"mccanne",
"mattnibs"
]|
]|
}
{
user: "henridf",
groups: |[
|[
"nwt",
"aswan",
"mccanne"
]|
]|
}
{
user: "mattnibs",
groups: |[
|[
"aswan",
"henridf"
]|,
|[
"aswan",
"mccanne"
]|,
|[
"aswan",
"henridf",
"mccanne"
]|,
|[
"nwt",
"aswan",
"henridf",
"mccanne"
]|,
|[
"nwt",
"aswan",
"mccanne",
"mikesbrown"
]|,
|[
"nwt",
"aswan",
"philrz",
"henridf",
"mccanne"
]|
]|
}
{
user: "mccanne",
groups: |[
|[
"nwt"
]|,
|[
"aswan"
]|,
|[
"mattnibs"
]|
]|
}
{
user: "nwt",
groups: |[
|[
"aswan"
]|,
|[
"aswan",
"mattnibs"
]|,
|[
"henridf",
"mattnibs"
]|,
|[
"mccanne",
"mattnibs"
]|
]|
}
After a quick glance here, you can tell that mccanne looks for
very targeted reviews while mattnibs casts a wide net, at least
for the PRs from the beginning of the repo.
To quantify this concept, we can easily modify this query to compute the average number of reviewers requested instead of the set of groups of reviewers. To do this, we just average the reviewer set size with an aggregation:
super -s -c '
unnest {user:user.login,reviewer:requested_reviewers} into (
reviewers:=union(reviewer.login) by user
)
| avg_reviewers:=avg(len(reviewers)) by user
| sort avg_reviewers
' prs.bsup
which produces
{user:"mccanne",avg_reviewers:1.}
{user:"nwt",avg_reviewers:1.75}
{user:"aswan",avg_reviewers:2.4}
{user:"mattnibs",avg_reviewers:2.9}
{user:"henridf",avg_reviewers:3.}
Of course, if you’d like the query output in JSON, you can just say -j and
super will happily format the sets as JSON arrays, e.g.,
super -j -c '
unnest {user:user.login,reviewer:requested_reviewers} into (
reviewers:=union(reviewer.login) by user
)
| groups:=union(reviewers) by user
| sort user,len(groups)
' prs.bsup
produces
{"user":"aswan","groups":[["mccanne"],["nwt","mccanne"],["nwt","henridf","mccanne"],["henridf","mccanne","mattnibs"]]}
{"user":"henridf","groups":[["nwt","aswan","mccanne"]]}
{"user":"mattnibs","groups":[["aswan","henridf"],["aswan","mccanne"],["aswan","henridf","mccanne"],["nwt","aswan","henridf","mccanne"],["nwt","aswan","mccanne","mikesbrown"],["nwt","aswan","philrz","henridf","mccanne"]]}
{"user":"mccanne","groups":[["nwt"],["aswan"],["mattnibs"]]}
{"user":"nwt","groups":[["aswan"],["aswan","mattnibs"],["henridf","mattnibs"],["mccanne","mattnibs"]]}
Key Takeaways
So to summarize, we gave you a tour here of how super the super data model
provide a powerful way do search, transformation, and analytics in a structured-like
way on data that begins its life as semi-structured JSON and is transformed
into the powerful super-structured format without having to create relational
tables and schemas.
As you can see, super is a general-purpose tool that you can add to your bag
of tricks to:
- explore messy and confusing JSON data using shaping and sampling,
- transform JSON data in ad hoc ways, and
- develop transform logic for hitting APIs like the GitHub API to produce
clean data for analysis by
superor even export into other systems or for testing.
If you’d like to learn more, feel free to read through the SuperSQL documentation in depth or see how you can organize data into a SuperDB database using a git-like commit model.
Formats
This section contains the data model definition for super-structured data along with a set of concrete formats that all implement this same data model, providing a unified approach to row, columnar, and human-readable formats:
- Super (SUP) is a human-readable format for super-structured data. All JSON documents are SUP values as the SUP format is a strict superset of the JSON syntax.
- Super Binary (BSUP) is a row-based, binary representation somewhat like Avro but leveraging the super data model to represent a sequence of arbitrarily-typed values.
- Super Columnar (CSUP) is columnar like Parquet, ORC, or Arrow but for super-structured data.
- Super JSON (JSUP) defines a format for encapsulating SUP inside plain JSON for easy decoding by JSON-based clients, e.g., the JavaScript library used by SuperDB Desktop and the SuperDB Python library.
Because all of the formats conform to the same super-structured data model, conversions between a human-readable form, a row-based binary form, and a row-based columnar form can be carried out with no loss of information. This provides the best of both worlds: the same data can be easily expressed in and converted between a human-friendly and easy-to-program text form alongside efficient row and columnar formats.
Data Model
Super-structured Data Model
Super-structured data is a collection of one or more typed data values. Each value’s type is either a “primitive type”, a “complex type”, the “type type”, a “named type”, or the “null type”.
1. Primitive Types
Primitive types include signed and unsigned integers, IEEE binary and decimal floating point, string, byte sequence, Boolean, IP address, IP network, null, and a first-class type type.
There are 30 types of primitive values defined as follows:
| Name | Definition |
|---|---|
uint8 | unsigned 8-bit integer |
uint16 | unsigned 16-bit integer |
uint32 | unsigned 32-bit integer |
uint64 | unsigned 64-bit integer |
uint128 | unsigned 128-bit integer |
uint256 | unsigned 256-bit integer |
int8 | signed 8-bit integer |
int16 | signed 16-bit integer |
int32 | signed 32-bit integer |
int64 | signed 64-bit integer |
int128 | signed 128-bit integer |
int256 | signed 256-bit integer |
duration | signed 64-bit integer as nanoseconds |
time | signed 64-bit integer as nanoseconds from epoch |
float16 | IEEE-754 binary16 |
float32 | IEEE-754 binary32 |
float64 | IEEE-754 binary64 |
float128 | IEEE-754 binary128 |
float256 | IEEE-754 binary256 |
decimal32 | IEEE-754 decimal32 |
decimal64 | IEEE-754 decimal64 |
decimal128 | IEEE-754 decimal128 |
decimal256 | IEEE-754 decimal256 |
bool | the Boolean value true or false |
bytes | a bounded sequence of 8-bit bytes |
string | a UTF-8 string |
ip | an IPv4 or IPv6 address |
net | an IPv4 or IPv6 address and net mask |
type | a type value |
null | the null type |
The type type provides for first-class types. Even though a type value can represent a complex type, the value itself is a singleton.
Two type values are equivalent if their underlying types are equal. Since every type in the type system is uniquely defined, type values are equal if and only if their corresponding types are uniquely equal.
The null type is a primitive type representing only a null value.
A null value can have any type.
2. Complex Types
Complex types are composed of primitive types and/or other complex types. The categories of complex types include:
- record - an ordered collection of zero or more named values called fields,
- array - an ordered sequence of zero or more values called elements,
- set - a set of zero or more unique values called elements,
- map - a collection of zero or more key/value pairs where the keys are of a uniform type called the key type and the values are of a uniform type called the value type,
- union - a type representing values whose type is any of a specified collection of two or more unique types,
- enum - a type representing a finite set of symbols typically representing categories, and
- error - any value wrapped as an “error”.
The type system comprises a total order:
- The order of primitive types corresponds to the order in the table above.
- All primitive types are ordered before any complex types.
- The order of complex type categories corresponds to the order above.
- For complex types of the same category, the order is defined below.
2.1 Record
A record comprises an ordered set of zero or more named values
called “fields”. The field names must be unique in a given record
and the order of the fields is significant, e.g., type {a:string,b:string}
is distinct from type {b:string,a:string}.
A field name is any UTF-8 string.
A field value is a value of any type.
In contrast to many schema-oriented data formats, the super data model has no way to specify a field as “optional” since any field value can be a null value.
If an instance of a record value omits a value by dropping the field altogether rather than using a null, then that record value corresponds to a different record type that elides the field in question.
A record type is uniquely defined by its ordered list of field-type pairs.
The type order of two records is as follows:
- Record with fewer columns than other is ordered before the other.
- Records with the same number of columns are ordered as follows according to:
- the lexicographic order of the field names from left to right,
- or if all the field names are the same, the type order of the field types from left to right.
2.2 Array
An array is an ordered sequence of zero or more values called “elements” all conforming to the same type.
An array value may be empty. An empty array may have element type null.
An array type is uniquely defined by its single element type.
The type order of two arrays is defined as the type order of the two array element types.
An array of mixed-type values (such a mixed-type JSON array) is representable
as an array with elements of type union.
2.3 Set
A set is an unordered sequence of zero or more values called “elements” all conforming to the same type.
A set may be empty. An empty set may have element type null.
A set of mixed-type values is representable as a set with
elements of type union.
A set type is uniquely defined by its single element type.
The type order of two sets is defined as the type order of the two set element types.
2.4 Map
A map represents a list of zero or more key-value pairs, where the keys have a common type and the values have a common type.
Each key across an instance of a map value must be a unique value.
A map value may be empty.
A map type is uniquely defined by its key type and value type.
The type order of two map types is as follows:
- the type order of their key types,
- or if they are the same, then the order of their value types.
2.5 Union
A union represents a value that may be any one of a specific enumeration of two or more unique data types that comprise its “union type”.
A union type is uniquely defined by an ordered set of unique types (which may be other union types) where the order corresponds to the type system’s total order.
Union values are tagged in that any instance of a union value explicitly conforms to exactly one of the union’s types. The union tag is an integer indicating the position of its type in the union type’s ordered list of types.
The type order of two union types is as follows:
- The union type with fewer types than other is ordered before the other.
- Two union types with the same number of types are ordered according to the type order of the constituent types in left to right order.
2.6 Enum
An enum represents a symbol from a finite set of one or more unique symbols referenced by name. An enum name may be any UTF-8 string.
An enum type is uniquely defined by its ordered set of unique symbols, where the order is significant, e.g., two enum types with the same set of symbols but in different order are distinct.
The type order of two enum types is as follows:
- The enum type with fewer symbols than other is ordered before the other.
- Two enum types with the same number of symbols are ordered according to the type order of the constituent types in left to right order.
The order among enum values correponds to the order of the symbols in the enum type. Order among enum values from different types is undefined.
2.7 Error
An error represents any value designated as an error.
The type order of an error is the type order of the type of its contained value.
3. Named Type
A named type is a name for a specific data type. Any value can have a named type and the named type is a distinct type from the underlying type. A named type can refer to another named type.
The binding between a named type and its underlying type is local in scope and need not be unique across a sequence of values.
A type name may be any UTF-8 string exclusive of primitive type names.
For example, if “port” is a named type for uint16, then two values of
type “port” have the same type but a value of type “port” and a value of type uint16
do not have the same type.
The type order of a named type is the type order of its underlying type with two exceptions:
- A named type is ordered after its underlying type.
- Named types sharing an underlying type are ordered lexicographically by name.
Super (SUP)
Super (SUP) Format
1. Introduction
Super (SUP) is the human-readable, text-based serialization format for super-structured data.
SUP builds upon the elegant simplicity of JSON with type decorators Where the type of a value is not implied by its syntax, a type decorator is appended to the value interposed with double colons to establish a concrete type for every value expressed in source text.
SUP is also a superset of JSON in that all JSON documents are valid SUP values.
2. The SUP Format
A SUP text is a sequence of UTF-8 characters organized either as a bounded input or an unbounded stream.
The input text is organized as a sequence of one or more values optionally
separated by and interspersed with whitespace.
Single-line (//) and multi-line (/* ... */) comments are
treated as whitespace and ignored.
All subsequent references to characters and strings in this section refer to the Unicode code points that result when the stream is decoded. If an input text includes data that is not valid UTF-8, then the text is invalid.
2.1 Names
SUP names encode record fields, enum symbols, and named types.
A name is either an identifier or a quoted string.
Names are referred to as <name> below.
An identifier is case-sensitive and can contain Unicode letters, $, _,
and digits [0-9], but may not start with a digit. An identifier cannot be
true, false, or null.
2.2 Type Decorators
A value may be explicitly typed by tagging it with a type decorator. The syntax for a decorator is a double-colon appended type:
<value>::<type>
For union values, multiple decorators might be required to distinguish the union-member type from the possible set of union types when there is ambiguity, as in
123.::float32::(int64|float32|float64)
In contrast, this union value is unambiguous:
123.::(int64|float64)
The syntax of a union value decorator is
<value>::<type>[::<type> ...]
where the rightmost type must be a union type if more than one decorator is present.
A decorator may also define a named type:
<value>::=<name>
which declares a new type with the indicated type name using the
implied type of the value. Type names may not be numeric, where a
numeric is a sequence of one or more characters in the set [0-9].
A decorator may also define a temporary numeric reference of the form:
<value>::=<numeric>
Once defined, this numeric reference may then be used anywhere a named type is used but a named type is not created.
It is an error for the decorator to be type incompatible with its referenced value.
Note that the = sigil here disambiguates between the case that a new
type is defined, which may override a previous definition of a different type with the
same name, from the case that an existing named type is merely decorating the value.
2.3 Primitive Values
The type names and format for primitive values is as follows:
| Type | Value Format |
|---|---|
uint8 | decimal string representation of any unsigned, 8-bit integer |
uint16 | decimal string representation of any unsigned, 16-bit integer |
uint32 | decimal string representation of any unsigned, 32-bit integer |
uint64 | decimal string representation of any unsigned, 64-bit integer |
uint128 | decimal string representation of any unsigned, 128-bit integer |
uint256 | decimal string representation of any unsigned, 256-bit integer |
int8 | decimal string representation of any signed, 8-bit integer |
int16 | decimal string representation of any signed, 16-bit integer |
int32 | decimal string representation of any signed, 32-bit integer |
int64 | decimal string representation of any signed, 64-bit integer |
int128 | decimal string representation of any signed, 128-bit integer |
int256 | decimal string representation of any signed, 256-bit integer |
duration | a duration string representing signed 64-bit nanoseconds |
time | an RFC 3339 UTC date/time string representing signed 64-bit nanoseconds from epoch |
float16 | a non-integer string representing an IEEE-754 binary16 value |
float32 | a non-integer string representing an IEEE-754 binary32 value |
float64 | a non-integer string representing an IEEE-754 binary64 value |
float128 | a non-integer string representing an IEEE-754 binary128 value |
float256 | a non-integer string representing an IEEE-754 binary256 value |
decimal32 | a non-integer string representing an IEEE-754 decimal32 value |
decimal64 | a non-integer string representing an IEEE-754 decimal64 value |
decimal128 | a non-integer string representing an IEEE-754 decimal128 value |
decimal256 | a non-integer string representing an IEEE-754 decimal256 value |
bool | the string true or false |
bytes | a sequence of bytes encoded as a hexadecimal string prefixed with 0x |
string | a double-quoted UTF-8 string |
ip | a string representing an IP address in IPv4 or IPv6 format |
net | a string in CIDR notation representing an IP address and prefix length as defined in RFC 4632 and RFC 4291. |
type | a string in canonical form as described in Section 2.5 |
null | the string null |
The format of a duration string is an optionally-signed concatenation of decimal numbers, each with optional fraction and a unit suffix, such as “300ms”, “-1.5h” or “2h45m”, representing a 64-bit nanosecond value. Valid time units are “ns” (nanosecond), “us” (microsecond), “ms” (millisecond), “s” (second), “m” (minute), “h” (hour), “d” (day), “w” (7 days), and “y” (365 days). Note that each of these time units accurately represents its calendar value, except for the “y” unit, which does not reflect leap years and so forth. Instead, “y” is defined as the number of nanoseconds in 365 days.
The format of floating point values is a non-integer string
conforming to any floating point representation that cannot be
interpreted as an integer, e.g., 1. or 1.0 instead of
1 or 1e3 instead of 1000. Unlike JSON, a floating point number can
also be one of:
+Inf, -Inf, or NaN.
A floating point value may be expressed with an integer string provided
a type decorator is applied, e.g., 123::float64.
Decimal values require type decorators.
Of the 30 primitive types, eleven of them represent implied-type values:
int64, time, duration, float64, bool, bytes, string, ip, net, type, and null.
Values for these types are determined by the format of the value and
thus do not need decorators to clarify the underlying type, e.g.,
123::int64
is the same as 123.
Values that do not have implied types must include a type decorator to clarify its type or appear in a context for which its type is defined (i.e., as a field value in a record, as an element in an array, etc.).
While a type value may represent a complex type, the value itself is a singleton
and thus always a primitive type. A type value is encoded as:
- a left angle bracket
<, followed by - a type as encoded below, followed by
- a right angle bracket
>.
A time value corresponds to 64-bit Unix epoch nanoseconds and thus
not all possible RFC 3339 date/time strings are valid. In addition,
nanosecond epoch times overflow on April 11, 2262.
2.3.1 Strings
Double-quoted string syntax is the same as that of JSON as described
in RFC 8259. Notably,
the following escape sequences are recognized:
| Sequence | Unicode Character |
|---|---|
\" | quotation mark U+0022 |
\\ | reverse solidus U+005C |
\/ | solidus U+002F |
\b | backspace U+0008 |
\f | form feed U+000C |
\n | line feed U+000A |
\r | carriage return U+000D |
\t | tab U+0009 |
\uXXXX | U+XXXX |
In \uXXXX sequences, each X is a hexadecimal digit, and letter
digits may be uppercase or lowercase.
The behavior of an implementation that encounters an unrecognized escape
sequence in a string type is undefined.
\u followed by anything that does not conform to the above syntax
is not a valid escape sequence. The behavior of an implementation
that encounters such invalid sequences in a string type is undefined.
These escaping rules apply also to quoted field names in record values and record types as well as enum symbols.
2.4 Complex Values
Complex values are built from primitive values and/or other complex values and conform to the super data model’s complex types: record, array, set, map, union, enum, and error.
Complex values have an implied type when their constituent values all have implied types.
2.4.1 Record Value
A record value has the form:
{ <name> : <value>, <name> : <value>, ... }
where <name> is a SUP name and <value> is
any optionally-decorated value inclusive of other records.
Each name/value pair is called a field.
There may be zero or more fields.
2.4.2 Array Value
An array value has the form:
[ <value>, <value>, ... ]
If the elements of the array are not of uniform type, then the implied type of the array elements is a union of the types present.
An array value may be empty. An empty array value without a type decorator is
presumed to be an empty array of type null.
2.4.3 Set Value
A set value has the form:
|[ <value>, <value>, ... ]|
where the indicated values must be distinct.
If the elements of the set are not of uniform type, then the implied type of the set elements is a union of the types present.
A set value may be empty. An empty set value without a type decorator is
presumed to be an empty set of type null.
2.4.4 Map Value
A map value has the form:
|{ <key> : <value>, <key> : <value>, ... }|
where zero or more comma-separated, key/value pairs are present.
Whitespace around keys and values is generally optional, but to avoid ambiguity, whitespace must separate an IPv6 key from the colon that follows it.
An empty map value without a type decorator is
presumed to be an empty map of type |{null: null}|.
2.4.5 Union Value
A union value is a value that conforms to one of the types within a union type. If the value appears in a context in which the type is unknown or ambiguous, then the value must be decorated as described above.
2.4.6 Enum Value
An enum type represents a symbol from a finite set of symbols referenced by name.
An enum value is formed from a string representing the symbol followed by an enum type decorator:
<string>::enum(<name>[,<name>...])
where each <name> is SUP name.
An enum value must appear in a context where the enum type is known, i.e., with an explicit enum type decorator or within a complex type where the contained enum type is defined by the complex type’s decorator.
A sequence of enum values might look like this:
"HEADS"::(flip=(enum(HEADS,TAILS)))
"TAILS"::flip
"HEADS"::flip
2.4.7 Error Value
An error value has the form:
error(<value>)
where <value> is any value.
2.5 Types
A primitive type is simply the name of the primitive type, i.e., string,
uint16, etc. Complex types are defined as follows.
2.5.1 Record Type
A record type has the form:
{ <name> : <type>, <name> : <type>, ... }
where <name> is a SUP name and
<type> is any type.
The order of the record fields is significant,
e.g., type {a:int32,b:int32} is distinct from type {b:int32,a:int32}.
2.5.2 Array Type
An array type has the form:
[ <type> ]
2.5.3 Set Type
A set type has the form:
|[ <type> ]|
2.5.4 Map Type
A map type has the form:
|{ <key-type>: <value-type> }|
where <key-type> is the type of the keys and <value-type> is the
type of the values.
2.5.5 Union Type
A union type has the form:
( <type>, <type>, ... )
where there are at least two types in the list.
2.5.6 Enum Type
An enum type has the form:
enum( <name>, <name>, ... )
where <name> is a SUP name.
Each enum name must be unique and the order is significant, e.g.,
enum type enum(HEADS,TAILS) is not equal to type enum(TAILS,HEADS).
2.5.7 Error Type
An error type has the form:
error( <type> )
where <type> is the type of the underlying values wrapped as an error.
2.5.8 Named Type
A named type has the form:
<name> = <type>
where a new type is defined with the given name and type.
When a named type appears in a complex value, the new type name may be referenced by any subsequent value in left-to-right depth-first order.
For example,
{p1:80::(port=uint16), p2: 8080::port}
is valid but
{p1:80::port, p2: 8080::(port=uint16)}
is invalid.
Named types may be redefined, in which case subsequent references resolve to the most recent definition according to
- sequence order across values, or
- left-to-right depth-first order within a complex value.
2.6 Null Value
The null value is represented by the string null.
A value of any type can be null.
3. Examples
The simplest SUP value is a single value, perhaps a string like this:
"hello, world"
There’s no need for a type declaration here. It’s explicitly a string.
A relational table might look like this:
{ city: "Berkeley", state: "CA", population: 121643::uint32 }::=city_schema
{ city: "Broad Cove", state: "ME", population: 806::uint32 }::=city_schema
{ city: "Baton Rouge", state: "LA", population: 221599::uint32 }::=city_schema
The text here depicts three record values. It defines a type called city_schema
and the inferred type of the city_schema has the signature:
{ city:string, state:string, population:uint32 }
When all the values in a sequence have the same record type, the sequence
can be interpreted as a table, where record values form the rows
and the fields of the records form the columns. In this way, these
three records form a relational table conforming to the schema city_schema.
In contrast, text representing a semi-structured sequence of log lines might look like this:
{
info: "Connection Example",
src: { addr: 10.1.1.2, port: 80::uint16 }::=socket,
dst: { addr: 10.0.1.2, port: 20130::uint16 }::=socket
}::=conn
{
info: "Connection Example 2",
src: { addr: 10.1.1.8, port: 80::uint16 }::=socket,
dst: { addr: 10.1.2.88, port: 19801::uint16 }::=socket
}::=conn
{
info: "Access List Example",
nets: [ 10.1.1.0/24, 10.1.2.0/24 ]
}::=access_list
{ metric: "A", ts: 2020-11-24T08:44:09.586441-08:00, value: 120 }
{ metric: "B", ts: 2020-11-24T08:44:20.726057-08:00, value: 0.86 }
{ metric: "A", ts: 2020-11-24T08:44:32.201458-08:00, value: 126 }
{ metric: "C", ts: 2020-11-24T08:44:43.547506-08:00, value: { x:10, y:101 } }
In this case, the first record defines not just a record type
with named type conn, but also a second embedded record type called socket.
Decorators are used where a type is not inferred from
the value itself:
socketis a record with typed fieldsaddrandportwhereportis an unsigned 16-bit integer, andconnis a record with typed fieldsinfo,src, anddst.
The subsequent value defines a type called access_list. In this case,
the nets field is an array of networks and illustrates the helpful range of
primitive types. Note that the syntax here implies
the type of the array, as it is inferred from the type of the elements.
Finally, there are four more values that show SUP’s efficacy for
representing metrics. Here, there are no type decorators as all of the field
types are implied by their syntax, and hence, the top-level record type is implied.
For instance, the ts field is an RFC 3339 date and time string,
unambiguously the primitive type time. Further,
note that the value field takes on different types and even a complex record
type on the last line. In this case, there is a different top-level
record type implied by each of the three variations of type of the value field.
4. Grammar
Here is a left-recursive pseudo-grammar of SUP. Note that not all acceptable inputs are semantically valid as type mismatches may arise. For example, union and enum values must both appear in a context that defines their type.
<sup> = <sup> <eos> <dec-value> | <sup> <dec-value> | <dec-value>
<eos> = .
<value> = <any> | <any> <val-typedef> | <any> <decorators>
<val-typedef> = "::=" <name>
<decorators> = "::" <type> | <decorators> "::" <type>
<any> = <primitive> | <type-val> | <record> | <array> | <set> | <map> | <enum>
<primitive> = primitive value as defined above
<record> = "{" <flist> "}" | "{" "}"
<flist> = <flist> "," <field> | <field>
<field> = <name> ":" <value>
<name> = <identifier> | <quoted-string>
<quoted-string> = quoted string as defined above
<identifier> = as defined above
<array> = "[" <vlist> "]" | "[" "]"
<vlist> = <vlist> "," <value> | <value>
<set> = "|[" <vlist> "]|" | "|[" "]|"
<enum> = <string> "::" <enum-type>
<map> = "|{" <mlist> "}|" | "|{" "}|"
<mlist> = <mvalue> | <mlist> "," <mvalue>
<mvalue> = <value> ":" <value>
<type-value> = "<" <type> ">"
<error-value> = "error(" <value> ")"
<type> = <primitive-type> | <record-type> | <array-type> | <set-type> |
<union-type> | <enum-type> | <map-type> |
<type-def> | <name> | <numeric> | <error-type>
<primitive-type> = uint8 | uint16 | etc. as defined above
<record-type> = "{" <tflist> "}" | "{" "}"
<tflist> = <tflist> "," <tfield> | <tfield>
<tfield> = <name> ":" <type>
<array-type> = "[" <type> "]" | "[" "]"
<set-type> = "|[" <type> "]|" | "|[" "]|"
<union-type> = <type> "|" <tlist>
<tlist> = <tlist> "|" <type> | <type>
<enum-type> = "enum(" <nlist> ")"
<nlist> = <nlist> "," <name> | <name>
<map-type> = "{" <type> "," <type> "}"
<type-def> = <identifier> = <type-type>
<name> = as defined above
<numeric> = [0-9]+
<error-type> = "error(" <type> ")"
Super Binary (BSUP)
Super Binary (BSUP) Format
1. Introduction
Super Binary (BSUP) is an efficient, sequence-oriented serialization format for super-structured data.
BSUP is “row oriented” and analogous to Apache Avro but does not require schema definitions as it instead utilizes the fine-grained type system of the super data model. This binary format is based on machine-readable data types with an encoding methodology inspired by Avro, Parquet, Protocol Buffers, and Apache Arrow.
To this end, BSUP embeds all type information in the stream itself while having a binary serialization format that allows “lazy parsing” of fields such that only the fields of interest in a stream need to be deserialized and interpreted. Unlike Avro, BSUP embeds its “schemas” in the data stream as types and thereby admits an efficient multiplexing of heterogeneous data types by prepending to each data value a simple integer identifier to reference its type.
Since no external schema definitions exist in BSUP, a “type context” is constructed on the fly by composing dynamic type definitions embedded in the format. BSUP can be readily adapted to systems like Apache Kafka which utilize schema registries, by having a connector translate the schemas implied in the BSUP stream into registered schemas and vice versa. Better still, Kafka could be used natively with BSUP obviating the need for the schema registry.
Multiple BSUP streams with different type contexts are easily merged because the serialization of values does not depend on the details of the type context. One or more streams can be merged by simply merging the input contexts into an output context and adjusting the type reference of each value in the output sequence. The values need not be traversed or otherwise rewritten to be merged in this fashion.
2. The BSUP Format
A BSUP stream comprises a sequence of frames where each frame contains one of three types of data: types, values, or externally-defined control.
A stream is punctuated by the end-of-stream value 0xff.
Each frame header includes a length field allowing an implementation to easily skip from frame to frame.
Each frame begins with a single-byte “frame code”:
7 6 5 4 3 2 1 0
+-+-+-+-+-+-+-+-+
|V|C| T| L|
+-+-+-+-+-+-+-+-+
V: 1 bit
Version number. Must be zero.
C: 1 bit
Indicates compressed frame data.
T: 2 bits
Type of frame data.
00: Types
01: Values
10: Control
11: End of stream
L: 4 bits
Low-order bits of frame length.
Bit 7 of the frame code must be zero as it defines version 0
of the BSUP stream format. If a future version of BSUP
arises, bit 7 of future BSUP frames will be 1.
BSUP version 0 readers must ignore and skip over such frames using the
len field, which must survive future versions.
Any future versions of BSUP must be able to integrate version 0 frames
for backward compatibility.
Following the frame code is its encoded length followed by a “frame payload” of bytes of said length:
<frame code><uvarint><frame payload>
The length encoding utilizes a variable-length unsigned integer called herein a uvarint:
Note
Inspired by Protocol Buffers, a
uvarintis an unsigned, variable-length integer encoded as a sequence of bytes consisting of N-1 bytes with bit 7 clear and the Nth byte with bit 7 set, whose value is the base-128 number composed of the digits defined by the lower 7 bits of each byte from least-significant digit (byte 0) to most-significant digit (byte N-1).
The frame payload’s length is equal to the value of the uvarint following the
frame code times 16 plus the low 4-bit integer value L field in the frame code.
If the C bit is set in the frame code, then the frame payload following the
frame length is compressed and has the form:
<format><size><compressed payload>
where
<format>is a single byte indicating the compression format of the the compressed payload,<size>is auvarintencoding the size of the uncompressed payload, and<compressed payload>is a bytes sequence whose length equals the outer frame length less 1 byte for the compression format and the encoded length of theuvarintsize field.
The compressed payload is compressed according to the compression algorithm
specified by the format byte. Each frame is compressed independently
such that the compression algorithm’s state is not carried from frame to frame
(thereby enabling parallel decoding).
The <size> value is redundant with the compressed payload
but is useful to an implementation to deterministically
size decompression buffers in advance of decoding.
Of the 256 possible values for the <format> byte, only type 0 is currently
defined and specifies that <compressed payload> contains an
LZ4 block.
Note
This arrangement of frames separating types and values allows for efficient scanning and parallelization. In general, values depend on type definitions but as long as all of the types are known when values are used, decoding can be done in parallel. Likewise, since each block is independently compressed, the blocks can be decompressed in parallel. Moreover, efficient filtering can be carried out over uncompressed data before it is deserialized into native data structures, e.g., allowing entire frames to be discarded based on heuristics, e.g., knowing a filtering predicate can’t be true based on a quick scan of the data perhaps using the Boyer-Moore algorithm to determine that a comparison with a string constant would not work for any value in the buffer.
Whether the payload was originally uncompressed or was decompressed, it is
then interpreted according to the T bits of the frame code as a
2.1 Types Frame
A types frame encodes a sequence of type definitions for complex types and establishes a “type ID” for each such definition. Type IDs for the “primitive types” are predefined with the IDs listed in the Primitive Types table.
Each definition, or “typedef”, consists of a typedef code followed by its type-specific encoding as described below. Each type must be decoded in sequence to find the start of the next type definition as there is no framing to separate the typedefs.
The typedefs are numbered in the order encountered starting at 30 (as the largest primary type ID is 29). Types refer to other types by their type ID. Note that the type ID of a typedef is implied by its position in the sequence and is not explicitly encoded.
The typedef codes are defined as follows:
| Code | Complex Type |
|---|---|
| 0 | record type definition |
| 1 | array type definition |
| 2 | set type definition |
| 3 | map type definition |
| 4 | union type definition |
| 5 | enum type definition |
| 6 | error type definition |
| 7 | named type definition |
Any references to a type ID in the body of a typedef are encoded as a uvarint.
2.1.1 Record Typedef
A record typedef creates a new type ID equal to the next stream type ID with the following structure:
--------------------------------------------------------
|0x00|<nfields>|<name1><type-id-1><name2><type-id-2>...|
--------------------------------------------------------
Record types consist of an ordered set of fields where each field consists of a name and its type. Unlike JSON, the ordering of the fields is significant and must be preserved through any APIs that consume, process, and emit BSUP records.
A record type is encoded as a count of fields, i.e., <nfields> from above,
followed by the field definitions,
where a field definition is a field name followed by a type ID, i.e.,
<name1> followed by <type-id-1> etc. as indicated above.
The field names in a record must be unique.
The <nfields> value is encoded as a uvarint.
The field name is encoded as a UTF-8 string defining a “BSUP identifier”.
The UTF-8 string
is further encoded as a “counted string”, which is the uvarint encoding
of the length of the string followed by that many bytes of UTF-8 encoded
string data.
Note
As defined by Super (SUP), a field name can be any valid UTF-8 string much like JSON objects can be indexed with arbitrary string keys (via index operator) even if the field names available to the dot operator are restricted by language syntax for identifiers.
The type ID follows the field name and is encoded as a uvarint.
2.1.2 Array Typedef
An array type is encoded as simply the type code of the elements of
the array encoded as a uvarint:
----------------
|0x01|<type-id>|
----------------
2.1.3 Set Typedef
A set type is encoded as the type ID of the
elements of the set, encoded as a uvarint:
----------------
|0x02|<type-id>|
----------------
2.1.4 Map Typedef
A map type is encoded as the type code of the key followed by the type code of the value.
--------------------------
|0x03|<type-id>|<type-id>|
--------------------------
Each <type-id> is encoded as uvarint.
2.1.5 Union Typedef
A union typedef creates a new type ID equal to the next stream type ID with the following structure:
-----------------------------------------
|0x04|<ntypes>|<type-id-1><type-id-2>...|
-----------------------------------------
A union type consists of an ordered set of types
encoded as a count of the number of types, i.e., <ntypes> from above,
followed by the type IDs comprising the types of the union.
The type IDs of a union must be unique.
The <ntypes> and the type IDs are all encoded as uvarint.
<ntypes> cannot be 0.
2.1.6 Enum Typedef
An enum type is encoded as a uvarint representing the number of symbols
in the enumeration followed by the names of each symbol.
--------------------------------
|0x05|<nelem>|<name1><name2>...|
--------------------------------
<nelem> is encoded as uvarint.
The names have the same UTF-8 format as record field names and are encoded
as counted strings following the same convention as record field names.
2.1.7 Error Typedef
An error type is encoded as follows:
----------------
|0x06|<type-id>|
----------------
which defines a new error type for error values that have the underlying type
indicated by <type-id>.
2.1.8 Named Type Typedef
A named type defines a new type ID that binds a name to a previously existing type ID.
A named type is encoded as follows:
----------------------
|0x07|<name><type-id>|
----------------------
where <name> is an identifier representing the new type name with a new type ID
allocated as the next available type ID in the stream that refers to the
existing type ID <type-id>. <type-id> is encoded as a uvarint and <name>
is encoded as a uvarint representing the length of the name in bytes,
followed by that many bytes of UTF-8 string.
As indicated in the data model, it is an error to define a type name that has the same name as a primitive type, and it is permissible to redefine a previously defined type name with a type that differs from the previous definition.
2.2 Values Frame
A values frame is a sequence of values each encoded as the value’s type ID,
encoded as a uvarint, followed by its tag-encoded serialization as described below.
Since a single type ID encodes the entire value’s structure, no additional type information is needed. Also, the value encoding follows the structure of the type explicitly so the type is not needed to parse the structure of the value, but rather only its semantics.
It is an error for a value to reference a type ID that has not been previously defined by a typedef scoped to the stream in which the value appears.
The value is encoded using a “tag-encoding” scheme that captures the structure of both primitive types and the recursive nature of complex types. This structure is encoded explicitly in every value and the boundaries of each value and its recursive nesting can be parsed without knowledge of the type or types of the underlying values. This admits an efficient implementation for traversing the values, inclusive of recursive traversal of complex values, whereby the inner loop need not consult and interpret the type ID of each element.
2.2.1 Tag-Encoding of Values
Each value is prefixed with a “tag” that defines:
- whether it is the null value, and
- its encoded length in bytes.
The tag is 0 for the null value and length+1 for non-null values where
length is the encoded length of the value. Note that this encoding
differentiates between a null value and a zero-length value. Many data types
have a meaningful interpretation of a zero-length value, for example, an
empty array, the empty record, etc.
The tag itself is encoded as a uvarint.
2.2.2 Tag-Encoded Body of Primitive Values
Following the tag encoding is the value encoded in N bytes as described above.
A typed value with a value of length N is interpreted as described in the
Primitive Types table. The type information needed to
interpret all of the value elements of a complex type are all implied by the
top-level type ID of the values frame. For example, the type ID could indicate
a particular record type, which recursively provides the type information
for all of the elements within that record, including other complex types
embedded within the top-level record.
Note that because the tag indicates the length of the value, there is no need to use varint encoding of integer values. Instead, an integer value is encoded using the full 8 bits of each byte in little-endian order. Signed values, before encoding, are shifted left one bit, and the sign bit stored as bit 0. For negative numbers, the remaining bits are negated so that the upper bytes tend to be zero-filled for small integers.
2.2.3 Tag-Encoded Body of Complex Values
The body of a length-N container comprises zero or more tag-encoded values, where the values are encoded as follows:
| Type | Value |
|---|---|
array | concatenation of elements |
set | normalized concatenation of elements |
record | concatenation of elements |
map | concatenation of key and value elements |
union | concatenation of tag and value |
enum | position of enum element |
error | wrapped element |
Since N, the byte length of any of these container values, is known, there is no need to encode a count of the elements present. Also, since the type ID is implied by the typedef of any complex type, each value is encoded without its type ID.
For sets, the concatenation of elements must be normalized so that the sequence of bytes encoding each element’s tag-counted value is lexicographically greater than that of the preceding element.
A union value is encoded as a container with two elements. The first
element, called the tag, is the uvarint encoding of the
positional index determining the type of the value in reference to the
union’s list of defined types, and the second element is the value
encoded according to that type.
An enum value is represented as the uvarint encoding of the
positional index of that value’s symbol in reference to the enum’s
list of defined symbols.
A map value is encoded as a container whose elements are alternating tag-encoded keys and values, with keys and values encoded according to the map’s key type and value type, respectively.
The concatenation of elements must be normalized so that the sequence of bytes encoding each tag-counted key (of the key/value pair) is lexicographically greater than that of the preceding key (of the preceding key/value pair).
2.3 Control Frame
A control frame contains an application-defined control message.
Control frames are available to higher-layer protocols and are carried in BSUP as a convenient signaling mechanism. A BSUP implementation may skip over all control frames and is guaranteed by this specification to decode all of the data as described herein even if such frames provide additional semantics on top of the base BSUP format.
The body of a control frame is a control message and may be JSON, SUP, BSUP, arbitrary binary, or UTF-8 text. The serialization of the control frame body is independent of the stream containing the control frame.
Any control message not known by a BSUP data receiver shall be ignored.
The delivery order of control messages with respect to the delivery order of values of the BSUP stream should be preserved by an API implementing BSUP serialization and deserialization. In this way, system endpoints that communicate using BSUP can embed protocol directives directly into the stream as control payloads in an order-preserving semantics rather than defining additional layers of encapsulation and synchronization between such layers.
A control frame has the following form:
-------------------------
|<encoding>|<len>|<body>|
-------------------------
where
<encoding>is a single byte indicating whether the body is encoded as BSUP (0), JSON (1), SUP (2), an arbitrary UTF-8 string (3), or arbitrary binary data (4),<len>is auvarintencoding the length in bytes of the body (exclusive of the length 1 encoding byte), and<body>is a control message whose semantics are outside the scope of the base BSUP specification.
If the encoding type is BSUP, the embedded BSUP data starts and ends a single BSUP stream independent of the outer BSUP stream.
2.4 End of Stream
A BSUP stream must be terminated by an end-of-stream marker. A new BSUP stream may begin immediately after an end-of-stream marker. Each such stream has its own, independent type context.
In this way, the concatenation of BSUP streams (or BSUP files containing BSUP streams) results in a valid BSUP data sequence.
For example, a large BSUP file can be arranged into multiple, smaller streams to facilitate random access at stream boundaries. This benefit comes at the cost of some additional overhead – the space consumed by stream boundary markers and repeated type definitions. Choosing an appropriate stream size that balances this overhead with the benefit of enabling random access is left up to implementations.
End-of-stream markers are also useful in the context of sending BSUP over Kafka, as a receiver can easily resynchronize with a live Kafka topic by discarding incomplete frames until a frame is found that is terminated by an end-of-stream marker (presuming the sender implementation aligns the BSUP frames on Kafka message boundaries).
A end-of-stream marker is encoded as follows:
------
|0xff|
------
After this marker, all previously read typedefs are invalidated and the “next available type ID” is reset to the initial value of 30. To represent subsequent values that use a previously defined type, the appropriate typedef control code must be re-emitted (and note that the typedef may now be assigned a different ID).
3. Primitive Types
For each BSUP primitive type, the following table describes:
- its type ID, and
- the interpretation of a length
Nvalue frame.
All fixed-size multi-byte sequences representing machine words are serialized in little-endian format.
| Type | ID | N | BSUP Value Interpretation |
|---|---|---|---|
uint8 | 0 | variable | unsigned int of length N |
uint16 | 1 | variable | unsigned int of length N |
uint32 | 2 | variable | unsigned int of length N |
uint64 | 3 | variable | unsigned int of length N |
uint128 | 4 | variable | unsigned int of length N |
uint256 | 5 | variable | unsigned int of length N |
int8 | 6 | variable | signed int of length N |
int16 | 7 | variable | signed int of length N |
int32 | 8 | variable | signed int of length N |
int64 | 9 | variable | signed int of length N |
int128 | 10 | variable | signed int of length N |
int256 | 11 | variable | signed int of length N |
duration | 12 | variable | signed int of length N as ns |
time | 13 | variable | signed int of length N as ns since epoch |
float16 | 14 | 2 | 2 bytes of IEEE 64-bit format |
float32 | 15 | 4 | 4 bytes of IEEE 64-bit format |
float64 | 16 | 8 | 8 bytes of IEEE 64-bit format |
float128 | 17 | 16 | 16 bytes of IEEE 64-bit format |
float256 | 18 | 32 | 32 bytes of IEEE 64-bit format |
decimal32 | 19 | 4 | 4 bytes of IEEE decimal format |
decimal64 | 20 | 8 | 8 bytes of IEEE decimal format |
decimal128 | 21 | 16 | 16 bytes of IEEE decimal format |
decimal256 | 22 | 32 | 32 bytes of IEEE decimal format |
bool | 23 | 1 | one byte 0 (false) or 1 (true) |
bytes | 24 | variable | N bytes of value |
string | 25 | variable | UTF-8 byte sequence |
ip | 26 | 4 or 16 | 4 or 16 bytes of IP address |
net | 27 | 8 or 32 | 8 or 32 bytes of IP prefix and subnet mask |
type | 28 | variable | type value byte sequence as defined below |
null | 29 | 0 | No value, always represents an undefined value |
4. Type Values
As the super data model supports first-class types and because the BSUP design goals require that value serializations cannot change across type contexts, type values must be encoded in a fashion that is independent of the type context. Thus, a serialized type value encodes the entire type in a canonical form according to the recursive definition in this section.
The type value of a primitive type (include type type) is its primitive ID,
serialized as a single byte.
The type value of a complex type is serialized recursively according to the complex type it represents as described below.
4.1 Record Type Value
A record type value has the form:
--------------------------------------------------
|30|<nfields>|<name1><typeval><name2><typeval>...|
--------------------------------------------------
where <nfields> is the number of fields in the record encoded as a uvarint,
<name1> etc. are the field names encoded as in the
record typedef, and each <typeval> is a recursive encoding of a type value.
4.2 Array Type Value
An array type value has the form:
--------------
|31|<typeval>|
--------------
where <typeval> is a recursive encoding of a type value.
4.3 Set Type Value
A set type value has the form:
--------------
|32|<typeval>|
--------------
where <typeval> is a recursive encoding of a type value.
4.4 Map Type Value
A map type value has the form:
--------------------------
|33|<key-type>|<val-type>|
--------------------------
where <key-type> and <val-type> are recursive encodings of type values.
4.5 Union Type Value
A union type value has the form:
-----------------------------------
|34|<ntypes>|<typeval><typeval>...|
-----------------------------------
where <ntypes> is the number of types in the union encoded as a uvarint
and each <typeval> is a recursive definition of a type value.
4.6 Enum Type Value
An enum type value has the form:
------------------------------
|35|<nelem>|<name1><name2>...|
------------------------------
where <nelem> and each symbol name is encoded as in an enum typedef.
4.7 Error Type Value
An error type value has the form:
-----------
|36|<type>|
-----------
where <type> is the type value of the error.
4.8 Named Type Type Value
A named type type value may appear either as a definition or a reference. When a named type is referenced, it must have been previously defined in the type value in accordance with a left-to-right depth-first-search (DFS) traversal of the type.
A named type definition has the form:
--------------------
|37|<name><typeval>|
--------------------
where <name> is encoded as in a named type typedef
and <typeval> is a recursive encoding of a type value. This creates
a binding between the given name and the indicated type value only within the
scope of the encoded value and does not affect the type context.
This binding may be changed by another named type definition
of the same name in the same type value according to the DFS order.
An named type reference has the form:
-----------
|38|<name>|
-----------
It is an error for a named type reference to appear in a type value with a name that has not been previously defined according to the DFS order.
5. Compression Types
This section specifies values for the <format> byte of a
compressed value message block
and the corresponding algorithms for the <compressed payload> byte sequence.
As new compression algorithms are specified, they will be documented here without any need to change the other sections of the BSUP specification.
Of the 256 possible values for the <format> byte, only type 0 is currently
defined and specifies that <compressed payload> contains an
LZ4 block.
Super Column (CSUP)
Super Column (CSUP) Format
TODO: this is out of date and needs to be updated.
Super Columnar (CSUP) is a file format based on the super-structured data model where data is stacked to form columns. Its purpose is to provide for efficient analytics and search over bounded-length sequences of super-structured data that is stored in columnar form.
Like Parquet, CSUP provides an efficient representation for semi-structured data, but unlike Parquet, CSUP is not based on schemas and does not require a schema to be declared when writing data to a file. Instead, it exploits the nature of super-structured data: columns of data self-organize around their type structure.
CSUP Files
A CSUP file encodes a bounded, ordered sequence of values. To provide for efficient access to subsets of CSUP-encoded data (e.g., columns), the file is presumed to be accessible via random access (e.g., range requests to a cloud object store or seeks in a Unix file system).
A CSUP file can be stored entirely as one storage object or split across separate objects that are treated together as a single CSUP entity. While the format provides much flexibility for how data is laid out, it is left to an implementation to lay out data in intelligent ways for efficient sequential read accesses of related data.
Column Streams
The CSUP data abstraction is built around a collection of column streams.
There is one column stream for each top-level type encountered in the input where each column stream is encoded according to its type. For top-level complex types, the embedded elements are encoded recursively in additional column streams as described below. For example, a record column encodes a presence column encoding any null value for each field then encodes each non-null field recursively, whereas an array column encodes a sequence of “lengths” and encodes each element recursively.
Values are reconstructed one by one from the column streams by picking values from each appropriate column stream based on the type structure of the value and its relationship to the various column streams. For hierarchical records (i.e., records inside of records, or records inside of arrays inside of records, etc.), the reconstruction process is recursive (as described below).
The Physical Layout
The overall layout of a CSUP file is comprised of the following sections, in this order:
- the data section,
- the reassembly section, and
- the trailer.
This layout allows an implementation to buffer metadata in memory while writing column data in a natural order to the data section (based on the volume statistics of each column), then write the metadata into the reassembly section along with the trailer at the end. This allows a stream to be converted to a CSUP file in a single pass.
Note
That said, the layout is flexible enough that an implementation may optimize the data layout with additional passes or by writing the output to multiple files then merging them together (or even leaving the CSUP entity as separate files).
The Data Section
The data section contains raw data values organized into segments, where a segment is a seek offset and byte length relative to the data section. Each segment contains a sequence of primitive-type values, encoded as counted-length byte sequences where the counted-length is variable-length encoded as in the Super Binary (BSUP) specification. Segments may be compressed.
There is no information in the data section for how segments relate to one another or how they are reconstructed into columns. They are just blobs of BSUP data.
Note
Unlike Parquet, there is no explicit arrangement of the column chunks into row groups but rather they are allowed to grow at different rates so a high-volume column might be comprised of many segments while a low-volume column must just be one or several. This allows scans of low-volume record types (the “mice”) to perform well amongst high-volume record types (the “elephants”), i.e., there are not a bunch of seeks with tiny reads of mice data interspersed throughout the elephants.
The mice/elephants model creates an interesting and challenging layout problem. If you let the row indexes get too far apart (call this “skew”), then you have to buffer very large amounts of data to keep the column data aligned. This is the point of row groups in Parquet, but the model here is to leave it up to the implementation to do layout as it sees fit. You can also fall back to doing lots of seeks and that might work perfectly fine when using SSDs but this also creates interesting optimization problems when sequential reads work a lot better. There could be a scan optimizer that lays out how the data is read that lives under the column stream reader. Also, you can make tradeoffs: if you use lots of buffering on ingest, you can write the mice in front of the elephants so the read path requires less buffering to align columns. Or you can do two passes where you store segments in separate files then merge them at close according to an optimization plan.
The Reassembly Section
The reassembly section provides the information needed to reconstruct column streams from segments, and in turn, to reconstruct the original values from column streams, i.e., to map columns back to composite values.
Note
Of course, the reassembly section also provides the ability to extract just subsets of columns to be read and searched efficiently without ever needing to reconstruct the original rows. How well this performs is up to any particular CSUP implementation._
Also, the reassembly section is in general vastly smaller than the data section so the goal here isn’t to express information in cute and obscure compact forms but rather to represent data in an easy-to-digest, programmer-friendly form that leverages BSUP.
The reassembly section is a BSUP stream. Unlike Parquet, which uses an externally described schema (via Thrift) to describe analogous data structures, we simply reuse BSUP here.
The Super Types
This reassembly stream encodes 2*N+1 values, where N is equal to the number of top-level types that are present in the encoded input. To simplify terminology, we call a top-level type a “super type”, e.g., there are N unique super types encoded in the CSUP file.
These N super types are defined by the first N values of the reassembly stream and are encoded as a null value of the indicated super type. A super type’s integer position in this sequence defines its identifier encoded in the super column. This identifier is called the super ID.
TODO: Change the first N values to type values instead of nulls?
The next N+1 records contain reassembly information for each of the N super types where each record defines the column streams needed to reconstruct the original values.
Segment Maps
The foundation of column reconstruction is based on segment maps. A segment map is a list of the segments from the data area that are concatenated to form the data for a column stream.
Each segment map that appears within the reassembly records is represented with an array of records that represent seek ranges conforming to this type signature:
[{offset:uint64,length:uint32,mem_length:uint32,compression_format:uint8}]
In the rest of this document, we will refer to this type as <segmap> for
shorthand and refer to the concept as a “segmap”.
Note
We use the type name “segmap” to emphasize that this information represents a set of byte ranges where data is stored and must be read from rather than the data itself.
The Super Column
The first of the N+1 reassembly records defines the “super column”, where this column represents the sequence of super types of each original value, i.e., indicating which super type’s column stream to select from to pull column values to form the reconstructed value. The sequence of super types is defined by each type’s super ID (as defined above), 0 to N-1, within the set of N super types.
The super column stream is encoded as a sequence of BSUP-encoded int32 primitive values.
While there are a large number of entries in the super column (one for each original row),
the cardinality of super IDs is small in practice so this column
will compress very significantly, e.g., in the special case that all the
values in the CSUP file have the same super ID,
the super column will compress trivially.
The reassembly map appears as the next value in the reassembly section
and is of type <segmap>.
The Reassembly Records
Following the root reassembly map are N reassembly maps, one for each unique super type.
Each reassembly record is a record of type <any_column>, as defined below,
where each reassembly record appears in the same sequence as the original N schemas.
Note that there is no “any” type in the super data model, but rather this terminology is used
here to refer to any of the concrete type structures that would appear
in a given CSUP file.
In other words, the reassembly record of the super column combined with the N reassembly records collectively define the original sequence of data values in the original order. Taken in pieces, the reassembly records allow efficient access to sub-ranges of the rows, to subsets of columns of the rows, to sub-ranges of columns of the rows, and so forth.
This simple top-down arrangement, along with the definition of the other column structures below, is all that is needed to reconstruct all of the original data.
Note
Each row reassembly record has its own layout of columnar values and there is no attempt made to store like-typed columns from different schemas in the same physical column.
The notation <any_column> refers to any instance of the five column types:
Note that when decoding a column, all type information is known from the super type in question so there is no need to encode the type information again in the reassembly record.
Record Column
A <record_column> is defined recursively in terms of the column types of
its fields, i.e., other types that represent arrays, unions, or primitive types
and has the form:
{
<fld1>:{column:<any_column>,presence:<segmap>},
<fld2>:{column:<any_column>,presence:<segmap>},
...
<fldn>:{column:<any_column>,presence:<segmap>}
}
where
<fld1>through<fldn>are the names of the top-level fields of the original row record,- the
columnfields are column stream definitions for each field, and - the
presencecolumns areint32BSUP column streams comprised of a run-length encoding of the locations of column values in their respective rows, when there are null values.
If there are no null values, then the presence field contains an empty <segmap>.
If all of the values are null, then the column field is null (and the presence
contains an empty <segmap>). For an empty <segmap>, there is no
corresponding data stored in the data section. Since a <segmap> is an
array, an empty <segmap> is simply the empty array value [].
Array Column
An <array_column> has the form:
{values:<any_column>,lengths:<segmap>}
where
valuesrepresents a continuous sequence of values of the array elements that are sliced into array values based on the length information, andlengthsencodes anint32sequence of values that represent the length of each array value.
The <array_column> structure is used for both arrays and sets.
Map Column
A <map_column> has the form:
{key:<any_column>,value:<any_column>}
where
keyencodes the column of map keys andvalueencodes the column of map values.
Union Column
A <union_column> has the form:
{columns:[<any_column>],tags:<segmap>}
where
columnsis an array containing the reassembly information for each tagged union value in the same column order implied by the union type, andtagsis a column ofint32values where each subsequent value encodes the tag of the union type indicating which column the value falls within.
TODO: Change code to conform to columns array instead of record{c0,c1,…}
The number of times each value of tags appears must equal the number of values
in each respective column.
Primitive Column
A <primitive_column> is a <segmap> that defines a column stream of
primitive values.
Presence Columns
The presence column is logically a sequence of booleans, one for each position in the original column, indicating whether a value is null or present. The number of values in the encoded column is equal to the number of values present so that null values are not encoded.
Instead the presence column is encoded as a sequence of alternating runs.
First, the number of values present is encoded, then the number of values not present,
then the number of values present, and so forth. These runs are then stored
as int32 values in the presence column (which may be subject to further
compression based on segment compression).
The Trailer
After the reassembly section is a BSUP stream with a single record defining the “trailer” of the CSUP file. The trailer provides a magic field indicating the file format, a version number, the size of the segment threshold for decomposing segments into frames, the size of the skew threshold for flushing all segments to storage when the memory footprint roughly exceeds this threshold, and an array of sizes in bytes of the sections of the CSUP file.
This type of this record has the format
{magic:string,type:string,version:int64,sections:[int64],meta:{skew_thresh:int64,segment_thresh:int64}}
The trailer can be efficiently found by scanning backward from the end of the CSUP file to find a valid BSUP stream containing a single record value conforming to the above type.
Decoding
To decode an entire CSUP file into rows, the trailer is read to find the sizes of the sections, then the BSUP stream of the reassembly section is read, typically in its entirety.
Since this data structure is relatively small compared to all of the columnar data in the file, it will typically fit comfortably in memory and it can be very fast to scan the entire reassembly structure for any purpose.
Note
For a given query, a “scan planner” could traverse all the reassembly records to figure out which segments will be needed, then construct an intelligent plan for reading the needed segments and attempt to read them in mostly sequential order, which could serve as an optimizing intermediary between any underlying storage API and the CSUP decoding logic.
To decode the “next” row, its schema index is read from the root reassembly column stream.
This schema index then determines which reassembly record to fetch column values from.
The top-level reassembly fetches column values as a <record_column>.
For any <record_column>, a value from each field is read from each field’s column,
accounting for the presence column indicating null,
and the results are encoded into the corresponding BSUP record value using
type information from the corresponding schema.
For a <primitive_column> a value is determined by reading the next
value from its segmap.
For an <array_column>, a length is read from its lengths segmap as an int32
and that many values are read from its the values sub-column,
encoding the result as a BSUP array value.
For a <union_column>, a value is read from its tags segmap
and that value is used to select the corresponding column stream
c0, c1, etc. The value read is then encoded as a BSUP union value
using the same tag within the union value.
Examples
Hello, world
Start with this Super (SUP) file hello.sup:
{a:"hello",b:"world"}
{a:"goodnight",b:"gracie"}
To convert to CSUP format:
super -f csup hello.sup > hello.csup
Segments in the CSUP format would be laid out like this:
=== column for a
hello
goodnight
=== column for b
world
gracie
=== column for schema IDs
0
0
===
Super JSON (JSUP)
Super JSON (JSUP) Format
1. Introduction
The super-structured data model is based on richly typed records with a deterministic field order, as is implemented by the Super (SUP), Super Binary (BSUP), and Super Columnar (CSUP) formats.
Given the ubiquity of JSON, it is desirable to also be able to serialize super data into the JSON format. However, encoding super data values directly as JSON values would not work without loss of information.
For example, consider this SUP data:
{
ts: 2018-03-24T17:15:21.926018012Z,
a: "hello, world",
b: {
x: 4611686018427387904,
y: 127.0.0.1
}
}
A straightforward translation to JSON might look like this:
{
"ts": 1521911721.926018012,
"a": "hello, world",
"b": {
"x": 4611686018427387904,
"y": "127.0.0.1"
}
}
But, when this JSON is transmitted to a JavaScript client and parsed, the result looks something like this:
{
"ts": 1521911721.926018,
"a": "hello, world",
"b": {
"x": 4611686018427388000,
"y": "127.0.0.1"
}
}
The good news is the a field came through just fine, but there are
a few problems with the remaining fields:
- the timestamp lost precision (due to 53 bits of mantissa in a JavaScript IEEE 754 floating point number) and was converted from a time type to a number,
- the int64 lost precision for the same reason, and
- the IP address has been converted to a string.
As a comparison, Python’s json module handles the 64-bit integer to full
precision, but loses precision on the floating point timestamp.
Also, it is at the whim of a JSON implementation whether
or not the order of object keys is preserved.
While JSON is well suited for data exchange of generic information, it is not sufficient for super-structured data. That said, JSON can be used as an encoding format for super-structured data with another layer of encoding on top of a JSON-based protocol. This allows clients like web apps or Electron apps to receive and understand SUP and, with the help of client libraries like superdb-types, to manipulate the rich, structured SUP types that are implemented on top of the basic JavaScript types.
In other words, because JSON objects do not have a deterministic field order nor does JSON in general have typing beyond the basics (i.e., strings, floating point numbers, objects, arrays, and booleans), SUP and its embedded type model is layered on top of regular JSON.
2. The Format
The format for representing SUP data in JSON is called JSUP. Converting SUP, BSUP, or CSUP to JSUP and back results in a complete and accurate restoration of the original super data.
A JSUP stream is defined as a sequence of JSON objects where each object represents a value and has the form:
{
"type": <type>,
"value": <value>
}
The type and value fields are encoded as defined below.
2.1 Type Encoding
The type encoding for a primitive type is simply its type name e.g., “int32” or “string”.
Complex types are encoded with small-integer identifiers. The first instance of a unique type defines the binding between the integer identifier and its definition, where the definition may recursively refer to earlier complex types by their identifiers.
For example, the type {s:string,x:int32} has this JSUP format:
{
"id": 123,
"kind": "record",
"fields": [
{
"name": "s",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "x",
"type": {
"kind": "primitive",
"name": "int32"
}
}
]
}
A previously defined complex type may be referred to using a reference of the form:
{
"kind": "ref",
"id": 123
}
2.1.1 Record Type
A record type is a JSON object of the form
{
"id": <number>,
"kind": "record",
"fields": [ <field>, <field>, ... ]
}
where each of the fields has the form
{
"name": <name>,
"type": <type>,
}
and <name> is a string defining the field name and <type> is a
recursively encoded type.
2.1.2 Array Type
An array type is defined by a JSON object having the form
{
"id": <number>,
"kind": "array",
"type": <type>
}
where <type> is a recursively encoded type.
2.1.3 Set Type
A set type is defined by a JSON object having the form
{
"id": <number>,
"kind": "set",
"type": <type>
}
where <type> is a recursively encoded type.
2.1.4 Map Type
A map type is defined by a JSON object of the form
{
"id": <number>,
"kind": "map",
"key_type": <type>,
"val_type": <type>
}
where each <type> is a recursively encoded type.
2.1.5 Union type
A union type is defined by a JSON object having the form
{
"id": <number>,
"kind": "union",
"types": [ <type>, <type>, ... ]
}
where the list of types comprise the types of the union and
and each <type>is a recursively encoded type.
2.1.6 Enum Type
An enum type is a JSON object of the form
{
"id": <number>,
"kind": "enum",
"symbols": [ <string>, <string>, ... ]
}
where the unique <string> values define a finite set of symbols.
2.1.7 Error Type
An error type is a JSON object of the form
{
"id": <number>,
"kind": "error",
"type": <type>
}
where <type> is a recursively encoded type.
2.1.8 Named Type
A named type is encoded as a binding between a name and a type and represents a new type so named. A type definition type has the form
{
"id": <number>,
"kind": "named",
"name": <id>,
"type": <type>,
}
where <id> is a JSON string representing the newly defined type name
and <type> is a recursively encoded type.
2.2 Value Encoding
The primitive values comprising an arbitrarily complex data value are encoded as a JSON array of strings mixed with nested JSON arrays whose structure conforms to the nested structure of the value’s schema as follows:
- each record, array, and set is encoded as a JSON array of its composite values,
- a union is encoded as a string of the form
<tag>:<value>wheretagis an integer string representing the positional index in the union’s list of types that specifies the type of<value>, which is a JSON string or array as described recursively herein, - a map is encoded as a JSON array of two-element arrays of the form
[ <key>, <value> ]wherekeyandvalueare recursively encoded, - a type value is encoded as above,
- each primitive that is not a type value is encoded as a string conforming to its SUP representation, as described in the corresponding section of the SUP specification.
For example, a record with three fields — a string, an array of integers, and an array of union of string, and float64 — might have a value that looks like this:
[ "hello, world", ["1","2","3","4"], ["1:foo", "0:10" ] ]
3. Object Framing
A JSUP file is composed of JSUP objects formatted as
newline delimited JSON (NDJSON).
e.g., the super CLI command
writes its JSUP output as lines of NDJSON.
4. Example
Here is an example that illustrates values of a repeated type,
nesting, records, array, and union. Consider the file input.sup:
{s:"hello",r:{a:1,b:2}}
{s:"world",r:{a:3,b:4}}
{s:"hello",r:{a:[1,2,3]}}
{s:"goodnight",r:{x:{u:"foo"::(string|int64)}}}
{s:"gracie",r:{x:{u:12::(string|int64)}}}
This data is represented in JSUP as follows:
super -f jsup input.sup | jq .
{
"type": {
"kind": "record",
"id": 31,
"fields": [
{
"name": "s",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "r",
"type": {
"kind": "record",
"id": 30,
"fields": [
{
"name": "a",
"type": {
"kind": "primitive",
"name": "int64"
}
},
{
"name": "b",
"type": {
"kind": "primitive",
"name": "int64"
}
}
]
}
}
]
},
"value": [
"hello",
[
"1",
"2"
]
]
}
{
"type": {
"kind": "ref",
"id": 31
},
"value": [
"world",
[
"3",
"4"
]
]
}
{
"type": {
"kind": "record",
"id": 34,
"fields": [
{
"name": "s",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "r",
"type": {
"kind": "record",
"id": 33,
"fields": [
{
"name": "a",
"type": {
"kind": "array",
"id": 32,
"type": {
"kind": "primitive",
"name": "int64"
}
}
}
]
}
}
]
},
"value": [
"hello",
[
[
"1",
"2",
"3"
]
]
]
}
{
"type": {
"kind": "record",
"id": 38,
"fields": [
{
"name": "s",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "r",
"type": {
"kind": "record",
"id": 37,
"fields": [
{
"name": "x",
"type": {
"kind": "record",
"id": 36,
"fields": [
{
"name": "u",
"type": {
"kind": "union",
"id": 35,
"types": [
{
"kind": "primitive",
"name": "int64"
},
{
"kind": "primitive",
"name": "string"
}
]
}
}
]
}
}
]
}
}
]
},
"value": [
"goodnight",
[
[
[
"1",
"foo"
]
]
]
]
}
{
"type": {
"kind": "ref",
"id": 38
},
"value": [
"gracie",
[
[
[
"0",
"12"
]
]
]
]
}
Database
TODO: update and simplify. add note about readiness
Data Pools
Commitish
Note
While
superand its accompanying formats are production quality, SuperDB’s persistent database is still fairly early in development and alpha quality. That said, SuperDB databases can be utilized quite effectively at small scale, or at larger scales when scripted automation is deployed to manage the lake’s data layout via the database API.Enhanced scalability with self-tuning configuration is under development.
Design Philosophy
XXX this section pasted in…fix
The design philosophy for SuperDB is based on composable building blocks built from self-describing data structures. Everything in a SuperDB data lake is built from super-structured data and each system component can be run and tested in isolation.
Since super-structured data is self-describing, this approach makes stream composition
very easy. Data from a query can trivially be piped to a local
instance of super by feeding the resulting output stream to stdin of super, for example,
super db query "from pool | ...remote query..." | super -c "...local query..." -
There is no need to configure the SuperDB entities with schema information like protobuf configs or connections to schema registries.
A SuperDB data lake is completely self-contained, requiring no auxiliary databases
(like the Hive metastore)
or other third-party services to interpret the lake data.
Once copied, a new service can be instantiated by pointing a super db serve
at the copy of the lake.
Functionality like data compaction and retention are all API-driven.
Bite-sized components are unified by the super-structured data, usually in the BSUP format:
- All lake meta-data is available via meta-queries.
- All lake operations available through the service API are also available
directly via the
super dbcommand. - Lake management is agent-driven through the API. For example, instead of complex policies like data compaction being implemented in the core with some fixed set of algorithms and policies, an agent can simply hit the API to obtain the meta-data of the objects in the lake, analyze the objects (e.g., looking for too much key space overlap) and issue API commands to merge overlapping objects and delete the old fragmented objects, all with the transactional consistency of the commit log.
- Components are easily tested and debugged in isolation.
The Database Model
A SuperDB database is a cloud-native arrangement of data, optimized for search, analytics, ETL, data discovery, and data preparation at scale based on data represented in accordance with the super-structured data model.
A database is organized into a collection of data pools forming a single administrative domain. The current implementation supports ACID append and delete semantics at the commit level while we have plans to support CRUD updates at the primary-key level in the near future.
TODO: make pools independent entities then tie them together with a separate layer of adminstrative glue (i.e., there should be no depedencies in a pool that are required to interpret and query it outside of the pool entity)
TODO: back off on github metaphor?
The semantics of a SuperDB database loosely follows the nomenclature and
design patterns of git. In this approach,
- a lake is like a GitHub organization,
- a pool is like a
gitrepository, - a branch of a pool is like a
gitbranch, - the use command is like a
git checkout, and - the load command is like a
git add/commit/push.
A core theme of the SuperDB database design is ergonomics. Given the Git metaphor, our goal here is that the lake tooling be as easy and familiar as Git is to a technical user.
Since databases are built upon the super-structured data model, getting different kinds of data into and out of a lake is easy. There is no need to define schemas or tables and then fit semi-structured data into schemas before loading data into a lake. And because SuperDB supports a large family of formats and the load endpoint automatically detects most formats, it’s easy to just load data into a lake without thinking about how to convert it into the right format.
CLI-First Approach
The SuperDB project has taken a CLI-first approach to designing and implementing
the system. Any time a new piece of functionality is added to the lake,
it is first implemented as a super db command. This is particularly convenient
for testing and continuous integration as well as providing intuitive,
bite-sized chunks for learning how the system works and how the different
components come together.
While the CLI-first approach provides these benefits,
all of the functionality is also exposed through an API to
a lake service. Many use cases involve an application like
SuperDB Desktop or a
programming environment like Python/Pandas interacting
with the service API in place of direct use with super db.
Storage Layer
The lake storage model is designed to leverage modern cloud object stores and separates compute from storage.
A lake is entirely defined by a collection of cloud objects stored at a configured object-key prefix. This prefix is called the storage path. All of the meta-data describing the data pools, branches, commit history, and so forth is stored as cloud objects inside of the lake. There is no need to set up and manage an auxiliary metadata store.
Data is arranged in a lake as a set of pools, which are comprised of one or more branches, which consist of a sequence of data commit objects that point to cloud data objects.
Cloud objects and commits are immutable and named with globally unique IDs, based on the KSUIDs, and many commands may reference various lake entities by their ID, e.g.,
- Pool ID - the KSUID of a pool
- Commit object ID - the KSUID of a commit object
- Data object ID - the KSUID of a committed data object
Data is added and deleted from the lake only with new commits that are implemented in a transactionally consistent fashion. Thus, each commit object (identified by its globally-unique ID) provides a completely consistent view of an arbitrarily large amount of committed data at a specific point in time.
While this commit model may sound heavyweight, excellent live ingest performance can be achieved by micro-batching commits.
Because the lake represents all state transitions with immutable objects, the caching of any cloud object (or byte ranges of cloud objects) is easy and effective since a cached object is never invalid. This design makes backup/restore, data migration, archive, and replication easy to support and deploy.
The cloud objects that comprise a lake, e.g., data objects, commit history, transaction journals, partial aggregations, etc., are stored as super-structured data, i.e., either as row-based Super Binary (BSUP) or Super Columnar (CSUP). This makes introspection of the lake structure straightforward as many key lake data structures can be queried with metadata queries and presented to a client for further processing by downstream tooling.
The implementation also includes a storage abstraction that maps the cloud object model onto a file system so that lakes can also be deployed on standard file systems.
Command Personalities
The super db command provides a single command-line interface to SuperDB data lakes, but
different personalities are taken on by super db depending on the particular
sub-command executed and the database connection.
To this end, super db can take on one of three personalities:
- Direct Access - When the lake is a storage path (
fileors3URI), then thesuper dbcommands (except forserve) all operate directly on the lake located at that path. - Client Personality - When the lake is an HTTP or HTTPS URL, then the lake is presumed to be a service endpoint and the client commands are directed to the service managing the lake.
- Server Personality - When the
super db servecommand is executed, then the personality is always the server personality and the lake must be a storage path. This command initiates a continuous server process that serves client requests for the lake at the configured storage path.
Note that a storage path on the file system may be specified either as
a fully qualified file URI of the form file:// or be a standard
file system path, relative or absolute, e.g., /lakes/test.
Concurrent access to any lake storage, of course, preserves
data consistency. You can run multiple super db serve processes while also
running any super db lake command all pointing at the same storage endpoint
and the lake’s data footprint will always remain consistent as the endpoints
all adhere to the consistency semantics of the lake.
Note
Transactional data consistency is not fully implemented yet for the S3 endpoint so only single-node access to S3 is available right now, though support for multi-node access is forthcoming. For a shared file system, the close-to-open cache consistency semantics of NFS should provide the necessary consistency guarantees needed by the lake though this has not been tested. Multi-process, single-node access to a local file system has been thoroughly tested and should be deemed reliable, i.e., you can run a direct-access instance of
super dbalongside a server instance ofsuper dbon the same file system and data consistency will be maintained.
Locating the Database
At times you may want super db commands to access the same lake storage
used by other tools such as SuperDB Desktop. To help
enable this by default while allowing for separate lake storage when desired,
super db checks each of the following in order to attempt to locate an existing
lake.
- The contents of the
-dboption (if specified) - The contents of the
SUPER_DBenvironment variable (if defined) - A lake service running locally at
http://localhost:9867(if a socket is listening at that port) - A
supersubdirectory below a path in theXDG_DATA_HOMETODO: add link to basedir spec environment variable (if defined) - A default file system location based on detected OS platform:
%LOCALAPPDATA%\superon Windows$HOME/.local/share/superon Linux and macOS
Data Pools
A database is made up of data pools, which are like “collections” in NoSQL
document stores. Pools may have one or more branches and every pool always
has a branch called main.
A pool is created with the create command
and a branch of a pool is created with the branch command.
A pool name can be any valid UTF-8 string and is allocated a unique ID when created. The pool can be referred to by its name or by its ID. A pool may be renamed but the unique ID is always fixed.
Commit Objects
Data is added into a pool in atomic units called commit objects.
Each commit object is assigned a global ID. Similar to Git, commit objects are arranged into a tree and represent the entire commit history of the lake.
Note
Technically speaking, Git can merge from multiple parents and thus Git commits form a directed acyclic graph instead of a tree; SuperDB does not currently support multiple parents in the commit object history.
A branch is simply a named pointer to a commit object in the lake and like a pool, a branch name can be any valid UTF-8 string. Consistent updates to a branch are made by writing a new commit object that points to the previous tip of the branch and updating the branch to point at the new commit object. This update may be made with a transaction constraint (e.g., requiring that the previous branch tip is the same as the commit object’s parent); if the constraint is violated, then the transaction is aborted.
The working branch of a pool may be selected on any command with the -use option
or may be persisted across commands with the use command so that
-use does not have to be specified on each command-line. For interactive
workflows, the use command is convenient but for automated workflows
in scripts, it is good practice to explicitly specify the branch in each
command invocation with the -use option.
Commitish
Many super db commands operate with respect to a commit object.
While commit objects are always referenceable by their commit ID, it is also convenient
to refer to the commit object at the tip of a branch.
The entity that represents either a commit ID or a branch is called a commitish. A commitish is always relative to the pool and has the form:
<pool>@<id>or<pool>@<branch>
where <pool> is a pool name or pool ID, <id> is a commit object ID,
and <branch> is a branch name.
In particular, the working branch set by the use command is a commitish.
A commitish may be abbreviated in several ways where the missing detail is obtained from the working-branch commitish, e.g.,
<pool>- When just a pool name is given, then the commitish is assumed to be<pool>@main.@<id>or<id>- When an ID is given (optionally with the@prefix), then the commitish is assumed to be<pool>@<id>where<pool>is obtained from the working-branch commitish.@<branch>- When a branch name is given with the@prefix, then the commitish is assumed to be<pool>@<id>where<pool>is obtained from the working-branch commitish.
An argument to a command that takes a commit object is called a commitish since it can be expressed as a branch or as a commit ID.
Pool Key
Each data pool is organized according to its configured pool key, which is the sort key for all data stored in the lake. Different data pools can have different pool keys but all of the data in a pool must have the same pool key.
As pool data is often comprised of records (analogous to JSON objects),
the pool key is typically a field of the stored records.
When pool data is not structured as records/objects (e.g., scalar or arrays or other
non-record types), then the pool key would typically be configured
as the special value this.
Data can be efficiently scanned if a query has a filter operating on the pool
key. For example, on a pool with pool key ts, the query ts == 100
will be optimized to scan only the data objects where the value 100 could be
present.
Note
The pool key will also serve as the primary key for the forthcoming CRUD semantics.
A pool also has a configured sort order, either ascending or descending and data is organized in the pool in accordance with this order. Data scans may be either ascending or descending, and scans that follow the configured order are generally more efficient than scans that run in the opposing order.
Scans may also be range-limited but unordered.
Any data loaded into a pool that lacks the pool key is presumed to have a null value with regard to range scans. If large amounts of such “keyless data” are loaded into a pool, the ability to optimize scans over such data is impaired.
Time Travel
Because commits are transactional and immutable, a query
sees its entire data scan as a fixed “snapshot” with respect to the
commit history. In fact, the from operator
allows a commit object to be specified with the @ suffix to a
pool reference, e.g.,
super db query 'from logs@1tRxi7zjT7oKxCBwwZ0rbaiLRxb | ...'
In this way, a query can time-travel through the commit history. As long as the underlying data has not been deleted, arbitrarily old snapshots of the lake can be easily queried.
If a writer commits data after or while a reader is scanning, then the reader does not see the new data since it’s scanning the snapshot that existed before these new writes occurred.
Also, arbitrary metadata can be committed to the log, e.g., to associate derived analytics to a specific journal commit point potentially across different data pools in a transactionally consistent fashion.
While time travel through commit history provides one means to explore past snapshots of the commit history, another means is to use a timestamp. Because the entire history of branch updates is stored in a transaction journal and each entry contains a timestamp, branch references can be easily navigated by time. For example, a list of branches of a pool’s past can be created by scanning the internal “pools log” and stopping at the largest timestamp less than or equal to the desired timestamp. Then using that historical snapshot of the pools, a branch can be located within the pool using that pool’s “branches log” in a similar fashion, then its corresponding commit object can be used to construct the data of that branch at that past point in time.
Note
Time travel using timestamps is a forthcoming feature.
API
SuperDB API
Status
Note
This is a brief sketch of the functionality exposed in the SuperDB API. More detailed documentation of the API will be forthcoming.
Endpoints
Pools
Create pool
Create a new lake pool.
POST /pool
Params
| Name | Type | In | Description |
|---|---|---|---|
| name | string | body | Required. Name of the pool. Must be unique to lake. |
| layout.order | string | body | Order of storage by primary key(s) in pool. Possible values: desc, asc. Default: asc. |
| layout.keys | [[string]] | body | Primary key(s) of pool. The element of each inner string array should reflect the hierarchical ordering of named fields within indexed records. Default: [[ts]]. |
| thresh | int | body | The size in bytes of each seek index. |
| Content-Type | string | header | MIME type of the request payload. |
| Accept | string | header | Preferred MIME type of the response. |
Example Request
curl -X POST \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"name": "inventory", "layout": {"keys": [["product","serial_number"]]}}' \
http://localhost:9867/pool
Example Response
{
"pool": {
"ts": "2022-07-13T21:23:05.323016Z",
"name": "inventory",
"id": "0x0f5ce9b9b6202f3883c9db8ff58d8721a075d1e4",
"layout": {
"order": "asc",
"keys": [
[
"product",
"serial_number"
]
]
},
"seek_stride": 65536,
"threshold": 524288000
},
"branch": {
"ts": "2022-07-13T21:23:05.367365Z",
"name": "main",
"commit": "0x0000000000000000000000000000000000000000"
}
}
Rename pool
Change a pool’s name.
PUT /pool/{pool}
Params
| Name | Type | In | Description |
|---|---|---|---|
| pool | string | path | Required. ID or name of the requested pool. |
| name | string | body | Required. The desired new name of the pool. Must be unique to lake. |
| Content-Type | string | header | MIME type of the request payload. |
Example Request
curl -X PUT \
-H 'Content-Type: application/json' \
-d '{"name": "catalog"}' \
http://localhost:9867/pool/inventory
On success, HTTP 204 is returned with no response payload.
Delete pool
Permanently delete a pool.
DELETE /pool/{pool}
Params
| Name | Type | In | Description |
|---|---|---|---|
| pool | string | path | Required. ID or name of the requested pool. |
Example Request
curl -X DELETE \
http://localhost:9867/pool/inventory
On success, HTTP 204 is returned with no response payload.
Vacuum pool
Free storage space by permanently removing underlying data objects that have previously been subject to a delete operation.
POST /pool/{pool}/revision/{revision}/vacuum
Params
| Name | Type | In | Description |
|---|---|---|---|
| pool | string | path | Required. ID or name of the requested pool. |
| revision | string | path | Required. The starting point for locating objects that can be vacuumed. Can be the name of a branch (whose tip would be used) or a commit ID. |
| dryrun | string | query | Set to “T” to return the list of objects that could be vacuumed, but don’t actually remove them. Defaults to “F”. |
Example Request
curl -X POST \
-H 'Accept: application/json' \
http://localhost:9867/pool/inventory/revision/main/vacuum
Example Response
{"object_ids":["0x10f5a24253887eaf179ee385532ee411c2ed8050","0x10f5a2410ccd08f72e5d98f6d054477173b4f13f"]}
Branches
Load Data
Add data to a pool and return a reference commit ID.
POST /pool/{pool}/branch/{branch}
Params
| Name | Type | In | Description |
|---|---|---|---|
| pool | string | path | Required. ID or name of the pool. |
| branch | string | path | Required. Name of branch to which data will be loaded. |
| various | body | Required. Contents of the posted data. | |
| csv.delim | string | query | Exactly one character specifying the field delimiter for CSV data. Defaults to “,”. |
| Content-Type | string | header | MIME type of the posted content. If undefined, the service will attempt to introspect the data and determine type automatically. |
| Accept | string | header | Preferred MIME type of the response. |
Example Request
curl -X POST \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"product": {"serial_number": 12345, "name": "widget"}, "warehouse": "chicago"}
{"product": {"serial_number": 12345, "name": "widget"}, "warehouse": "miami"}
{"product": {"serial_number": 12346, "name": "gadget"}, "warehouse": "chicago"}' \
http://localhost:9867/pool/inventory/branch/main
Example Response
{"commit":"0x0ed4f42da5763a9500ee71bc3fa5c69f306872de","warnings":[]}
Get Branch
Get information about a branch.
GET /pool/{pool}/branch/{branch}
Params
| Name | Type | In | Description |
|---|---|---|---|
| pool | string | path | Required. ID or name of the pool. |
| branch | string | path | Required. Name of branch. |
| Accept | string | header | Preferred MIME type of the response. |
Example Request
curl -X GET \
-H 'Accept: application/json' \
http://localhost:9867/pool/inventory/branch/main
Example Response
{"commit":"0x0ed4fa21616ecd8fec9d6fd395ad876db98a5dae","warnings":null}
Delete Branch
Delete a branch.
DELETE /pool/{pool}/branch/{branch}
Params
| Name | Type | In | Description |
|---|---|---|---|
| pool | string | path | Required. ID or name of the pool. |
| branch | string | path | Required. Name of branch. |
Example Request
curl -X DELETE \
http://localhost:9867/pool/inventory/branch/staging
On success, HTTP 204 is returned with no response payload.
Delete Data
Create a commit that reflects the deletion of some data in the branch. The data to delete can be specified via a list of object IDs or as a filter expression (see limitations).
This simply removes the data from the branch without actually removing the underlying data objects thereby allowing time travel to work in the face of deletes. Permanent removal of underlying data objects is handled by a separate vacuum operation.
POST /pool/{pool}/branch/{branch}/delete
Params
| Name | Type | In | Description |
|---|---|---|---|
| pool | string | path | Required. ID of the pool. |
| branch | string | path | Required. Name of branch. |
| object_ids | [string] | body | Object IDs to be deleted. |
| where | string | body | Filter expression (see limitations). |
| Content-Type | string | header | MIME type of the request payload. |
| Accept | string | header | Preferred MIME type of the response. |
Example Request
curl -X POST \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"object_ids": ["274Eb1Kn8MTM6qxPyBpVTvYhLLa", "274EavbXt546VNelRLNXrzWShNh"]}' \
http://localhost:9867/pool/inventory/branch/main/delete
Example Response
{"commit":"0x0ed4fee861e8fb61568783205a46a218182eba6c","warnings":null}
Example Request
curl -X POST \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"where": "product.serial_number > 12345"}' \
http://localhost:9867/pool/inventory/branch/main/delete
Example Response
{"commit":"0x0f5ceaeaaec7b4c33cfdece9f2e8577ad89d21e2","warnings":null}
Merge Branches
Create a commit with the difference of the child branch added to the selected branch.
POST /pool/{pool}/branch/{branch}/merge/{child}
Params
| Name | Type | In | Description |
|---|---|---|---|
| pool | string | path | Required. ID of the pool. |
| branch | string | path | Required. Name of branch selected as merge destination. |
| child | string | path | Required. Name of child branch selected as source of merge. |
| Accept | string | header | Preferred MIME type of the response. |
Example Request
curl -X POST \
-H 'Accept: application/json' \
http://localhost:9867/pool/inventory/branch/main/merge/staging
Example Response
{"commit":"0x0ed4ffc2566b423ee444c1c8e6bf964515290f4c","warnings":null}
Revert
Create a revert commit of the specified commit.
POST /pool/{pool}/branch/{branch}/revert/{commit}
Params
| Name | Type | In | Description |
|---|---|---|---|
| pool | string | path | Required. ID of the pool. |
| branch | string | path | Required. Name of branch on which to revert commit. |
| commit | string | path | Required. ID of commit to be reverted. |
| Accept | string | header | Preferred MIME type of the response. |
Example Request
curl -X POST \
-H 'Accept: application/json' \
http://localhost:9867/pool/inventory/branch/main/revert/27D22ifDw3Ms2NMzo8jXpDfpgjc
Example Response
{"commit":"0x0ed500ab6f80e5ac8a1b871bddd88c57fe963ab1","warnings":null}
Query
Execute a Zed query against data in a data lake.
POST /query
Params
| Name | Type | In | Description |
|---|---|---|---|
| query | string | body | Zed query to execute. All data is returned if not specified. |
| head.pool | string | body | Pool to query against Not required if pool is specified in query. |
| head.branch | string | body | Branch to query against. Defaults to “main”. |
| ctrl | string | query | Set to “T” to include control messages in BSUP or ZJSON responses. Defaults to “F”. |
| Content-Type | string | header | MIME type of the request payload. |
| Accept | string | header | Preferred MIME type of the response. |
Example Request
curl -X POST \
-H 'Accept: application/x-sup' \
-H 'Content-Type: application/json' \
http://localhost:9867/query -d '{"query":"from inventory@main | count() by warehouse"}'
Example Response
{warehouse:"chicago",count:2::uint64}
{warehouse:"miami",count:1::uint64}
Example Request
curl -X POST \
-H 'Accept: application/x-zjson' \
-H 'Content-Type: application/json' \
http://localhost:9867/query?ctrl=T -d '{"query":"from inventory@main | count() by warehouse"}'
Example Response
{"type":"QueryChannelSet","value":{"channel":"main"}}
{"type":{"kind":"record","id":30,"fields":[{"name":"warehouse","type":{"kind":"primitive","name":"string"}},{"name":"count","type":{"kind":"primitive","name":"uint64"}}]},"value":["miami","1"]}
{"type":{"kind":"ref","id":30},"value":["chicago","2"]}
{"type":"QueryChannelEnd","value":{"channel":"main"}}
{"type":"QueryStats","value":{"start_time":{"sec":1658193276,"ns":964207000},"update_time":{"sec":1658193276,"ns":964592000},"bytes_read":55,"bytes_matched":55,"records_read":3,"records_matched":3}}
Query Status
Retrieve any runtime errors from a specific query. This endpoint only responds after the query has exited and is only available for a limited time afterwards.
GET /query/status/{request_id}
Params
| Name | Type | In | Description |
|---|---|---|---|
| request_id | string | path | Required. The value of the response header X-Request-Id of the target query. |
Example Request
curl -X GET \
-H 'Accept: application/json' \
http://localhost:9867/query/status/2U1oso7btnCXfDenqFOSExOBEIv
Example Response
{"error":"parquetio: unsupported type: empty record"}
Events
Subscribe to an events feed, which returns an event stream in the format of
server-sent events.
The MIME type specified in the request’s Accept HTTP header determines the format
of data field values in the event stream.
GET /events
Params
None
Example Request
curl -X GET \
-H 'Accept: application/json' \
http://localhost:9867/events
Example Response
event: pool-new
data: {"pool_id": "1sMDXpVwqxm36Rc2vfrmgizc3jz"}
event: pool-update
data: {"pool_id": "1sMDXpVwqxm36Rc2vfrmgizc3jz"}
event: pool-commit
data: {"pool_id": "1sMDXpVwqxm36Rc2vfrmgizc3jz", "commit_id": "1tisISpHoWI7MAZdFBiMERXeA2X"}
event: pool-delete
data: {"pool_id": "1sMDXpVwqxm36Rc2vfrmgizc3jz"}
Media Types
For both request and response payloads, the service supports a variety of formats.
Request Payloads
When sending request payloads, include the MIME type of the format in the request’s Content-Type header. If the Content-Type header is not specified, the service will expect SUP as the payload format.
An exception to this is when loading data and Content-Type is not specified. In this case the service will attempt to introspect the data and may determine the type automatically. The input formats table describes which formats may be successfully auto-detected.
Response Payloads
To receive successful (2xx) responses in a preferred format, include the MIME
type of the format in the request’s Accept HTTP header. If the Accept header is
not specified, the service will return SUP as the default response format. A
different default response format can be specified by invoking the
-defaultfmt option when running super db serve.
For non-2xx responses, the content type of the response will be
application/json or text/plain.
MIME Types
The following table shows the supported MIME types and where they can be used.
| Format | Request | Response | MIME Type |
|---|---|---|---|
| Arrow IPC Stream | yes | yes | application/vnd.apache.arrow.stream |
| BSUP | yes | yes | application/x-bsup |
| CSUP | yes | yes | application/x-csup |
| CSV | yes | yes | text/csv |
| JSON | yes | yes | application/json |
| JSUP | yes | yes | application/x-zjson |
| Line | yes | yes | application/x-line |
| NDJSON | no | yes | application/x-ndjson |
| Parquet | yes | yes | application/x-parquet |
| SUP | yes | yes | application/x-sup |
| TSV | yes | yes | text/tab-separated-values |
| Zeek | yes | yes | application/x-zeek |
Format
Database Format
Note
This document is a rough draft and work in progress. We plan to soon bring it up to date with the current implementation and maintain it as we add new capabilities to the system.
Introduction
To support the client-facing SuperDB access
implemented by the super db command, we are developing
an open specification for the database format described in this document.
This format is somewhat analagous the emerging cloud table formats like Iceberg, but differs but differs in a fundamental way: there are no tables in SuperDB.
On the contrary, we believe a better approach for organizing modern, eclectic data is based on a type system rather than a collection of tables and relational schemas. Since relations, tables, schemas, data frames, Parquet files, Avro files, JSON, CSV, XML, and so forth are all subsets of the SuperDB’s super-structured type system, a data lake based on SuperDB holds the promise to provide a universal data representation for all of these different approaches to data.
TODO: update this to emphasize tables are just a special case… not that they aren’t present in the database
Also, while we are not currently focused on building a SQL engine for the SuperDB database, it is most certainly possible to do so, as a Super record type is analagous to a SQL table definition. SQL tables can essentially be dynamically projected via a table virtualization layer built on top of the SuperDB model.
All data and metadata in a database conforms to the super-structured data model, which materially simplifies development, test, introspection, and so forth.
Cloud Object Model
Every data element in a database is either of two fundamental object types:
- a single-writer immutable object, or
- a multi-writer transaction journal.
Immutable Objects
All imported data in a data pool is composed of immutable objects, which are organized
around a primary data object. Each data object is composed of one or more immutable objects
all of which share a common, globally unique identifier,
which is referred to below generically as <id> below.
TODO: change text to foreshadow KSUID swap out
These identifiers are KSUIDs. The KSUID allocation scheme provides a decentralized solution for creating globally unique IDs. KSUIDs have embedded timestamps so the creation time of any object named in this way can be derived. Also, a simple lexicographic sort of the KSUIDs results in a creation-time ordering (though this ordering is not relied on for causal relationships since clock skew can violate such an assumption).
Note
While a SuperDB database is defined in terms of a cloud object store, it may also be realized on top of a file system, which provides a convenient means for local, small-scale deployments for test/debug workflows. Thus, for simple use cases, the complexity of running an object-store service may be avoided.
Data Objects
A data object is created by a single writer using a globally unique name with an embedded KSUID.
New objects are written in their entirety. No updates, appends, or modifications may be made once an object exists. Given these semantics, any such object may be trivially cached as neither its name nor content ever change.
Since the object’s name is globally unique and the resulting object is immutable, there is no possible write concurrency to manage with respect to a given object.
A data object is composed of the primary data object stored as one or two objects (for sequence and/or vector layout) and an optional seek index.
Data objects may be either in sequence form (i.e., BSUP) or vector form (i.e., CSUP), or both forms may be present as a query optimizer may choose to use whatever representation is more efficient. When both sequence and vector data objects are present, they must contain the same underlying Super data.
Immutable objects are named as follows:
| object type | name |
|---|---|
| vector data | <pool-id>/data/<id>.csup |
| sequence data | <pool-id>/data/<id>.bsup |
| sequence seek index | <pool-id>/data/<id>-seek.bsup |
<id> is the KSUID of the data object.
The seek index maps pool key values to seek offsets in the BSUP file thereby allowing a scan to do a byte-range retrieval of the BSUP object when processing only a subset of data.
Note
The CSUP format allows individual vector segments to be read in isolation and the in-memory CSUP representation supports random access so there is no need to have a seek index for the vector object.
Commit History
A branch’s commit history is the definitive record of the evolution of data in that pool in a transactionally consistent fashion.
Each commit object entry is identified with its commit ID.
Objects are immutable and uniquely named so there is never a concurrent write
condition.
The “add” and “commit” operations are transactionally stored in a chain of commit objects. Any number of adds (and deletes) may appear in a commit object. All of the operations that belong to a commit are identified with a commit identifier (ID).
As each commit object points to its parent (except for the initial commit in main), the collection of commit objects in a pool forms a tree.
Each commit object contains a sequence of actions:
Addto add a data object reference to a pool,Deleteto delete a data object reference from a pool,Commitfor providing metadata about each commit.
The actions are not grouped directly by their commit ID but instead each action serialization includes its commit ID.
The chain of commit objects starting at any commit and following the parent pointers to the original commit is called the “commit log”. This log represents the definitive record of a branch’s present and historical content, and accessing its complete detail can provide insights about data layout, provenance, history, and so forth.
Transaction Journal
State that is mutable is built upon a transaction journal of immutable collections of entries. In this way, there are no objects in the storage footprint that are ever modified. Instead, the journal captures changes and journal snapshots are used to provide synchronization points for efficient access to the journal (so the entire journal need not be read to create the current state) and old journal entries may be removed based on retention policy.
The journal may be updated concurrently by multiple writers so concurrency controls are included (see Journal Concurrency Control below) to provide atomic updates.
A journal entry simply contains actions that modify the visible “state” of the pool by changing branch name to commit object mappings. Note that adding a commit object to a pool changes nothing until a branch pointer is mutated to point at that object.
Each atomic journal commit object is a BSUP file numbered 1 to the end of journal (HEAD),
e.g., 1.bsup, 2.bsup, etc., each number corresponding to a journal ID.
The 0 value is reserved as the null journal ID.
The journal’s TAIL begins at 1 and is increased as journal entries are purged.
Entries are added at the HEAD and removed from the TAIL.
Once created, a journal entry is never modified but it may be deleted and
never again allocated.
There may be 1 or more entries in each commit object.
Each journal entry implies a snapshot of the data in a pool. A snapshot is computed by applying the transactions in sequence from entry TAIL to the journal entry in question, up to HEAD. This gives the set of commit IDs that comprise a snapshot.
The set of branch pointers in a pool is assembled at any point in the journal’s history by scanning a journal that includes ADD, UPDATE, and DELETE actions for the mapping of a branch name to a commit object. A timestamp is recorded in each action to provide for time travel.
For efficiency, a journal entry’s snapshot may be stored as a “cached snapshot” alongside the journal entry. This way, the snapshot at HEAD may be efficiently computed by locating the most recent cached snapshot and scanning forward to HEAD.
Journal Concurrency Control
To provide for atomic commits, a writer must be able to atomically update the HEAD of the log. There are three strategies for doing so.
First, if the cloud service offers “put-if-missing” semantics, then a writer can simply read the HEAD file and use put-if-missing to write to the journal at position HEAD+1. If this fails because of a race, then the writer can simply write at position HEAD+2 and so forth until it succeeds (and then update the HEAD object). Note that there can be a race in updating HEAD, but HEAD is always less than or equal to the real end of journal, and this condition can be self-corrected by probing for HEAD+1 whenever the HEAD of the journal is accessed.
Note
Put-if-missing can be emulated on a local file system by opening a file for exclusive access and checking that it has zero length after a successful open.
Second, strong read/write ordering semantics (as exists in Amazon S3 can be used to implement transactional journal updates as follows:
- TBD: this is worked out but needs to be written up
Finally, since the above algorithm requires many round trips to the storage system and such round trips can be tens of milliseconds, another approach is to simply run a lock service as part of a cloud deployment that manages a mutex lock for each pool’s journal.
Configuration State
Configuration state describing a lake or pool is also stored in mutable objects. The database uses a commit journal to store configuration like the list of pools and pool attributes. Here, a generic interface to a commit journal manages any configuration state simply as a key-value store of snapshots providing time travel over the configuration history.
Merge on Read
To support sorted scans, data objects are store in a sorted order defined by the pool’s sort key. The sort key may be a composite key compised of primary, secondary, etc component keys.
When the key range of objects overlap, they may be read in parallel in merged in sorted order. This is called the merge scan.
If many overlapping data objects arise, performing a merge scan
on every read can be inefficient.
This can arise when
many random data load operations involving perhaps “late” data
(e.g., the pool key is a timestamp and records with old timestamp values regularly
show up and need to be inserted into the past). The data layout can become
fragmented and less efficient to scan, requiring a scan to merge data
from a potentially large number of different objects.
To solve this problem, the database format follows the LSM design pattern. Since records in each data object are stored in sorted order, a total order over a collection of objects (e.g., the collection coming from a specific set of commits) can be produced by executing a sorted scan and rewriting the results back to the pool in a new commit. In addition, the objects comprising the total order do not overlap. This is just the basic LSM algorithm at work.
Object Naming
<lake-path>/
lake.bsup
pools/
HEAD
TAIL
1.bsup
2.bsup
...
<pool-id-1>/
branches/
HEAD
TAIL
1.bsup
2.bsup
...
commits/
<id1>.bsup
<id2>.bsup
...
data/
<id1>.{bsup,csup}
<id2>.{bsup,csup}
...
<pool-id-2>/
...
Developer
SuperDB includes some basic libraries for interacting with a database, and some initial integrations with external systems.
Libraries
Libraries
SuperDB currently supports a small number of languages with client libraries for manipulating super-structured data and interacting with a SuperDB service via the remote API.
Our documentation for client libraries is early but will be improved as the project develops.
We plan to support a broad range of languages. Open source contributions are welcome. Give us a holler on Slack if you would like help or guidance on developing a SuperDB library.
Go
Go
SuperDB is developed in Go so support for Go clients is fairly comprehensive. Documentation of exported package functions is fairly scant though we plan to improve it soon.
Also, our focus for the Go client packages has been on supporting the interal APIs in the SuperDB implementation. We intend to develop a Go package that is easier to use for external clients. In the meantime, clients may use the internal Go packages though the APIs are subject to change.
Installation
SuperDB is structured as a standard Go module so it’s easy to import into other Go projects straight from the GitHub repo.
Some of the key packages are:
- super - Super values and types
- sup - SUP support
- sio - I/O interfaces for Super data following the Reader/Writer patterns
- sio/bsupio - BSUP reader/writer
- sio/supio - SUP reader/writer
- db/api - interact with a SuperDB database
To install in your local Go project, simply run:
go get github.com/brimdata/super
Examples
SUP Reader
Read SUP from stdin, dereference field s, and print results:
package main
import (
"fmt"
"log"
"os"
"github.com/brimdata/super"
"github.com/brimdata/super/sio/supio"
"github.com/brimdata/super/sup"
)
func main() {
sctx := super.NewContext()
reader := supio.NewReader(sctx, os.Stdin)
for {
val, err := reader.Read()
if err != nil {
log.Fatalln(err)
}
if val == nil {
return
}
s := val.Deref("s")
if s == nil {
s = sctx.Missing().Ptr()
}
fmt.Println(sup.String(s))
}
}
To build, create a directory for the main package, initialize it,
copy the above code into main.go, and fetch the required Super packages.
mkdir example
cd example
go mod init example
cat > main.go
# [paste from above]
go mod tidy
To run type:
echo '{s:"hello"}{x:123}{s:"world"}' | go run .
which produces
"hello"
error("missing")
"world"
Local Database Reader
This example interacts with a SuperDB database. Note that it is straightforward to support both direct access to a lake via the file system (or S3 URL) as well as access via a service endpoint.
First, we’ll use super to create a lake and load the example data:
super db init -db scratch
super db create -db scratch Demo
echo '{s:"hello, world"}{x:1}{s:"good bye"}' | super db load -db scratch -use Demo -
Now replace main.go with this code:
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/brimdata/super"
"github.com/brimdata/super/db/api"
"github.com/brimdata/super/pkg/storage"
"github.com/brimdata/super/sup"
"github.com/brimdata/super/sbuf"
)
func main() {
if len(os.Args) != 2 {
log.Fatalln("URI of database not provided")
}
uri, err := storage.ParseURI(os.Args[1])
if err != nil {
log.Fatalln(err)
}
ctx := context.TODO()
db, err := api.Connect(ctx, nil, uri.String())
if err != nil {
log.Fatalln(err)
}
q, err := db.Query(ctx, "from Demo")
if err != nil {
log.Fatalln(err)
}
defer q.Pull(true)
reader := sbuf.PullerReader(q)
sctx := super.NewContext()
for {
val, err := reader.Read()
if err != nil {
log.Fatalln(err)
}
if val == nil {
return
}
s := val.Deref("s")
if s == nil {
s = sctx.Missing().Ptr()
}
fmt.Println(sup.String(s))
}
}
After a re-run of go mod tidy, run this command to interact with the lake via
the local file system:
go run . ./scratch
which should output
"hello, world"
"good bye"
error("missing")
Note that the order of data has changed because the database stores data
in a sorted order. Since we did not specify a “pool key” when we created
the lake, it ends up sorting the data by this.
Database Service Reader
We can use the same code above to talk to a SuperDB servuce. All we do is give it the URI of the service, which by default is on port 9867.
To try this out, first run a SuperDB service on the scratch lake we created above:
super db serve -db ./scratch
Finally, in another local shell, run the Go program and specify the service endpoint we just created:
go run . http://localhost:9867
and you should again get this result:
"hello, world"
"good bye"
error("missing")
JavaScript
The superdb-types library provides support for the super data model from within JavaScript as well as methods for communicating with a SuperDB data lake.
Because JavaScript’s native type system is limited, superdb-types provides implementations for each of the super-structured primitive types as well as technique for interpreting and/or constructing arbitrary complex types.
Installation
Documentation coming soon.
Library API
Documentation coming soon.
Examples
Examples coming soon.
Python
SuperDB includes preliminary support for Python-based interaction with a SuperDB database.
The Python package supports loading data into a database as well as
querying and retrieving results in the JSUP format.
The Python client interacts with the database via the REST API served by
super db serve.
This approach works adequately when high data throughput is not required. We plan to introduce native binary format support for Python that should increase performance substantially for more data intensive workloads.
Installation
Install the latest version like this:
pip3 install "git+https://github.com/brimdata/super#subdirectory=python/superdb"
Install the version compatible with a particular version of SuperDB like this:
pip3 install "git+https://github.com/brimdata/super@$(super -version | cut -d ' ' -f 2)#subdirectory=python/superdb"
Example
To run this example, first start a SuperDB service from your shell:
super db init -db scratch
super db serve -db scratch
Then, in another shell, use Python to create a pool, load some data, and run a query:
python3 <<EOF
import superdb
# Connect to the default lake at http://localhost:9867. To use a
# different lake, supply its URL via the SUPER_DB environment variable
# or as an argument here.
client = superdb.Client()
client.create_pool('TestPool')
# Load some SUP records from a string. A file-like object also works.
# Data format is detected automatically and can be BSUP, CSV, JSON, SUP,
# Zeek TSV, or JSUP.
client.load('TestPool', '{s:"hello"} {s:"world"}')
# Begin executing a SuperDB query for all values in TestPool.
# This returns an iterator, not a container.
values = client.query('from TestPool')
# Stream values from the server.
for val in values:
print(val)
EOF
You should see this output:
{'s': 'world'}
{'s': 'hello'}
Integrations
SuperDB currently supports a small number of integrations with other systems.
Our coverage and documentation for integrations is early but will be improved as the project develops.
We plan to support a broad range of integrations. Open source contributions are welcome. Give us a holler on Slack if you would like help or guidance on developing a SuperDB integrations.
Amazon S3
SuperDB can access Amazon S3 and
S3-compatible storage via s3:// URIs. Details are described below.
Region
You must specify an AWS region via one of the following:
- The
AWS_REGIONenvironment variable - The
~/.aws/configfile - The file specified by the
AWS_CONFIG_FILEenvironment variable
You can create ~/.aws/config by installing the
AWS CLI and running aws configure.
Note
If using S3-compatible storage that does not recognize the concept of regions, a region must still be specified, e.g., by providing a dummy value for
AWS_REGION.
Credentials
You must specify AWS credentials via one of the following:
- The
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYenvironment variables - The
~/.aws/credentialsfile - The file specified by the
AWS_SHARED_CREDENTIALS_FILEenvironment variable
You can create ~/.aws/credentials by installing the
AWS CLI and running aws configure.
Endpoint
To use S3-compatible storage not provided by AWS, set the AWS_S3_ENDPOINT
environment variable to the hostname or URI of the provider.
Wildcard Support
Like the AWS CLI tools themselves,
SuperDB does not currently expand UNIX-style * wildcards in S3 URIs. If you
find this limitation is impacting your workflow, please add your use case
details as a comment in issue super/1994
to help us track the priority of possible enhancements in this area.
Fluentd
TODO: Phil to update this.
The Fluentd open source data collector can be used to push log data to a SuperDB database in a continuous manner. This allows for querying near-“live” event data to enable use cases such as dashboarding and alerting in addition to creating a long-running historical record for archiving and analytics.
This guide walks through two simple configurations of Fluentd with a SuperDB database that can be used as reference for starting your own production configuration. As it’s a data source important to many in the SuperDB community, log data from Zeek is used in this guide. The approach shown can be easily adapted to any log data source.
Software
The examples were tested on an AWS EC2 t2.large instance running Ubuntu
Linux 24.04. At the time this article was written, the following versions
were used for the referenced software:
- Fluentd v1.17.0
- SuperDB v0.1
- Zeek v6.2.1
Zeek
The commands below were used to install Zeek from a binary package. The JSON Streaming Logs package was also installed, as this log format is preferred in many production Zeek environments and it lends itself to use with Fluentd’s tail input plugin.
echo 'deb http://download.opensuse.org/repositories/security:/zeek/xUbuntu_24.04/ /' | sudo tee /etc/apt/sources.list.d/security:zeek.list
curl -fsSL https://download.opensuse.org/repositories/security:zeek/xUbuntu_24.04/Release.key | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/security_zeek.gpg > /dev/null
sudo apt update
sudo apt install -y zeek
sudo /opt/zeek/bin/zkg install --force json-streaming-logs
Two edits were then performed to the configuration:
-
The
#in the last line in/opt/zeek/share/zeek/site/local.zeekwas removed to uncomment@load packages, which allows the JSON Streaming Logs package to be activated when Zeek starts. -
The file
/opt/zeek/etc/node.cfgwas edited to to change theinterfacesetting to reflect the network source from which Zeek should sniff live traffic, which in our instance wasenX0.
After making these changes, Zeek was started by running
sudo /opt/zeek/bin/zeekctl and executing the deploy command.
SuperDB
The super
executable compatible with our instance was downloaded and unpacked to a
directory in our $PATH, then the database service
was started with a specified storage path.
wget https://github.com/brimdata/super/releases/download/v1.17.0/zed-v1.17.0.linux-amd64.tar.gz
tar xzvf zed-v1.17.0.linux-amd64.tar.gz
sudo mv zed /usr/local/bin
zed -lake $HOME/lake serve -manage 5m
Once the lake service was running, a pool was created to hold our Zeek data by executing the following command in another shell.
super db create zeek
The default settings when running super db create set the
database sort key
to the ts field and sort the stored data in descending order by that key.
This configuration is ideal for Zeek log data.
Fluentd
Multiple approaches are available for installing Fluentd. Here we opted to take the approach of installing via Ruby Gem.
sudo apt install -y ruby ruby-dev make gcc
sudo gem install fluentd --no-doc
Simple Example
The following simple fluentd.conf was used to watch the streamed Zeek logs
for newly added lines and load each set of them to the pool in the Zed lake as
a separate commit.
<source>
@type tail
path /opt/zeek/logs/current/json_streaming_*
follow_inodes true
pos_file /opt/zeek/logs/fluentd.pos
tag zeek
<parse>
@type json
</parse>
</source>
<match zeek>
@type http
endpoint http://127.0.0.1:9867/pool/zeek/branch/main
content_type application/json
<format>
@type json
</format>
</match>
When starting Fluentd with this configuration, we followed their guidance to increase the maximum number of file descriptors.
ulimit -n 65535
sudo fluentd -c fluentd.conf
To confirm everything was working, we generated some network traffic that would be reflected in a Zeek log by performing a DNS lookup from a shell on our instance.
nslookup example.com
To see the event was been stored in our pool, we executed the following query:
super db -S -c 'from zeek | _path=="dns" query=="example.com"'
With the Fluentd configuration shown here, it took about a minute for the most recent log data to be flushed through the ingest flow such that the query produced the following response:
{
_path: "dns",
_write_ts: "2024-07-21T00:36:58.826215Z",
ts: "2024-07-21T00:36:48.826245Z",
uid: "CVWi4c1GsgQrgUohth",
"id.orig_h": "172.31.9.104",
"id.orig_p": 38430,
"id.resp_h": "172.31.0.2",
"id.resp_p": 53,
proto: "udp",
trans_id: 7250,
query: "example.com",
rcode: 0,
rcode_name: "NOERROR",
AA: false,
TC: false,
RD: false,
RA: true,
Z: 0,
answers: [
"2606:2800:21f:cb07:6820:80da:af6b:8b2c"
],
TTLs: [
300.
],
rejected: false
}
Shaping Example
The query result just shown reflects the minimal data typing available in JSON
format. Meanwhile, the
super-structured data model provides much
richer data typing options, including some types well-suited to Zeek data such
as ip, time, and duration. In Zed, the task of cleaning up data to
improve its typing is known as shaping.
For Zeek data specifically, a reference shaper is available that reflects the field and type information in the logs generated by a recent Zeek release. To improve the quality of our data, we next created an expanded configuration that applies the shaper before loading the data into our pool.
First we saved the contents of the shaper from
here to a file
shaper.zed. Then in the same directory we created the following
fluentd-shaped.conf:
<source>
@type tail
path /opt/zeek/logs/current/json_streaming_*
follow_inodes true
pos_file /opt/zeek/logs/fluentd.pos
tag zeek
<parse>
@type json
</parse>
</source>
<match zeek>
@type exec_filter
command super -s -I shaper.zed -
tag shaped
<format>
@type json
</format>
<parse>
@type none
</parse>
<buffer>
flush_interval 1s
</buffer>
</match>
<match shaped>
@type http
endpoint http://127.0.0.1:9867/pool/zeek-shaped/branch/main
content_type application/x-sup
<format>
@type single_value
</format>
</match>
After stopping the Fluentd process that was previously running, we created a new pool to store these shaped logs and restarted Fluentd with the new configuration.
zed create zeek-shaped
ulimit -n 65535
sudo fluentd -c fluentd-shaped.conf
We triggered another Zeek event by performing DNS lookup on another domain.
nslookup example.org
After a delay, we executed the following query to see the event in its shaped form:
super db -S -c 'from "zeek-shaped" | _path=="dns" query=="example.org"'
Example output:
{
_path: "dns",
ts: 2024-07-21T00:43:38.385932Z,
uid: "CNcpGS2BFLZaRCyN46",
id: {
orig_h: 172.31.9.104,
orig_p: 42796::(port=uint16),
resp_h: 172.31.0.2,
resp_p: 53 (port)
} (=conn_id),
proto: "udp" (=zenum),
trans_id: 19994::uint64,
rtt: null (duration),
query: "example.org",
qclass: null::uint64,
qclass_name: null::string,
qtype: null::uint64,
qtype_name: null::string,
rcode: 0::uint64,
rcode_name: "NOERROR",
AA: false,
TC: false,
RD: false,
RA: true,
Z: 0::uint64,
answers: [
"2606:2800:21f:cb07:6820:80da:af6b:8b2c"
],
TTLs: [
300ns
],
rejected: false,
_write_ts: 2024-07-21T00:43:48.396878Z
} (=dns)
Notice quotes are no longer present around the values that contain IP addresses
and times, since they are no longer stored as strings. With the data in this
shaped form, we could now invoke SuperSQL
functionality that leverages the richer data typing such as filtering ip
values by CIDR block, e.g.,
super db -c 'from "zeek-shaped" | _path=="conn" | cidr_match(172.31.0.0/16, id.resp_h) | count() by id'
which in our test environment produced
{id:{orig_h:218.92.0.99,orig_p:9090::(port=uint16),resp_h:172.31.0.253,resp_p:22(port)}(=conn_id),count:4::uint64}
{id:{orig_h:172.31.0.253,orig_p:42014::(port=uint16),resp_h:172.31.0.2,resp_p:53(port)}(=conn_id),count:1::uint64}
{id:{orig_h:172.31.0.253,orig_p:37490::(port=uint16),resp_h:172.31.0.2,resp_p:53(port)}(=conn_id),count:1::uint64}
{id:{orig_h:172.31.0.253,orig_p:33488::(port=uint16),resp_h:172.31.0.2,resp_p:53(port)}(=conn_id),count:1::uint64}
{id:{orig_h:172.31.0.253,orig_p:44362::(port=uint16),resp_h:172.31.0.2,resp_p:53(port)}(=conn_id),count:1::uint64}
{id:{orig_h:199.83.220.79,orig_p:52876::(port=uint16),resp_h:172.31.0.253,resp_p:22(port)}(=conn_id),count:1::uint64}
or this query that counts events into buckets by time span
super db -c 'from "zeek-shaped" | count() by bucket(ts,5m) | sort bucket'
which in our test environment produced
{bucket:2024-07-19T22:15:00Z,count:1::uint64}
{bucket:2024-07-19T22:45:00Z,count:6::uint64}
{bucket:2024-07-19T22:50:00Z,count:696::uint64}
{bucket:2024-07-19T22:55:00Z,count:683::uint64}
{bucket:2024-07-19T23:00:00Z,count:698::uint64}
{bucket:2024-07-19T23:05:00Z,count:309::uint64}
SuperDB Database Maintenance
The database stores the data for each load
operation in a separate commit. If you observe the output of
super db log -use zeek-shaped after several minutes, you will see many
such commits have accumulated, which is a reflection of Fluentd frequently
pushing new sets of lines from each of the many log files generated by Zeek.
The bulky nature of log data combined with the need to often perform “needle
in a haystack” queries over long time spans means that performance could
degrade as many small commits accumulate. However, the -manage 5m option
that was included when starting our Zed lake service mitigates this effect
by compacting the data in the lake’s pools every five minutes. This results
in storing the pool data across a smaller number of larger
data objects, allowing for better query performance
as data volumes increase.
By default, even after compaction is performed, the granular commit history is
still maintained to allow for time travel
use cases. However, if time travel is not functionality you’re likely to
leverage, you can reduce the lake’s storage footprint by periodically running
super db vacuum. This will delete files from lake
storage that contain the granular commits that have already been rolled into
larger objects by compaction.
Note
As described in issue super/4934, even after running
super db vacuum, some files related to commit history are currently still left behind below the lake storage path. The issue describes manual steps that can be taken to remove these files safely, if desired. However, if you find yourself needing to take these steps in your environment, please contact us as it will allow us to boost the priority of addressing the issue.
Ideas For Enhancement
The examples shown above provide a starting point for creating a production configuration that suits the needs of your environment. While we cannot anticipate the many ways users may enhance these configurations, we can cite some opportunities for possible improvement we spotted during this exercise. You may wish to experiment with these to see what best suits your needs.
-
Buffering - Components of Fluentd used here such as
exec_filterprovide many buffering options. Varying these may impact how quickly events appear in the pool and the size of the commit objects to which they’re initially stored. -
BSUP format - In the shaping example shown above, we used the Super (SUP) format format for the shaped data output from
super. This text format is typically used in contexts where human readability is required. Due to its compact nature, Super Binary (BSUP) format would have been preferred, but in our research we found Fluentd consistently steered us toward using only text formats. However, someone more proficient with Fluentd may be able to employ BSUP instead.
If you have success experimenting with these ideas or have other enhancements you feel would benefit other users, please contact us so this article can be improved.
Contact Us
If you’re having difficulty, interested in loading or shaping other data sources, or just have feedback, please join our public Slack and speak up or open an issue. Thanks!
Grafana
A data source plugin for Grafana is available that enables plotting of time-series data that’s stored in a SuperDB database. See the README in the grafana-zed-datasource repository for details.
Zeek
SuperDB includes functionality and reference configurations specific to working with logs from the Zeek open source network security monitoring tool.
Logs
SuperDB can read both of the common Zeek log formats. This section
provides guidance for what to expect when reading logs of these formats using
the super command.
Zeek TSV
Zeek TSV
is Zeek’s default output format for logs. This format can be read automatically
(i.e., no -i command line flag is necessary to indicate the input format)
with super.
The following example shows a TSV conn.log being read via super and
output as Super (SUP).
conn.log
#separator \x09
#set_separator ,
#empty_field (empty)
#unset_field -
#path conn
#open 2019-11-08-11-44-16
#fields ts uid id.orig_h id.orig_p id.resp_h id.resp_p proto service duration orig_bytes resp_bytes conn_state local_orig local_resp missed_bytes history orig_pkts orig_ip_bytes resp_pkts resp_ip_bytes tunnel_parents
#types time string addr port addr port enum string interval count count string bool bool count string count count count count set[string]
1521911721.255387 C8Tful1TvM3Zf5x8fl 10.164.94.120 39681 10.47.3.155 3389 tcp - 0.004266 97 19 RSTR - - 0 ShADTdtr 10 730 6 342 -
Example
super -S -c 'head 1' conn.log
Output
{
_path: "conn",
ts: 2018-03-24T17:15:21.255387Z,
uid: "C8Tful1TvM3Zf5x8fl",
id: {
orig_h: 10.164.94.120,
orig_p: 39681::(port=uint16),
resp_h: 10.47.3.155,
resp_p: 3389::port
},
proto: "tcp"::=zenum,
service: null::string,
duration: 4.266ms,
orig_bytes: 97::uint64,
resp_bytes: 19::uint64,
conn_state: "RSTR",
local_orig: null::bool,
local_resp: null::bool,
missed_bytes: 0::uint64,
history: "ShADTdtr",
orig_pkts: 10::uint64,
orig_ip_bytes: 730::uint64,
resp_pkts: 6::uint64,
resp_ip_bytes: 342::uint64,
tunnel_parents: null::|[string]|
}
Since Zeek provides a richest type system, such records typically need no adjustment to their data types once they’ve been read in as is. The Zeek Type Compatibility document provides further detail on how the rich data types in Zeek TSV map to the equivalent super-structured types.
Zeek JSON
As an alternative to the default TSV format, there are two common ways that Zeek may instead generate logs in JSON format.
- Using the JSON Streaming Logs
package (recommended for use with
super) - Using the built-in ASCII logger
configured with
redef LogAscii::use_json = T;
In both cases, super can read these logs automatically
as is, but with caveats.
Let’s revisit the same conn record we just examined from the Zeek TSV
log, but now as generated using the JSON Streaming Logs package.
conn.json
{"_path":"conn","_write_ts":"2018-03-24T17:15:21.400275Z","ts":"2018-03-24T17:15:21.255387Z","uid":"C8Tful1TvM3Zf5x8fl","id.orig_h":"10.164.94.120","id.orig_p":39681,"id.resp_h":"10.47.3.155","id.resp_p":3389,"proto":"tcp","duration":0.004266023635864258,"orig_bytes":97,"resp_bytes":19,"conn_state":"RSTR","missed_bytes":0,"history":"ShADTdtr","orig_pkts":10,"orig_ip_bytes":730,"resp_pkts":6,"resp_ip_bytes":342}
Example
super -S -c 'head 1' conn.json
Output
{
_path: "conn",
_write_ts: "2018-03-24T17:15:21.400275Z",
ts: "2018-03-24T17:15:21.255387Z",
uid: "C8Tful1TvM3Zf5x8fl",
"id.orig_h": "10.164.94.120",
"id.orig_p": 39681,
"id.resp_h": "10.47.3.155",
"id.resp_p": 3389,
proto: "tcp",
duration: 0.004266023635864258,
orig_bytes: 97,
resp_bytes: 19,
conn_state: "RSTR",
missed_bytes: 0,
history: "ShADTdtr",
orig_pkts: 10,
orig_ip_bytes: 730,
resp_pkts: 6,
resp_ip_bytes: 342
}
When we compare this to the TSV example, we notice a few things right away that all follow from the records having been previously output as JSON.
- The timestamps like
_write_tsandtsare printed as strings rather than the Supertimetype. - The IP addresses such as
id.orig_handid.resp_hare printed as strings rather than the Superiptype. - The connection
durationis printed as a floating point number rather than the Superdurationtype. - The keys for the null-valued fields in the record read from TSV are not present in the record read from JSON.
If you’re familiar with the limitations of the JSON data types, it makes sense that Zeek chose to output these values as it did. Furthermore, if you were just seeking to do quick searches on the string values or simple math on the numbers, these limitations may be acceptable. However, if you intended to perform operations like aggregations with time-based grouping or CIDR matches on IP addresses, you would likely want to restore the rich Super data types as the records are being read. The document on shaping Zeek JSON provides details on how this can be done.
The Role of _path
Zeek’s _path field plays an important role in differentiating between its
different log types
(conn, dns, etc.) For instance,
shaping Zeek JSON relies on the value of
the _path field to know which type to apply to an input JSON
record.
If reading Zeek TSV logs or logs generated by the JSON Streaming Logs
package, this _path value is provided within the Zeek logs. However, if the
log was generated by Zeek’s built-in ASCII logger when using the
redef LogAscii::use_json = T; configuration, the value that would be used for
_path is present in the log file name but is not in the JSON log
records. In this case you could adjust your Zeek configuration by following the
Log Extension Fields example
from the Zeek docs. If you enter path in the locations where the example
shows stream, you will see the field named _path populated just like was
shown for the JSON Streaming Logs output.
Zeek Type Compatibility
As the super data model was in many ways inspired by the Zeek TSV log format, SuperDB’s rich storage formats (Super (SUP), Super Binary (BSUP), etc.) maintain comprehensive interoperability with Zeek. When Zeek is configured to output its logs in JSON format, much of the rich type information is lost in translation, but this can be restored by following the guidance for shaping Zeek JSON. On the other hand, Zeek TSV can be converted to Zed storage formats and back to Zeek TSV without any loss of information.
This document describes how the super-structured type system is able to represent each of the types that may appear in Zeek logs.
SuperDB maintains an internal super-structured representation of any Zeek data that is read or imported. Therefore, knowing the equivalent types will prove useful when performing operations in the SuperSQL such as type casting or looking at the data when output as SUP.
Equivalent Types
The following table summarizes which Zed data type corresponds to each Zeek data type that may appear in a Zeek TSV log. While most types have a simple 1-to-1 mapping from Zeek to Zed and back to Zeek again, the sections linked from the Additional Detail column describe cosmetic differences and other subtleties applicable to handling certain types.
| Zeek Type | Zed Type | Additional Detail |
|---|---|---|
bool | bool | |
count | uint64 | |
int | int64 | |
double | float64 | See double details |
time | time | |
interval | duration | |
string | string | See string details about escaping |
port | uint16 | See port details |
addr | ip | |
subnet | net | |
enum | string | See enum details |
set | set | See set details |
vector | array | |
record | record | See record details |
Note
The Zeek data types page describes the types in the context of the Zeek scripting language. The Zeek types available in scripting are a superset of the data types that may appear in Zeek log files. The encodings of the types also differ in some ways between the two contexts. However, we link to this reference because there is no authoritative specification of the Zeek TSV log format.
Example
The following example shows a TSV log that includes each Zeek data type, how
it’s output as SUP by super, and then how it’s written back out again as a Zeek
log. You may find it helpful to refer to this example when reading the
type-specific details.
Viewing the TSV log:
cat zeek_types.log
Output:
#separator \x09
#set_separator ,
#empty_field (empty)
#unset_field -
#fields my_bool my_count my_int my_double my_time my_interval my_printable_string my_bytes_string my_port my_addr my_subnet my_enum my_set my_vector my_record.name my_record.age
#types bool count int double time interval string string port addr subnet enum set[string] vector[string] string count
T 123 456 123.4560 1592502151.123456 123.456 smile😁smile \x09\x07\x04 80 127.0.0.1 10.0.0.0/8 tcp things,in,a,set order,is,important Jeanne 122
Reading the TSV log, outputting as SUP, and saving a copy:
super -S zeek_types.log | tee zeek_types.sup
Output:
{
my_bool: true,
my_count: 123::uint64,
my_int: 456,
my_double: 123.456,
my_time: 2020-06-18T17:42:31.123456Z,
my_interval: 2m3.456s,
my_printable_string: "smile😁smile",
my_bytes_string: "\t\u0007\u0004",
my_port: 80::(port=uint16),
my_addr: 127.0.0.1,
my_subnet: 10.0.0.0/8,
my_enum: "tcp"::=zenum,
my_set: |[
"a",
"in",
"set",
"things"
]|,
my_vector: [
"order",
"is",
"important"
],
my_record: {
name: "Jeanne",
age: 122::uint64
}
}
Reading the saved SUP output and outputting as Zeek TSV:
super -f zeek zeek_types.sup
Output:
#separator \x09
#set_separator ,
#empty_field (empty)
#unset_field -
#fields my_bool my_count my_int my_double my_time my_interval my_printable_string my_bytes_string my_port my_addr my_subnet my_enum my_set my_vector my_record.name my_record.age
#types bool count int double time interval string string port addr subnet enum set[string] vector[string] string count
T 123 456 123.456 1592502151.123456 123.456000 smile😁smile \x09\x07\x04 80 127.0.0.1 10.0.0.0/8 tcp a,in,set,things order,is,important Jeanne 122
Type-Specific Details
As super acts as a reference implementation for SuperDB storage formats such as
SUP and BSUP, it’s helpful to understand how it reads the following Zeek data
types into readable text equivalents in the SUP format, then writes them back
out again in the Zeek TSV log format. Other implementations of the Zed storage
formats (should they exist) may handle these differently.
Multiple Zeek types discussed below are represented via a
type definition to one of Zed’s
primitive types. The Zed type
definitions maintain the history of the field’s original Zeek type name
such that super may restore it if the field is later output in
Zeek TSV format. Knowledge of its original Zeek type may also enable special
operations in Zed that are unique to values known to have originated as a
specific Zeek type, though no such operations are currently implemented in
super.
double
As they do not affect accuracy, “trailing zero” decimal digits on Zeek double
values will not be preserved when they are formatted into a string, such as
via the -f sup|zeek|table output options in super (e.g., 123.4560 becomes
123.456).
s
enum
As they’re encountered in common programming languages, enum variables
typically hold one of a set of predefined values. While this is
how Zeek’s enum type behaves inside the Zeek scripting language,
when the enum type is output in a Zeek log, the log does not communicate
any such set of “allowed” values as they were originally defined. Therefore,
these values are represented with a type name bound to the Zed string
type. See the text above regarding type definitions
for more details.
port
The numeric values that appear in Zeek logs under this type are represented
in Zed with a type name of port bound to the uint16 type. See the text
above regarding type names for more details.
set
Because order within sets is not significant, no attempt is made to maintain
the order of set elements as they originally appeared in a Zeek log.
string
Zeek’s string data type is complicated by its ability to hold printable ASCII
and UTF-8 as well as arbitrary unprintable bytes represented as \x escapes.
Because such binary data may need to legitimately be captured (e.g. to record
the symptoms of DNS exfiltration), it’s helpful that Zeek has a mechanism to
log it. Unfortunately, Zeek’s use of the single string type for these
multiple uses leaves out important details about the intended interpretation
and presentation of the bytes that make up the value. For instance, one Zeek
string field may hold arbitrary network data that coincidentally sometimes
form byte sequences that could be interpreted as printable UTF-8, but they are
not intended to be read or presented as such. Meanwhile, another Zeek
string field may be populated such that it will only ever contain printable
UTF-8. These details are currently only captured within the Zeek source code
itself that defines how these values are generated.
SuperSQL includes a primitive type
called bytes that’s suited to storing the former “always binary” case and a
string type for the latter “always printable” case. However, Zeek logs do
not currently communicate details that would allow an implementation to know
which Zeek string fields to store as which of these two Zed data types.
Instead, the Zed system does what the Zeek system does when writing strings
to JSON: any \x escapes used in Zeek TSV strings are translated into valid
Zed UTF-8 strings by escaping the backslash before the x. In this way,
you can still see binary-corrupted strings that are generated by Zeek in
the Zed data formats.
Unfortunately there is no way to distinguish whether a \x escape occurred
or whether that string pattern happened to occur in the original data. A nice
solution would be to convert Zeek strings that are valid UTF-8 strings into
Zed strings and convert invalid strings into a Zed bytes type, or we could
convert both of them into a Zed union of string and bytes. If you have
interest in a capability like this, please let us know and we can elevate
the priority.
If Zeek were to provide an option to output logs directly in a super-structured
storage formats, this would create an opportunity to
assign the appropriate SuperDB bytes or string type at the point of origin,
depending on what’s known about how the field’s value is intended to be
populated and used.
record
Zeek’s record type is unique in that every Zeek log line effectively is a
record, with its schema defined via the #fields and #types directives in
the headers of each log file. The word “record” never appears explicitly in
the schema definition in Zeek logs.
Embedded records also subtly appear within Zeek log lines in the form of
dot-separated field names. A common example in Zeek is the
id
record, which captures the source and destination IP addresses and ports for a
network connection as fields id.orig_h, id.orig_p, id.resp_h, and
id.resp_p. When reading such fields into their Zed equivalent, super restores
the hierarchical nature of the record as it originally existed inside of Zeek
itself before it was output by its logging system. This enables operations in
Zed that refer to the record at a higher level but affect all values lower
down in the record hierarchy.
For instance, revisiting the data from our example, we can output all fields within
my_record using Zed’s cut operator.
Command:
super -f zeek -c 'cut my_record' zeek_types.sup
Output:
#separator \x09
#set_separator ,
#empty_field (empty)
#unset_field -
#fields my_record.name my_record.age
#types string count
Jeanne 122
Shaping Zeek JSON
When reading Zeek JSON format logs, much of the rich data typing that was originally present inside Zeek is at risk of being lost. This detail can be restored using a shaper, such as the reference shaper described below.
Zeek Version/Configuration
The fields and data types in the reference shaper reflect the default JSON-format logs output by Zeek releases up to the version number referenced in the comments at the top. They have been revisited periodically as new Zeek versions have been released.
Most changes we’ve observed in Zeek logs between versions have involved only the
addition of new fields. Because of this, we expect the shaper should be usable
as is for Zeek releases older than the one most recently tested, since fields
in the shaper not present in your environment would just be filled in with
null values.
All attempts will be made to update this reference shaper in a timely manner as new Zeek versions are released. However, if you have modified your Zeek installation with packages or other customizations, or if you are using a Corelight Sensor that produces Zeek logs with many fields and logs beyond those found in open source Zeek, the reference shaper will not cover all the fields in your logs. As described below, by default the shaper will produce errors when this happens, though it can also be configured to silently crop such fields or keep them while assigning inferred types.
Reference Shaper Contents
The following reference shaper.spq may seem large, but ultimately it follows a
fairly simple pattern that repeats across the many Zeek log types.
-- This reference shaper for Zeek JSON logs was most recently tested with
-- Zeek v7.0.0. The fields and data types reflect the default JSON
-- logs output by that Zeek version when using the JSON Streaming Logs package.
-- (https://github.com/corelight/json-streaming-logs).
const _crop_records = true
const _error_if_cropped = true
type port=uint16
type zenum=string
type conn_id={orig_h:ip,orig_p:port,resp_h:ip,resp_p:port}
const zeek_log_types = |{
"analyzer": <analyzer={_path:string,ts:time,cause:string,analyzer_kind:string,analyzer_name:string,uid:string,fuid:string,id:conn_id,failure_reason:string,failure_data:string,_write_ts:time}>,
"broker": <broker={_path:string,ts:time,ty:zenum,ev:string,peer:{address:string,bound_port:port},message:string,_write_ts:time}>,
"capture_loss": <capture_loss={_path:string,ts:time,ts_delta:duration,peer:string,gaps:uint64,acks:uint64,percent_lost:float64,_write_ts:time}>,
"cluster": <cluster={_path:string,ts:time,node:string,message:string,_write_ts:time}>,
"config": <config={_path:string,ts:time,id:string,old_value:string,new_value:string,location:string,_write_ts:time}>,
"conn": <conn={_path:string,ts:time,uid:string,id:conn_id,proto:zenum,service:string,duration:duration,orig_bytes:uint64,resp_bytes:uint64,conn_state:string,local_orig:bool,local_resp:bool,missed_bytes:uint64,history:string,orig_pkts:uint64,orig_ip_bytes:uint64,resp_pkts:uint64,resp_ip_bytes:uint64,tunnel_parents:|[string]|,_write_ts:time}>,
"dce_rpc": <dce_rpc={_path:string,ts:time,uid:string,id:conn_id,rtt:duration,named_pipe:string,endpoint:string,operation:string,_write_ts:time}>,
"dhcp": <dhcp={_path:string,ts:time,uids:|[string]|,client_addr:ip,server_addr:ip,mac:string,host_name:string,client_fqdn:string,domain:string,requested_addr:ip,assigned_addr:ip,lease_time:duration,client_message:string,server_message:string,msg_types:[string],duration:duration,_write_ts:time}>,
"dnp3": <dnp3={_path:string,ts:time,uid:string,id:conn_id,fc_request:string,fc_reply:string,iin:uint64,_write_ts:time}>,
"dns": <dns={_path:string,ts:time,uid:string,id:conn_id,proto:zenum,trans_id:uint64,rtt:duration,query:string,qclass:uint64,qclass_name:string,qtype:uint64,qtype_name:string,rcode:uint64,rcode_name:string,AA:bool,TC:bool,RD:bool,RA:bool,Z:uint64,answers:[string],TTLs:[duration],rejected:bool,_write_ts:time}>,
"dpd": <dpd={_path:string,ts:time,uid:string,id:conn_id,proto:zenum,analyzer:string,failure_reason:string,_write_ts:time}>,
"files": <files={_path:string,ts:time,fuid:string,uid:string,id:conn_id,source:string,depth:uint64,analyzers:|[string]|,mime_type:string,filename:string,duration:duration,local_orig:bool,is_orig:bool,seen_bytes:uint64,total_bytes:uint64,missing_bytes:uint64,overflow_bytes:uint64,timedout:bool,parent_fuid:string,md5:string,sha1:string,sha256:string,extracted:string,extracted_cutoff:bool,extracted_size:uint64,_write_ts:time}>,
"ftp": <ftp={_path:string,ts:time,uid:string,id:conn_id,user:string,password:string,command:string,arg:string,mime_type:string,file_size:uint64,reply_code:uint64,reply_msg:string,data_channel:{passive:bool,orig_h:ip,resp_h:ip,resp_p:port},fuid:string,_write_ts:time}>,
"http": <http={_path:string,ts:time,uid:string,id:conn_id,trans_depth:uint64,method:string,host:string,uri:string,referrer:string,version:string,user_agent:string,origin:string,request_body_len:uint64,response_body_len:uint64,status_code:uint64,status_msg:string,info_code:uint64,info_msg:string,tags:|[zenum]|,username:string,password:string,proxied:|[string]|,orig_fuids:[string],orig_filenames:[string],orig_mime_types:[string],resp_fuids:[string],resp_filenames:[string],resp_mime_types:[string],_write_ts:time}>,
"intel": <intel={_path:string,ts:time,uid:string,id:conn_id,seen:{indicator:string,indicator_type:zenum,where:zenum,node:string},matched:|[zenum]|,sources:|[string]|,fuid:string,file_mime_type:string,file_desc:string,_write_ts:time}>,
"irc": <irc={_path:string,ts:time,uid:string,id:conn_id,nick:string,user:string,command:string,value:string,addl:string,dcc_file_name:string,dcc_file_size:uint64,dcc_mime_type:string,fuid:string,_write_ts:time}>,
"kerberos": <kerberos={_path:string,ts:time,uid:string,id:conn_id,request_type:string,client:string,service:string,success:bool,error_msg:string,from:time,till:time,cipher:string,forwardable:bool,renewable:bool,client_cert_subject:string,client_cert_fuid:string,server_cert_subject:string,server_cert_fuid:string,_write_ts:time}>,
"known_certs": <known_certs={_path:string,ts:time,host:ip,port_num:port,subject:string,issuer_subject:string,serial:string,_write_ts:time}>,
"known_hosts": <known_hosts={_path:string,ts:time,host:ip,_write_ts:time}>,
"known_services": <known_services={_path:string,ts:time,host:ip,port_num:port,port_proto:zenum,service:|[string]|,_write_ts:time}>,
"ldap": <ldap={_path:string,ts:time,uid:string,id:conn_id,message_id:int64,version:int64,opcode:string,result:string,diagnostic_message:string,object:string,argument:string,_write_ts:time}>,
"ldap_search": <ldap_search={_path:string,ts:time,uid:string,id:conn_id,message_id:int64,scope:string,deref_aliases:string,base_object:string,result_count:uint64,result:string,diagnostic_message:string,filter:string,attributes:[string],_write_ts:time}>,
"loaded_scripts": <loaded_scripts={_path:string,name:string,_write_ts:time}>,
"modbus": <modbus={_path:string,ts:time,uid:string,id:conn_id,tid:uint64,unit:uint64,func:string,pdu_type:string,exception:string,_write_ts:time}>,
"mqtt_connect": <mqtt_connect={_path:string,ts:time,uid:string,id:conn_id,proto_name:string,proto_version:string,client_id:string,connect_status:string,will_topic:string,will_payload:string,_write_ts:time}>,
"mqtt_publish": <mqtt_publish={_path:string,ts:time,uid:string,id:conn_id,from_client:bool,retain:bool,qos:string,status:string,topic:string,payload:string,payload_len:uint64,_write_ts:time}>,
"mqtt_subscribe": <mqtt_subscribe={_path:string,ts:time,uid:string,id:conn_id,action:zenum,topics:[string],qos_levels:[uint64],granted_qos_level:uint64,ack:bool,_write_ts:time}>,
"mysql": <mysql={_path:string,ts:time,uid:string,id:conn_id,cmd:string,arg:string,success:bool,rows:uint64,response:string,_write_ts:time}>,
"netcontrol": <netcontrol={_path:string,ts:time,rule_id:string,category:zenum,cmd:string,state:zenum,action:string,target:zenum,entity_type:string,entity:string,mod:string,msg:string,priority:int64,expire:duration,location:string,plugin:string,_write_ts:time}>,
"netcontrol_drop": <netcontrol_drop={_path:string,ts:time,rule_id:string,orig_h:ip,orig_p:port,resp_h:ip,resp_p:port,expire:duration,location:string,_write_ts:time}>,
"netcontrol_shunt": <netcontrol_shunt={_path:string,ts:time,rule_id:string,f:{src_h:ip,src_p:port,dst_h:ip,dst_p:port},expire:duration,location:string,_write_ts:time}>,
"notice": <notice={_path:string,ts:time,uid:string,id:conn_id,fuid:string,file_mime_type:string,file_desc:string,proto:zenum,note:zenum,msg:string,sub:string,src:ip,dst:ip,p:port,n:uint64,peer_descr:string,actions:|[zenum]|,email_dest:|[string]|,suppress_for:duration,remote_location:{country_code:string,region:string,city:string,latitude:float64,longitude:float64},_write_ts:time}>,
"notice_alarm": <notice_alarm={_path:string,ts:time,uid:string,id:conn_id,fuid:string,file_mime_type:string,file_desc:string,proto:zenum,note:zenum,msg:string,sub:string,src:ip,dst:ip,p:port,n:uint64,peer_descr:string,actions:|[zenum]|,email_dest:|[string]|,suppress_for:duration,remote_location:{country_code:string,region:string,city:string,latitude:float64,longitude:float64},_write_ts:time}>,
"ntlm": <ntlm={_path:string,ts:time,uid:string,id:conn_id,username:string,hostname:string,domainname:string,server_nb_computer_name:string,server_dns_computer_name:string,server_tree_name:string,success:bool,_write_ts:time}>,
"ntp": <ntp={_path:string,ts:time,uid:string,id:conn_id,version:uint64,mode:uint64,stratum:uint64,poll:duration,precision:duration,root_delay:duration,root_disp:duration,ref_id:string,ref_time:time,org_time:time,rec_time:time,xmt_time:time,num_exts:uint64,_write_ts:time}>,
"ocsp": <ocsp={_path:string,ts:time,id:string,hashAlgorithm:string,issuerNameHash:string,issuerKeyHash:string,serialNumber:string,certStatus:string,revoketime:time,revokereason:string,thisUpdate:time,nextUpdate:time,_write_ts:time}>,
"openflow": <openflow={_path:string,ts:time,dpid:uint64,match:{in_port:uint64,dl_src:string,dl_dst:string,dl_vlan:uint64,dl_vlan_pcp:uint64,dl_type:uint64,nw_tos:uint64,nw_proto:uint64,nw_src:net,nw_dst:net,tp_src:uint64,tp_dst:uint64},flow_mod:{cookie:uint64,table_id:uint64,command:zenum,idle_timeout:uint64,hard_timeout:uint64,priority:uint64,out_port:uint64,out_group:uint64,flags:uint64,actions:{out_ports:[uint64],vlan_vid:uint64,vlan_pcp:uint64,vlan_strip:bool,dl_src:string,dl_dst:string,nw_tos:uint64,nw_src:ip,nw_dst:ip,tp_src:uint64,tp_dst:uint64}},_write_ts:time}>,
"packet_filter": <packet_filter={_path:string,ts:time,node:string,filter:string,init:bool,success:bool,failure_reason:string,_write_ts:time}>,
"pe": <pe={_path:string,ts:time,id:string,machine:string,compile_ts:time,os:string,subsystem:string,is_exe:bool,is_64bit:bool,uses_aslr:bool,uses_dep:bool,uses_code_integrity:bool,uses_seh:bool,has_import_table:bool,has_export_table:bool,has_cert_table:bool,has_debug_data:bool,section_names:[string],_write_ts:time}>,
"quic": <quic={_path:string,ts:time,uid:string,id:conn_id,version:string,client_initial_dcid:string,client_scid:string,server_scid:string,server_name:string,client_protocol:string,history:string,_write_ts:time}>,
"radius": <radius={_path:string,ts:time,uid:string,id:conn_id,username:string,mac:string,framed_addr:ip,tunnel_client:string,connect_info:string,reply_msg:string,result:string,ttl:duration,_write_ts:time}>,
"rdp": <rdp={_path:string,ts:time,uid:string,id:conn_id,cookie:string,result:string,security_protocol:string,client_channels:[string],keyboard_layout:string,client_build:string,client_name:string,client_dig_product_id:string,desktop_width:uint64,desktop_height:uint64,requested_color_depth:string,cert_type:string,cert_count:uint64,cert_permanent:bool,encryption_level:string,encryption_method:string,_write_ts:time}>,
"reporter": <reporter={_path:string,ts:time,level:zenum,message:string,location:string,_write_ts:time}>,
"rfb": <rfb={_path:string,ts:time,uid:string,id:conn_id,client_major_version:string,client_minor_version:string,server_major_version:string,server_minor_version:string,authentication_method:string,auth:bool,share_flag:bool,desktop_name:string,width:uint64,height:uint64,_write_ts:time}>,
"signatures": <signatures={_path:string,ts:time,uid:string,src_addr:ip,src_port:port,dst_addr:ip,dst_port:port,note:zenum,sig_id:string,event_msg:string,sub_msg:string,sig_count:uint64,host_count:uint64,_write_ts:time}>,
"sip": <sip={_path:string,ts:time,uid:string,id:conn_id,trans_depth:uint64,method:string,uri:string,date:string,request_from:string,request_to:string,response_from:string,response_to:string,reply_to:string,call_id:string,seq:string,subject:string,request_path:[string],response_path:[string],user_agent:string,status_code:uint64,status_msg:string,warning:string,request_body_len:uint64,response_body_len:uint64,content_type:string,_write_ts:time}>,
"smb_files": <smb_files={_path:string,ts:time,uid:string,id:conn_id,fuid:string,action:zenum,path:string,name:string,size:uint64,prev_name:string,times:{modified:time,accessed:time,created:time,changed:time},_write_ts:time}>,
"smb_mapping": <smb_mapping={_path:string,ts:time,uid:string,id:conn_id,path:string,service:string,native_file_system:string,share_type:string,_write_ts:time}>,
"smtp": <smtp={_path:string,ts:time,uid:string,id:conn_id,trans_depth:uint64,helo:string,mailfrom:string,rcptto:|[string]|,date:string,from:string,to:|[string]|,cc:|[string]|,reply_to:string,msg_id:string,in_reply_to:string,subject:string,x_originating_ip:ip,first_received:string,second_received:string,last_reply:string,path:[ip],user_agent:string,tls:bool,fuids:[string],is_webmail:bool,_write_ts:time}>,
"snmp": <snmp={_path:string,ts:time,uid:string,id:conn_id,duration:duration,version:string,community:string,get_requests:uint64,get_bulk_requests:uint64,get_responses:uint64,set_requests:uint64,display_string:string,up_since:time,_write_ts:time}>,
"socks": <socks={_path:string,ts:time,uid:string,id:conn_id,version:uint64,user:string,password:string,status:string,request:{host:ip,name:string},request_p:port,bound:{host:ip,name:string},bound_p:port,_write_ts:time}>,
"software": <software={_path:string,ts:time,host:ip,host_p:port,software_type:zenum,name:string,version:{major:uint64,minor:uint64,minor2:uint64,minor3:uint64,addl:string},unparsed_version:string,_write_ts:time}>,
"ssh": <ssh={_path:string,ts:time,uid:string,id:conn_id,version:uint64,auth_success:bool,auth_attempts:uint64,direction:zenum,client:string,server:string,cipher_alg:string,mac_alg:string,compression_alg:string,kex_alg:string,host_key_alg:string,host_key:string,remote_location:{country_code:string,region:string,city:string,latitude:float64,longitude:float64},_write_ts:time}>,
"ssl": <ssl={_path:string,ts:time,uid:string,id:conn_id,version:string,cipher:string,curve:string,server_name:string,resumed:bool,last_alert:string,next_protocol:string,established:bool,ssl_history:string,cert_chain_fps:[string],client_cert_chain_fps:[string],subject:string,issuer:string,client_subject:string,client_issuer:string,sni_matches_cert:bool,validation_status:string,_write_ts:time}>,
"stats": <stats={_path:string,ts:time,peer:string,mem:uint64,pkts_proc:uint64,bytes_recv:uint64,pkts_dropped:uint64,pkts_link:uint64,pkt_lag:duration,pkts_filtered:uint64,events_proc:uint64,events_queued:uint64,active_tcp_conns:uint64,active_udp_conns:uint64,active_icmp_conns:uint64,tcp_conns:uint64,udp_conns:uint64,icmp_conns:uint64,timers:uint64,active_timers:uint64,files:uint64,active_files:uint64,dns_requests:uint64,active_dns_requests:uint64,reassem_tcp_size:uint64,reassem_file_size:uint64,reassem_frag_size:uint64,reassem_unknown_size:uint64,_write_ts:time}>,
"syslog": <syslog={_path:string,ts:time,uid:string,id:conn_id,proto:zenum,facility:string,severity:string,message:string,_write_ts:time}>,
"telemetry_histogram": <telemetry_histogram={_path:string,ts:time,peer:string,name:string,labels:[string],label_values:[string],bounds:[float64],values:[float64],sum:float64,observations:float64,_write_ts:time}>,
"telemetry": <telemetry={_path:string,ts:time,peer:string,metric_type:string,name:string,labels:[string],label_values:[string],value:float64,_write_ts:time}>,
"tunnel": <tunnel={_path:string,ts:time,uid:string,id:conn_id,tunnel_type:zenum,action:zenum,_write_ts:time}>,
"websocket": <websocket={_path:string,ts:time,uid:string,id:conn_id,host:string,uri:string,user_agent:string,subprotocol:string,client_protocols:[string],server_extensions:[string],client_extensions:[string],_write_ts:time}>,
"weird": <weird={_path:string,ts:time,uid:string,id:conn_id,name:string,addl:string,notice:bool,peer:string,source:string,_write_ts:time}>,
"x509": <x509={_path:string,ts:time,fingerprint:string,certificate:{version:uint64,serial:string,subject:string,issuer:string,not_valid_before:time,not_valid_after:time,key_alg:string,sig_alg:string,key_type:string,key_length:uint64,exponent:string,curve:string},san:{dns:[string],uri:[string],email:[string],ip:[ip]},basic_constraints:{ca:bool,path_len:uint64},host_cert:bool,client_cert:bool,_write_ts:time}>
}|
values nest_dotted(this)
| switch has(_path)
case true (
switch (_path in zeek_log_types)
case true (
values {_original: this, _shaped: shape(this, zeek_log_types[_path])}
| switch has_error(_shaped)
case true (
values error({msg: "shaper error(s): see inner error value(s) for details", _original, _shaped})
)
case false (
values {_original, _shaped}
| switch _crop_records
case true (
put _cropped := crop(_shaped, zeek_log_types[_shaped._path])
| switch (_cropped == _shaped)
case true ( values _shaped )
case false (
values {_original, _shaped, _cropped}
| switch _error_if_cropped
case true (
values error({msg: "shaper error: one or more fields were cropped", _original, _shaped, _cropped})
)
case false ( values _cropped )
)
)
case false ( values _shaped )
)
)
case false (
values error({msg: "shaper error: _path '" + _path + "' is not a known zeek log type in shaper config", _original: this})
)
)
case false (
values error({msg: "shaper error: input record lacks _path field", _original: this})
)
Configurable Options
The shaper begins with some configurable boolean constants that control how the shaper will behave when the JSON data does not precisely match the Zeek type definitions.
-
_crop_records(default:true) - Fields in the JSON records whose names are not referenced in the type definitions will be removed. If set tofalse, such a field would be maintained and assigned an inferred type. -
_error_if_cropped(default:true) - If such a field is cropped, the original input record will be wrapped inside anerrorvalue along with the shaped and cropped variations.
At these default settings, the shaper is well-suited for an iterative workflow
with a goal of establishing full coverage of the JSON data with rich types.
For instance, the has_error function
can be applied on the shaped output and any error values surfaced will point
to fields that can be added to the type definitions in the shaper.
Leading Type Definitions
The next three lines define types that are referenced further below in the type definitions for the different Zeek events.
type port=uint16;
type zenum=string;
type conn_id={orig_h:ip,orig_p:port,resp_h:ip,resp_p:port};
The port and zenum types are described further in the
Zeek Type Compatibility doc.
The conn_id type will just save us from having to repeat these fields
individually in the many Zeek record types that contain an embedded id
record.
Type Definitions Per Zeek Log _path
The bulk of this shaper consists of detailed per-field data type
definitions for each record in the default set of JSON logs output by Zeek.
These type definitions reference the types we defined above, such as port
and conn_id. The syntax for defining primitive and complex types follows the
relevant sections of the Super (SUP) format
specification.
...
"conn": <conn={_path:string,ts:time,uid:string,id:conn_id,proto:zenum,service:string,duration:duration,orig_bytes:uint64,resp_bytes:uint64,conn_state:string,local_orig:bool,local_resp:bool,missed_bytes:uint64,history:string,orig_pkts:uint64,orig_ip_bytes:uint64,resp_pkts:uint64,resp_ip_bytes:uint64,tunnel_parents:|[string]|,_write_ts:time}>,
"dce_rpc": <dce_rpc={_path:string,ts:time,uid:string,id:conn_id,rtt:duration,named_pipe:string,endpoint:string,operation:string,_write_ts:time}>,
...
Note
See the role of
_pathfor important details if you’re using Zeek’s built-in ASCII logger rather than the JSON Streaming Logs package.
SuperSQL Pipeline
The shaper ends with a pipeline that stitches together everything we’ve defined so far.
values nest_dotted(this)
| switch has(_path) (
case true => switch (_path in zeek_log_types) (
case true => values {_original: this, _shaped: shape(zeek_log_types[_path])}
| switch has_error(_shaped) (
case true => values error({msg: "shaper error(s): see inner error value(s) for details", _original, _shaped})
case false => values {_original, _shaped}
| switch _crop_records (
case true => put _cropped := crop(_shaped, zeek_log_types[_shaped._path])
| switch (_cropped == _shaped) (
case true => values _shaped
case false => values {_original, _shaped, _cropped}
| switch _error_if_cropped (
case true => values error({msg: "shaper error: one ore more fields were cropped", _original, _shaped, _cropped})
case false => values _cropped
)
)
case false => values _shaped
)
)
case false => values error({msg: "shaper error: _path '" + _path + "' is not a known zeek log type in shaper config", _original: this})
)
case false => values error({msg: "shaper error: input record lacks _path field", _original: this})
)
Picking this apart, it transforms each record as it’s being read in several steps.
-
The nest_dotted function reverses the Zeek JSON logger’s “flattening” of nested records, e.g., how it populates a field named
id.orig_hrather than creating a fieldidwith sub-fieldorig_hinside it. Restoring the original nesting now gives us the option to reference the embedded record namedidin SuperSQL and access the entire 4-tuple of values, but still access the individual values using the same dotted syntax likeid.orig_hwhen needed. -
The switch operator is used to flag any problems encountered when applying the shaper logic, e.g.,
- An incoming Zeek JSON record has a
_pathvalue for which the shaper lacks a type definition. - A field in an incoming Zeek JSON record is located in our type definitions but cannot be successfully cast to the target type defined in the shaper.
- An incoming Zeek JSON record has additional field(s) beyond those in the target type definition and the configurable options are set such that this should be treated as an error.
- An incoming Zeek JSON record has a
-
Each
shapefunction TODO: delete call applies an appropriate type definition based on the nature of the incoming Zeek JSON record. The logic ofshapeincludes:- For any fields referenced in the type definition that aren’t present in
the input record, the field is added with a
nullvalue. - Each field in the input record is cast to the corresponding type of the field of the same name in the type definition.
- The fields in the input record are ordered to match the order in which they appear in the type definition.
- For any fields referenced in the type definition that aren’t present in
the input record, the field is added with a
Invoking the Shaper From super
A shaper is typically invoked via the -I option of super.
For example, if we assume this input file weird.json
{
"_path": "weird",
"_write_ts": "2018-03-24T17:15:20.600843Z",
"ts": "2018-03-24T17:15:20.600843Z",
"uid": "C1zOivgBT6dBmknqk",
"id.orig_h": "10.47.1.152",
"id.orig_p": 49562,
"id.resp_h": "23.217.103.245",
"id.resp_p": 80,
"name": "TCP_ack_underflow_or_misorder",
"notice": false,
"peer": "zeek"
}
applying the reference shaper via
super -S -I shaper.spq weird.json
produces
{
_path: "weird",
ts: 2018-03-24T17:15:20.600843Z,
uid: "C1zOivgBT6dBmknqk",
id: {
orig_h: 10.47.1.152,
orig_p: 49562::(port=uint16),
resp_h: 23.217.103.245,
resp_p: 80::port
}::=conn_id,
name: "TCP_ack_underflow_or_misorder",
addl: null::string,
notice: false,
peer: "zeek",
source: null::string,
_write_ts: 2018-03-24T17:15:20.600843Z
}::=weird
If working in a directory containing many JSON logs, the reference shaper can be applied to all the records they contain and output them all in a single Super Binary (BSUP) file as follows:
super -I shaper.spq *.log > /tmp/all.bsup
If you wish to apply the shaper and then perform additional
operations on the richly-typed records, the query on the command line
should begin with a |, as this appends it to the pipeline at the bottom of
the shaper from the included file.
For example, to see a SUP representation of just the errors that may have come from attempting to shape all the logs in the current directory:
super -S -I shaper.spq -c '| has_error(this)' *.log
Importing Shaped Data Into Zui
If you wish to shape your Zeek JSON data in Zui,
drag the files into the app and then paste the contents of the
shaper.spq shown above into the
Shaper Editor of the Preview & Load
screen.
Contact us!
If you’re having difficulty, interested in shaping other data sources, or just have feedback, please join our public Slack and speak up or open an issue. Thanks!
