Query composition

The Fauna Query Language is a functional language that is highly composable. This section describes these aspects of the language and how to take advantage of them.

Functional programming

Functional programming refers to the construction of programs by applying and composing functions.

A function is a list of instructions that perform a specific task, packaged as a single unit. The function can be used wherever the set of instructions needs to be executed.

Most built-in FQL functions are pure functions:

  • Given a specific set of inputs, the same result is returned, no matter how often the function is called. This is the same property of many mathematical functions. For example, adding 2 to 1 always results in 3.

  • Pure functions have no side effects, so there are no changes to any of a function’s input values, or to the database.

Functions that write to the database cause side effects, so the built-in write functions are not pure functions.

Composability

Composition in programming languages refers to the ability to create complex behavior by aggregating simpler behavior.

For Unix-like systems (e.g. Linux, macOS), one example of composability is terminal’s implementation of pipes. A pipe allows the output of one program to be fed into another program.

For example, to show the contents of a text file, you could run:

cat myfile.txt
1
2
3
4
5

To show just the first two lines of the text file, you could run:

cat myfile.txt | head -2
1
2

The cat program knows how to read a file and show its content. The head program knows how to just display a specific number of lines of text, regardless of where they came from. We combined the two programs with a "pipe", which is the vertical bar character |.

In a composed command, the pipe says "take the output from the program on the left and pass it to the program on the right". This feature enables any two programs that can operate on common data to be combined to achieve a goal that neither could be used to accomplish on their own.

For functional languages, like FQL, composability means that the result of a function can be used in place of a specified value so long as the function returns the same type of value.

FQL queries

Every Fauna query is made up of a single expression. An expression can be one of FQL’s data types, or a function call that is itself an expression and can involve subordinate expressions. At any point in a query, where a value can be used, an FQL expression that returns the same type of value can be used in place of the value.

"Data type" expressions

Here are some examples of simple expressions:

  1. A numeric expression:

    client.query(
      12345
    )
    .then((ret) => console.log(ret))
    .catch((err) => console.error(
      'Error: [%s] %s: %s',
      err.name,
      err.message,
      err.errors()[0].description,
    ))
    12345
    Query metrics:
    •    bytesIn:   5

    •   bytesOut:  18

    • computeOps:   1

    •    readOps:   0

    •   writeOps:   0

    •  readBytes:   0

    • writeBytes:   0

    •  queryTime: 0ms

    •    retries:   0

  2. A string expression:

    client.query(
      'FQL is a functional language!'
    )
    .then((ret) => console.log(ret))
    .catch((err) => console.error(
      'Error: [%s] %s: %s',
      err.name,
      err.message,
      err.errors()[0].description,
    ))
    FQL is a functional language!
    Query metrics:
    •    bytesIn:  31

    •   bytesOut:  44

    • computeOps:   1

    •    readOps:   0

    •   writeOps:   0

    •  readBytes:   0

    • writeBytes:   0

    •  queryTime: 1ms

    •    retries:   0

Function expressions

Calling an FQL function executes that function, and passes any provided arguments as parameters to the function. When the function execution is completed, there is a return value called the result, or an error occurs that terminates the query containing the function call.

For example:

client.query(
  q.Add(1, 2)
)
.then((ret) => console.log(ret))
.catch((err) => console.error(
  'Error: [%s] %s: %s',
  err.name,
  err.message,
  err.errors()[0].description,
))
3
Query metrics:
  •    bytesIn:  13

  •   bytesOut:  14

  • computeOps:   1

  •    readOps:   0

  •   writeOps:   0

  •  readBytes:   0

  • writeBytes:   0

  •  queryTime: 1ms

  •    retries:   0

The Add function is called with the arguments 1 and 2, the function is executed, and its return value (or result) is 3.

The result of evaluating an expression is always one of Fauna’s Data types. That means that expressions can be used as parameters to other functions. In the following example, the result of calling Add is used in a separate Add call:

client.query(
  q.Add(4, q.Add(1, 2))
)
.then((ret) => console.log(ret))
.catch((err) => console.error(
  'Error: [%s] %s: %s',
  err.name,
  err.message,
  err.errors()[0].description,
))
7
Query metrics:
  •    bytesIn:  25

  •   bytesOut:  14

  • computeOps:   1

  •    readOps:   0

  •   writeOps:   0

  •  readBytes:   0

  • writeBytes:   0

  •  queryTime: 0ms

  •    retries:   0

Add should only work with numeric parameters (arithmetic addition doesn’t work, for example, with Strings). FQL evaluates the inner Add expression when it encounters it, before it can complete evaluation of the outer Add expression, so by the time the outer Add is evaluated, it is working with numeric parameters.

Combining expressions

You can combine expressions, even if they are unrelated, in two ways:

  • The Do function takes a list of expressions, evaluates them in order, and returns the result from the last expression:

    client.query(
      q.Do(
        q.Concat(['The', 'answer', 'is'], ' '),
        q.Multiply(6, 7),
      )
    )
    .then((ret) => console.log(ret))
    .catch((err) => console.error(
      'Error: [%s] %s: %s',
      err.name,
      err.message,
      err.errors()[0].description,
    ))
    42
    Query metrics:
    •    bytesIn:  76

    •   bytesOut:  15

    • computeOps:   1

    •    readOps:   0

    •   writeOps:   0

    •  readBytes:   0

    • writeBytes:   0

    •  queryTime: 1ms

    •    retries:   0

  • An Array can be used to provide a list of expressions, which are evaluated in order, and the resulting array contains the results from each expression:

    client.query(
      [
        q.Concat(['The', 'answer', 'is'], ' '),
        q.Multiply(6, 7),
      ]
    )
    .then((ret) => console.log(ret))
    .catch((err) => console.error(
      'Error: [%s] %s: %s',
      err.name,
      err.message,
      err.errors()[0].description,
    ))
    [ 'The answer is', 42 ]
    Query metrics:
    •    bytesIn:  69

    •   bytesOut:  33

    • computeOps:   1

    •    readOps:   0

    •   writeOps:   0

    •  readBytes:   0

    • writeBytes:   0

    •  queryTime: 1ms

    •    retries:   0

Conditional logic

FQL provides several functions that allow your queries to perform conditional logic:

  • The If function tests a condition and either executes the true or false expression depending on the result of evaluating the condition:

    client.query(
      q.If(
        true,
        'The condition evaluates as true',
        'The condition evaluates as false',
      )
    )
    .then((ret) => console.log(ret))
    .catch((err) => console.error(
      'Error: [%s] %s: %s',
      err.name,
      err.message,
      err.errors()[0].description,
    ))
    The condition evaluates as true
    Query metrics:
    •    bytesIn:  76

    •   bytesOut:  37

    • computeOps:   1

    •    readOps:   0

    •   writeOps:   0

    •  readBytes:   0

    • writeBytes:   0

    •  queryTime: 0ms

    •    retries:   0

  • In order to build conditional expressions, FQL provides the following functions:

    And

    Returns true if all values are true.

    ContainsField

    Returns true when a specific field is found in a document.

    ContainsPath

    Returns true when a document contains a value at the specified path.

    ContainsValue

    Returns true when a specific value is found in a document.

    Equals

    Returns true if all values are equivalent.

    Exists

    Returns true if a document has an event at a specific time.

    GT

    Returns true if each value is greater than all following values.

    GTE

    Returns true if each value is greater than, or equal to, all following values.

    LT

    Returns true if each value is less than all following values.

    LTE

    Returns true if each value is less than, or equal to, all following values.

    Not

    Returns the opposite of a boolean expression.

    Or

    Returns true if any value is true.

A very common use case is to implement an "upsert", where a document should be created if it does not exist or updated when it does:

client.query(
  q.If(
    q.Exists(q.Ref(q.Collection('Letters'), '101')),
    q.Update(
      q.Ref(q.Collection('Letters'), '101'),
      {
        data: {
          letter: 'A',
        },
      },
    ),
    q.Create(
      q.Ref(q.Collection('Letters'), '101'),
      {
        data: {
          letter: 'A',
        },
      },
    )
  )
)
.then((ret) => console.log(ret))
.catch((err) => console.error(
  'Error: [%s] %s: %s',
  err.name,
  err.message,
  err.errors()[0].description,
))
{
  ref: Ref(Collection("Letters"), "101"),
  ts: 1649427736990000,
  data: { letter: 'A', extra: 'First' }
}
Query metrics:
  •    bytesIn:  295

  •   bytesOut:  184

  • computeOps:    1

  •    readOps:    1

  •   writeOps:    1

  •  readBytes:   71

  • writeBytes:   83

  •  queryTime: 25ms

  •    retries:    0

Use Let to gather intermediate results

When composing FQL queries, you may encounter the need to use the result of a particular expression multiple times. The Let function allows you to give names to the results of expressions, and then you can use those named values while composing new named values or within the result expression.

For example, we can rewrite the "upsert" query provided above using Let to reduce the repetitive elements, which makes the query more concise and easier to reason about:

client.query(
  q.Let(
    {
      reference: q.Ref(q.Collection('Letters'), '101'),
      data_field: { letter: 'A' },
    },
    q.If(
      q.Exists(q.Var('reference')),
      q.Update(q.Var('reference'), { data: q.Var('data_field') }),
      q.Create(q.Var('reference'), { data: q.Var('data_field') })
    )
  )
)
.then((ret) => console.log(ret))
.catch((err) => console.error(
  'Error: [%s] %s: %s',
  err.name,
  err.message,
  err.errors()[0].description,
))
{
  ref: Ref(Collection("Letters"), "101"),
  ts: 1649427716790000,
  data: { letter: 'A', extra: 'First' }
}
Query metrics:
  •    bytesIn:  327

  •   bytesOut:  184

  • computeOps:    1

  •    readOps:    1

  •   writeOps:    1

  •  readBytes:  139

  • writeBytes:   83

  •  queryTime: 12ms

  •    retries:    0

The Let function allows you to gather intermediate results in a complex expression, and then access those results by name where you need them.

Composing queries in an application

Each FQL query is sent to the Fauna service and evaluated only when the client.query driver function is executed in your client program. Until that happens, you can assign FQL expressions to variables and then use those variables in your query.

For example:

var add = q.Add(4, q.Add(1, 2))
client.query(
  add
)
.then((ret) => console.log(ret))
.catch((err) => console.error(
  'Error: [%s] %s: %s',
  err.name,
  err.message,
  err.errors()[0].description,
))
7
Query metrics:
  •    bytesIn:  25

  •   bytesOut:  14

  • computeOps:   1

  •    readOps:   0

  •   writeOps:   0

  •  readBytes:   0

  • writeBytes:   0

  •  queryTime: 0ms

  •    retries:   0

Here is a more sophisticated example that combines host language functions and a host language mathematical constant with FQL functions, used both inside and outside of the host language functions:

function areaRectangle (width, height) {
  return q.Multiply(width, height)
}

function areaCircle (radius) {
  return q.Multiply(radius, radius, Math.PI)
}

var area1 = areaRectangle(3, 4)
var area2 = areaCircle(5)
client.query(
  q.Format('The total area is %.2f', q.Add(area1, area2))
)
.then((ret) => console.log(ret))
.catch((err) => console.error(
  'Error: [%s] %s: %s',
  err.name,
  err.message,
  err.errors()[0].description,
))
The total area is 90.54
Query metrics:
  •    bytesIn: 110

  •   bytesOut:  38

  • computeOps:   1

  •    readOps:   0

  •   writeOps:   0

  •  readBytes:   0

  • writeBytes:   0

  •  queryTime: 1ms

  •    retries:   0

This facility means that your application logic can compose complex queries by establishing simple expressions and combining them to compose more complex expressions. Those expressions can serve as queries themselves, or be used as subordinate expressions in even more complex queries. There are limits (see below) that constrain the maximum complexity. Within those limits, you can make your queries as complex as you need.

Caveats

  • Fauna queries are limited to 16 MB in size.

  • For Fauna "system schemas", including databases, collections, and indexes, you cannot both create a system schema and use it in a single transaction. However, you can create multiple system schemas in a single transaction, and the next query can use any/all of the now-existing system schemas.

  • The JavaScript driver supports syntactic sugar for Lambda functions:

    // standard FQL
    q.Lambda(["x", "y"], q.Add(q.Var("x"), q.Var("y")))
    
    // syntactic sugar
    (x, y) => { q.Add(x, y) }

    Using syntactic sugar, the driver translates named variables into Var expressions directly, with no attention to variable scope. If your query involves nested Lambda functions that use identically-named variables, the inner usage refers to the same variable as the outer usage.

Is this article helpful? 

Tell Fauna how the article can be improved:
Visit Fauna's forums or email docs@fauna.com

Thank you for your feedback!