Praxsuite

Relations

Vincent Depassier · August 29, 2026

PraxQL — Nested Relations

Instead of making several API calls to fetch related data, PraxQL lets you express relations inline in select. The engine resolves them in batches, so there is no N+1 problem.


How relations work

When a column is of type Table, it already names the table it points at — /schema reports that as pointsTo. PraxQL uses it to work out the join on its own. You declare the relation in select as an object instead of a string:

{
  "refs": {
    "Customers": "aaa-guid",
    "Orders":    "bbb-guid"
  },
  "query": {
    "from": "Customers",
    "select": [
      "Name",
      "Email",
      {
        "table": "Orders",
        "select": ["Total", "Date", "Status"]
      }
    ]
  }
}
{
  "data": [
    {
      "Name": "Acme Corp",
      "Email": "hello@acme.com",
      "Orders": [
        { "Total": 1500.00, "Date": "2026-03-01", "Status": "Completed" },
        { "Total": 800.00,  "Date": "2026-04-15", "Status": "Pending"   }
      ]
    }
  ]
}

Related records nest as arrays inside each parent row. They are never flattened into repeated parent rows, so you do not have to de-duplicate anything.


Every field in a relation object

Field

Required

Description

table

yes

Alias of the related table. Must exist in refs.

select

no

Columns to return from this table

where

no

Filters applied to the related rows

orderBy

no

Sort for the related rows

limit

no

Max related rows per parent row

relations

no

Further nested relations

on

no

Explicit join columns, overriding auto-detection

joinType

no

"LEFT" (default) or "INNER"


Filtering and limiting a relation

{
  "table": "Orders",
  "select": ["Total", "Date", "Status"],
  "where": [
    { "field": "Status", "op": "neq", "value": "Cancelled" }
  ],
  "orderBy": [{ "field": "Date", "dir": "desc" }],
  "limit": 5
}

At most five non-cancelled orders per customer, newest first. Note that limit here is per parent row, not a total across the response.


Deep nesting

Nest relations inside relations with the relations key:

{
  "refs": {
    "Customers":  "aaa-guid",
    "Orders":     "bbb-guid",
    "OrderItems": "ccc-guid"
  },
  "query": {
    "from": "Customers",
    "select": [
      "Name",
      {
        "table": "Orders",
        "select": ["Total", "Date"],
        "relations": [
          {
            "table": "OrderItems",
            "select": ["ProductName", "Quantity", "UnitPrice"]
          }
        ]
      }
    ]
  }
}
{
  "data": [
    {
      "Name": "Acme Corp",
      "Orders": [
        {
          "Total": 1500.00,
          "Date": "2026-03-01",
          "OrderItems": [
            { "ProductName": "Widget A", "Quantity": 10, "UnitPrice": 150.00 }
          ]
        }
      ]
    }
  ]
}

Auto-detected joins

PraxQL works out the join condition when either side points at the other:

  • the parent table has a Table column whose pointsTo is the child table, or

  • the child table has a Table column whose pointsTo is the parent table.

For a normal relationship column you never write on — the schema already knows the connection.


Explicit joins

When auto-detection picks the wrong column, or the relationship is not expressed as a Table column at all, name the columns yourself:

{
  "table": "Invoices",
  "on": { "left": "ID", "right": "CustomerRef" },
  "joinType": "INNER",
  "select": ["InvoiceNumber", "Amount"]
}

Field

Description

left

Column in the parent table

right

Column in the child table

joinType

LEFT keeps parent rows with no children; INNER drops them


Depth limit

There are two numbers here and they are easy to confuse.

5 is the platform ceiling. Nothing raises it.

2 is what a newly created table scope actually gets — MaxRelationDepthOverride starts at 2. So a fresh credential nests two levels deep, not five, until somebody widens it. An override can be moved anywhere up to 5, never past it.

Going past the effective limit returns 403 SCOPE_VIOLATION. If a query that works in the Playground fails from your app, this is the first thing to check.

The related table must also allow relations at all: AllowRelations is on by default on a new scope, but it can be switched off.


Why it is not N+1

PraxQL does not issue one query per parent row:

  1. The primary query runs and returns the parent rows.

  2. Their ids are collected.

  3. One batched sub-query fetches every related row for that whole set of ids at once.

  4. Results are stitched together before the response is built.

Fetching 100 customers with their orders is two queries, not 101. Each extra nesting level adds one query, not one per row.


Every table must be in refs

Every table used in a relation — including deeply nested ones — must be declared in the top-level refs with its GUID:

{
  "refs": {
    "Customers":  "aaa-guid",
    "Orders":     "bbb-guid",
    "OrderItems": "ccc-guid"
  }
}

A table referenced in a relation but missing from refs returns 400 INVALID_QUERY. This is deliberate: refs is the complete list of what a request may touch, so it can be checked against your scopes before any work is done.