Check out v4 of the Fauna CLI

v4 of the Fauna CLI is now GA.

The new version introduces enhancements to the developer experience, including an improved authentication workflow. To get started, check out the CLI v4 quick start.

Migrating from v3 of the CLI? See the CLI migration guide.

Work with multiple Sets

This guide covers common patterns for performing SQL-like set operations, such as unions, joins, and intersections, in FQL. You can use these operations to combine and filter Sets of collection documents.

Unions

Use set.concat() to combine two Sets into a single Set. This is similar to a UNION clause in SQL.

// Setup: Get frozen and produce `Category` documents.
let frozenCat = Category.byName("frozen").first()
let produceCat = Category.byName("produce").first()

// Get Sets of related `Product` documents for each category.
let frozenDocs = Product.byCategory(frozenCat)
let produceDocs = Product.byCategory(produceCat)

// Use `concat()` to combine the previous
// `Product` document Sets. Project each
// product's name and category.
frozenDocs.concat(produceDocs) {
  name: .name,
  category: .category!.name
}
// The combined Set contains all `Product` documents
// in the frozen and produce categories.
{
  data: [
    {
      name: "pizza",
      category: "frozen"
    },
    ...
    {
      name: "cilantro",
      category: "produce"
    }
  ]
}

Chain set.concat()

You can chain set.concat() to combine three or more Sets. The following example expands on the previous one:

// Setup: Get frozen, produce, and party `Category` documents.
let frozenCat = Category.byName("frozen").first()
let produceCat = Category.byName("produce").first()
let partyCat = Category.byName("party").first()

// Get Sets of related `Product` documents for each category.
let frozenDocs = Product.byCategory(frozenCat)
let produceDocs = Product.byCategory(produceCat)
let partyDocs = Product.byCategory(partyCat)

// Chain `concat()` expressions to combine the previous
// `Product` document Sets. Project each
// product's name and category.
frozenDocs
  .concat(produceDocs)
  .concat(partyDocs) {
    name: .name,
    category: .category!.name
  }
// The combined Set contains all `Product` documents
// in the frozen, produce, and party categories.
{
  data: [
    {
      name: "pizza",
      category: "frozen"
    },
    {
      name: "avocados",
      category: "produce"
    },
    ...
    {
      name: "taco pinata",
      category: "party"
    }
  ]
}

Use set.flatMap()

When combining several Sets, you can improve readability by using set.flatMap() to iterate through the Sets:

// Start with an Array of category names.
["frozen", "produce", "party"]
  // Convert the Array to a Set. `flatMap()` operates on Sets.
  .toSet()
  // Use `flatMap()` to process each category name
  // and combine the resulting Sets. `flatMap()` will:
  // 1. Apply the given function to each element of the Set.
  // 2. Collect the results in a Set,
  //    with a nested child Set for each element.
  // 3. Flatten the resulting Set by one level,
  //    resulting in a single, flat Set.
  .flatMap(name => {
    // Get the `Category` document for each category name.
    let category = Category.byName(name).first()

    // Get Sets of related `Product` documents for each category.
    // Each Set is included in the flattened results, creating
    // a single Set of products across all listed categories.
    // Project each product's name and category.
    Product.byCategory(category)
}) {
      name: .name,
      category: .category!.name
    }
// The combined Set contains all `Product` documents
// in the listed categories.
{
  data: [
    {
      name: "pizza",
      category: "frozen"
    },
    {
      name: "avocados",
      category: "produce"
    },
    ...
    {
      name: "taco pinata",
      category: "party"
    }
  ]
}

Joins

In Fauna, you can use document references to model relationships between documents in different collections. You can use projection to traverse the references. This is similar to performing a JOIN in SQL.

// Get a Set of `Order` documents with a `status` of `cart`.
// In production, use an index instead of `where()` for
// better performance.
let orders = Order.where(.status == "cart")

// Projects fields in the `Order` documents.
orders {
  // Traverses the `Customer` document reference in
  // the `customer` field.
  customer {
    id,
    name,
    email
  }
}
{
  data: [
    {
      customer: {
        id: "111",
        name: "Alice Appleseed",
        email: "alice.appleseed@example.com"
      }
    },
    {
      customer: {
        id: "222",
        name: "Bob Brown",
        email: "bob.brown@example.com"
      }
    }
  ]
}

Use set.map() for transformations

You can also use set.map() to extract and transform field values from referenced documents.

// Get a Set of `Order` documents with a `status` of `cart`.
// In production, use an index instead of `where()` for
// better performance.
let orders: Any = Order.where(.status == "cart")
  // Uses `map()` to iterate through each document in the Set
  // and extract the `customer` field from each `Order` document.
  // Then project fields from the `customer` field's referenced
  // `Customer` document.
 orders.map(order => order.customer) {
    id,
    name,
    email
  }
{
  data: [
    {
      id: "111",
      name: "Alice Appleseed",
      email: "alice.appleseed@example.com"
    },
    {
      id: "222",
      name: "Bob Brown",
      email: "bob.brown@example.com"
    }
  ]
}

Use set.flatMap() for *-to-many relationships

For one-to-many or many-to-many relationships, you can use set.flatMap() to extract field values from referenced documents and flatten the resulting Set by one level:

// Get a Set of all `Customer` documents.
Customer.all()
  // Use `flatMap()` to iterate through each document in the Set
  // and extract fields from:
  // - The `Order` document referenced in the `order` field.
  // - The `Customer` document reference nested in the `order` field.
  // Then flatten the resulting Set.
  .flatMap(customer => {
    let orders = customer.orders
    orders {
      id,
      total,
      status,
      customer {
        id,
        name
      }
    }
  })
{
  data: [
    {
      id: "408922916844994633",
      total: 5392,
      status: "cart",
      customer: {
        id: "111",
        name: "Alice Appleseed"
      }
    },
    {
      id: "408922916872257609",
      total: 1880,
      status: "cart",
      customer: {
        id: "222",
        name: "Bob Brown"
      }
    }
  ]
}

Intersections

An intersection returns elements that exist in multiple Sets, similar to an INTERSECT clause in SQL.

Filter using where()

To perform intersections in FQL, start with the most selective expression and filter the resulting Sets using set.where().

For the best performance, use a covered index call as the first expression and only filter on covered values.

// Get the produce category.
let produce = Category.byName("produce").first()

// Use the most selective index, `byName()`, first.
// Then filter with covered values.
// In this example, `category`, `price`, and `name`
// should be covered by the `byName()` index.
Product.byName("limes")
  .where(.category == produce)
  .where(.price < 500) {
    name,
    category: .category!.name,
    price
  }
Avoid filtering using includes()

In most cases, you should avoid using set.includes() to intersect results, including results from covered index calls.

set.includes() is a linear operation. The compute costs consumed by repeatedly iterating through results will typically exceed the read costs of directly reading documents.

For example, the following query is inefficient and will likely incur high compute costs:

// Each variable is a covered index call
let limes = Product.byName("limes")
let produce = Product.byCategory(Category.byName("produce").first()!)
let under5 = Product.sortedByPriceLowToHigh({ to: 500 })

// Uses `includes()` to intersect the results from
// covered index calls
limes.where(doc => produce.includes(doc))
     .where(doc => under5.includes(doc))

Instead, use a covered index call and set.where() to filter the results as outlined in Filter using where(). For example, you can rewrite the previous query as:

// Start with a covered index call.
Product.byName("limes")
  // Layer on filters using `where()`
  .where(doc => doc.category == Category.byName("produce").first()!)
  .where(doc => doc.price < 500 )

Filter using query composition

The Fauna client drivers compose queries using FQL template strings. You can interpolate variables, including other FQL template strings, into the template strings to compose dynamic queries.

You can use FQL string templates to reuse filters across queries. For example, using the JavaScript driver:

// Start with base query
let query = fql`Product.byName("limes")`

// Define filters to apply
const filters = [
  fql`.where(doc => doc.category == Category.byName("produce").first()!)`,
  fql`.where(doc => doc.price < 500)`
]

// Compose the final query
filters.forEach(filter => {
  query = fql`${query}${filter}`
})

For more complex use cases, you can build a priority map of indexes and filters to automatically select the most performant approach:

const indexMap = {
  by_name: (name: string) => fql`Product.byName(${name})`,
  by_category: (cat: string) =>
    fql`Product.byCategory(Category.byName(${cat}).first()!)`
}

const filterMap = {
  by_name: (name: string) => fql`.where(.name == ${name})`,
  by_category: (cat: string) =>
    fql`.where(.category == Category.byName(${cat}).first()!)`
}
Template: Dynamic filtering with advanced query composition

Complex applications may need to handle arbitrary combinations of search criteria. In these cases, you can use query composition to dynamically apply indexes and filters to queries.

The following template uses query composition to:

  • Automatically select the most selective index

  • Apply remaining criteria as filters in priority order

  • Support both index-based and filter-based search patterns

The template uses TypeScript and the JavaScript driver. A similar approach can be used with any Fauna client driver.

/**
 * A Javascript object with a sorted list of indexes or filters.
 *
 * Javascript maintains key order for objects.
 * Sort items in the map from most to least selective.
 */
type QueryMap = Record<string, (...args: any[]) => Query>

/** Object to represent a search argument.
 *
 * Contains the name of the index to use and the arguments
 * to pass to it.
 *
 * Example:
 * { name: "by_name", args: ["limes"] }
 * { name: "range_price", args: [{ from: 100, to: 500 }] }
 */
type SearchTerm = {
  name: string
  args: any[]
}

/**
 * Composes a query by prioritizing the most selective index and then
 * applying filters.
 *
 * @param default_query - The initial query to which indexes and filters are applied.
 * @param index_map - A map of index names to functions that generate query components.
 * @param filter_map - A map of filter names to functions that generate query components.
 * @param search_terms - An array of search terms that specify the type and arguments
 *                       for composing the query.
 * @returns The composed query after applying all relevant indices and filters.
 */
const build_search = (
  default_query: Query,
  index_map: QueryMap,
  filter_map: QueryMap,
  search_terms: SearchTerm[]
): Query => {
  const _search_terms = [...search_terms]

  // Initialize a default query. Used if no other indexes are applicable.
  let query: Query = default_query

  // Iterate through the index map, from most to least selective.
  build_index_query: for (const index_name of Object.keys(
    index_map
  )) {
    // Iterate through each search term to check if it matches the highest priority index.
    for (const search_term of _search_terms) {
      // If a match is found, update the query. Then remove the search term from the
      // list and break out of the loop.
      if (index_name === search_term.name) {
        query = index_map[search_term.name](...search_term.args)
        _search_terms.splice(_search_terms.indexOf(search_term), 1)
        break build_index_query
      }
    }
  }

  // Iterate through the filter map, from most to least selective.
  for (const filter_name of Object.keys(filter_map)) {
    // Iterate through each search term to check if it matches the highest priority filter.
    for (const search_term of _search_terms) {
      // If a match is found, update the query. Then remove the search term from the list.
      if (filter_name === search_term.name) {
        const filter = filter_map[search_term.name](...search_term.args)
        query = fql`${query}${filter}`
        _search_terms.splice(_search_terms.indexOf(search_term), 1)
      }
    }
  }

  // If there are remaining search terms, you can't build the full query.
  if (_search_terms.length > 0) {
    throw new Error("Unable to build query")
  }

  return query
}

The following example implements the template using the Fauna Dashboard's demo data:

// Implementation of `index_map` from the template.
// Sort items in the map from most to least selective.
const product_index_priority_map: QueryMap = {
  by_order: (id: string) =>
    fql`Order.byId(${id})!.items.map(.product!)`,
  by_name: (name: string) => fql`Product.byName(${name})`,
  by_category: (category: string) =>
    fql`Product.byCategory(Category.byName(${category}).first()!)`,
  range_price: (range: { from?: number; to?: number }) =>
    fql`Product.sortedByPriceLowToHigh(${range})`,
}

// Implementation of `filter_map` from the template.
// Sort items in the map from most to least selective.
const product_filter_map: QueryMap = {
  by_name: (name: string) => fql`.where(.name == ${name})`,
  by_category: (category: string) =>
    fql`.where(.category == Category.byName(${category}).first()!)`,
  range_price: ({ from, to }: { from?: number; to?: number }) => {
    // Dynamically filter products by price range.
    if (from && to) {
      return fql`.where(.price >= ${from} && .price <= ${to})`
    } else if (from) {
      return fql`.where(.price >= ${from})`
    } else if (to) {
      return fql`.where(.price <= ${to})`
    }
    return fql``
  },
}

// Hybrid implementation of `index_map` and `filter_map` from the template.
// Combines filters and indexes to compose FQL query fragments.
// Sort items in the map from most to least selective.
const product_filter_with_indexes_map: QueryMap = {
  by_name: (name: string) =>
    fql`.where(doc => Product.byName(${name}).includes(doc))`,
  by_category: (category: string) =>
    fql`.where(doc => Product.byCategory(Category.byName(${category}).first()!).includes(doc))`,
  range_price: (range: { from?: number; to?: number }) =>
    fql`.where(doc => Product.sortedByPriceLowToHigh(${range}).includes(doc))`,
}

const order_id = (await client.query(fql`Order.all().first()!`))
  .data.id

const query = build_search(
  fql`Product.all()`,
  product_index_priority_map,
  product_filter_with_indexes_map,
  [
    // { type: "by", name: "name", args: ["limes"] },
    // { type: "by", name: "category", args: ["produce"] },
    { type: "range", name: "price", args: [{ to: 1000 }] },
    { type: "by", name: "order", args: [order_id] },
  ]
)

const res = await client.query(query)

Check for the existence of document references

When checking for the existence of document references, use set.take() to avoid unneeded document reads:

// Get a Set of `Customer` documents that have:
// - An address in "DC".
// - At least one order.
Customer
  // Filter customers by state.
  .where(.address.state == "DC")
  // Filter customers by `orders` field.
  // The `orders` field is a computed field containing a Set of
  // `Order` documents for the customer. `take(1)` ensures we only
  // read the first document to check for existence.
  .where(.orders.take(1).isEmpty() == false) {
    id,
    name,
    email
  }

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!