---
title: "Data Overview"
framework: javascript
version: "2.1.2"
---

# Data Overview

Data is provided to Studio using the `data` property.

Data is retrieved from data sources. A data source represents one or more tables of data.

When multiple tables are provided, [Relationships](#relationships) describe how the tables are linked.

There are two main types of data source:

- [Sync Data](https://www.ag-grid.com/studio/javascript/sync-data/) - for data already loaded in the application.
- [Async Data](https://www.ag-grid.com/studio/javascript/async-data/) - for data lazily loaded on demand.

See [Sharing & Caching Data](https://www.ag-grid.com/studio/javascript/sharing-caching-data/) to reuse one data source across multiple instances of Studio.

#### Single Data Source

```ts
import {
  AgReportState,
  AgStudioApi,
  AgStudioProperties,
  createStudio,
} from "ag-studio";

const initialState: AgReportState = {
  pages: [
    {
      id: "page1",
      widgets: {
        "1": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "medals.country" },
              { id: "medals.sport" },
              { id: "medals.gold", aggregation: "sum" },
              { id: "medals.silver", aggregation: "sum" },
              { id: "medals.bronze", aggregation: "sum" },
              { id: "medals.total", aggregation: "sum" },
            ],
          },
        },
        "2": {
          type: "column-chart-grouped",
          dataMapping: {
            categoryKey: [{ id: "medals.country" }],
            valueKey: [
              { id: "medals.gold", aggregation: "sum" },
              { id: "medals.silver", aggregation: "sum" },
              { id: "medals.bronze", aggregation: "sum" },
            ],
            tooltipKey: [],
          },
        },
      },
      widgetLayout: {
        "1": {
          xTrack: 0,
          yTrack: 0,
          xSpan: 24,
          ySpan: 16,
        },
        "2": {
          xTrack: 0,
          yTrack: 16,
          xSpan: 24,
          ySpan: 16,
        },
      },
    },
  ],
  selectedPageId: "page1",
  panels: {
    filters: {
      collapsed: true,
    },
    edit: {
      collapsed: true,
    },
  },
};

const studioProperties: AgStudioProperties = {
  mode: "edit",
  initialState,
};

let studioApi: AgStudioApi;

// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);

fetch("https://www.ag-grid.com/studio/example-assets/olympic-winners.json")
  .then((response) => response.json())
  .then((data) =>
    studioApi!.setProperty("data", { sources: [{ id: "medals", data }] }),
  );
```

[Live example: Single Data Source](https://www.ag-grid.com/studio/examples/data/single-data-source/typescript/)

The example above demonstrates configuring a single [Sync Data](https://www.ag-grid.com/studio/javascript/sync-data/).

```js
const studioProperties = {
    data: {
        sources: [{
            id: 'medals',
            data: [
                {
                    year: 2000,
                    sport: 'Swimming',
                    country: 'United States',
                    // ... other fields
                },
                // ... other rows
            ],
        }],
    },

    // other studio properties ...
}
```

## Relationships

When multiple tables are provided, if there are relationships between the data, then these should be provided alongside the data sources. This will allow fields from different tables to be displayed together in the same widget.

#### Multiple Data Sources

```ts
import {
  AgReportState,
  AgStudioApi,
  AgStudioProperties,
  createStudio,
} from "ag-studio";

const initialState: AgReportState = {
  pages: [
    {
      id: "page1",
      widgets: {
        "1": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "capitals.capital" },
              { id: "medals.sport" },
              { id: "medals.gold", aggregation: "sum" },
              { id: "medals.silver", aggregation: "sum" },
              { id: "medals.bronze", aggregation: "sum" },
              { id: "medals.total", aggregation: "sum" },
            ],
          },
        },
      },
      widgetLayout: {
        "1": {
          xTrack: 0,
          yTrack: 0,
          xSpan: 24,
          ySpan: 25,
        },
      },
    },
  ],
  selectedPageId: "page1",
  panels: {
    filters: {
      collapsed: true,
    },
    edit: {
      collapsed: true,
    },
  },
};

const studioProperties: AgStudioProperties = {
  mode: "edit",
  initialState,
};

let studioApi: AgStudioApi;

// setup Studio after the page has finished loading
const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
studioApi = createStudio(studioDiv, studioProperties);

fetch("https://www.ag-grid.com/studio/example-assets/olympic-winners.json")
  .then((response) => response.json())
  .then((data) =>
    studioApi!.setProperty("data", {
      sources: [
        { id: "medals", data },
        {
          id: "capitals",
          data: [
            { country: "United States", capital: "Washington, D.C." },
            { country: "Australia", capital: "Canberra" },
            { country: "Russia", capital: "Moscow" },
            { country: "China", capital: "Beijing" },
            { country: "Great Britain", capital: "London" },
          ],
        },
      ],
      relationships: [
        {
          id: "medals-capitals",
          source: {
            tableId: "medals",
            fieldId: "country",
          },
          target: {
            tableId: "capitals",
            fieldId: "country",
          },
          type: "many-to-one",
        },
      ],
    }),
  );
```

[Live example: Multiple Data Sources](https://www.ag-grid.com/studio/examples/data/multiple-data-sources/typescript/)

The example above demonstrates two tables, Medals and Capitals, linked together by country. Both tables are [Sync Data](https://www.ag-grid.com/studio/javascript/sync-data/).

```js
const studioProperties = {
    data: {
        sources: [{
            id: 'medals',
            data: [
                {
                    year: 2000,
                    sport: 'Swimming',
                    country: 'United States',
                    // ... other fields
                },
                // ... other rows
            ],
        }, {
            id: 'capitals',
            data: [
                {
                    country: 'United States',
                    capital: 'Washington, D.C.',
                    // ... other fields
                },
                // ... other rows
            ],
        }],
        relationships: [
            {
                id: 'medals-capitals',
                source: {
                    tableId: 'medals',
                    fieldId: 'country',
                },
                target: {
                    tableId: 'capitals',
                    fieldId: 'country',
                },
                type: 'many-to-one'
            },
        ],
    },

    // other studio properties ...
}
```

Normal relationships are defined using the `AgDataRelationDefinition` interface:

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | ID of the relationship. |
| `source` | `AgRelationField` |  | Source field. |
| `target` | `AgRelationField` |  | Target field. |
| `type` | `AgRelationType` |  | The cardinality of the relationship from the source field to the target field. |
| `acceptFanout` | `boolean` |  | Accept row duplication a query genuinely introduces by joining through this relationship - e.g. a `many-to-many` relationship onto a pre-aggregated table with no finer-grained key available. When `true`, a query whose only path to a shared dimension crosses this relationship executes via ordinary join-through instead of being rejected; downstream aggregates must account for the duplication. |

### Joining Dates

By default date fields are joined the same way as any other field, where matching rows are linked by exact date value.

It is also possible to join date fields of different granularities, or to override the granularity at which they are joined.

This is done by using a `::unit` field ID in the relationship, Studio derives a bucketed column from the base date field and uses it as the join key.

The following units are supported. The value column shows the integer each unit produces, which is what the join key is compared against.

| Unit | Value | Example |
| --- | --- | --- |
| `year` | Calendar year | `2024` |
| `quarter` | 1-4 | `1` = Jan-Mar `2` = Apr-Jun `3` = Jul-Sep `4` = Oct-Dec |
| `month` | 1-12 | `1` = January `12` = December |
| `monthOfQuarter` | 1-3 | Position within the quarter |
| `week` | 1-53 | ISO 8601 week number |
| `weekOfMonth` | 1-5 | Position within the month |
| `day` / `dayOfMonth` | 1-31 | Day of the month |
| `dayOfWeek` | 1-7 | `1` = Monday `7` = Sunday (ISO 8601) |
| `weekend` | 0 or 1 | `0` = weekday `1` = weekend |
| `hour` | 0-23 | UTC hour |
| `timeOfDay` | 0-3 | `0` = Night `1` = Morning `2` = Afternoon `3` = Evening |
| `minute` | 0-59 | UTC minute |
| `second` | 0-59 | UTC second |

Both sides of the relationship can use `::unit`. When they do, both must declare the same unit. Using different units on each side is a configuration error.

A plain date column can also appear on one side without a `::unit` suffix. Studio extracts the same unit from it automatically, so both keys are comparable integers.

```ts
// Valid: both sides extract the same unit
{
    id: 'orders-targets',
    source: { tableId: 'orders', fieldId: 'order_date::quarter' },
    target: { tableId: 'targets', fieldId: 'target_date::quarter' },
    type: 'many-to-one',
}

// Valid: one side declares the common unit; the plain date side inherits it
{
    id: 'orders-targets',
    source: { tableId: 'orders', fieldId: 'order_date' },
    target: { tableId: 'targets', fieldId: 'target_date::quarter' },
    type: 'many-to-one',
}

// Error: mismatched units
{
    id: 'orders-targets',
    source: { tableId: 'orders', fieldId: 'order_date::month' },
    target: { tableId: 'targets', fieldId: 'target_date::year' },
    type: 'many-to-one',
}
```

#### Cross-Granularity Date Joining

```ts
import { AgReportState, AgStudioProperties, createStudio } from "ag-studio";

const initialState: AgReportState = {
  pages: [
    {
      id: "page1",
      widgets: {
        "by-season": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "dim_month.season" },
              { id: "sales.amount", aggregation: "sum" },
            ],
          },
          format: {
            title: { enabled: true, text: "Sales by Season" },
            subtitle: {
              enabled: true,
              text: "Season comes from the month dimension table, joined via order_date::monthOfYear",
            },
          },
        },
        "sales-raw": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "sales.order_date" },
              { id: "sales.product" },
              { id: "sales.amount" },
            ],
          },
          format: {
            title: { enabled: true, text: "Sales (fact table)" },
            subtitle: { enabled: true, text: "Full date values" },
          },
        },
        "dim-month-raw": {
          type: "grid",
          dataMapping: {
            cols: [{ id: "dim_month.month_num" }, { id: "dim_month.season" }],
          },
          format: {
            title: { enabled: true, text: "Month Dimension" },
            subtitle: { enabled: true, text: "One row per calendar month" },
          },
        },
      },
      widgetLayout: {
        "by-season": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 14 },
        "sales-raw": { xTrack: 0, yTrack: 14, xSpan: 14, ySpan: 12 },
        "dim-month-raw": { xTrack: 14, yTrack: 14, xSpan: 10, ySpan: 12 },
      },
    },
  ],
  selectedPageId: "page1",
  panels: {
    filters: { collapsed: true },
    edit: { collapsed: true },
  },
};

const studioProperties: AgStudioProperties = {
  mode: "view",
  initialState,
  data: {
    sources: [
      {
        id: "sales",
        name: "Sales",
        data: [
          { order_date: "2024-01-10", product: "Widget A", amount: 420 },
          { order_date: "2024-01-22", product: "Widget B", amount: 310 },
          { order_date: "2024-02-05", product: "Widget A", amount: 390 },
          { order_date: "2024-03-14", product: "Widget C", amount: 540 },
          { order_date: "2024-03-28", product: "Widget A", amount: 480 },
          { order_date: "2024-04-03", product: "Widget B", amount: 620 },
          { order_date: "2024-05-17", product: "Widget C", amount: 710 },
          { order_date: "2024-06-21", product: "Widget A", amount: 830 },
          { order_date: "2024-07-09", product: "Widget B", amount: 760 },
          { order_date: "2024-08-15", product: "Widget C", amount: 890 },
          { order_date: "2024-09-04", product: "Widget A", amount: 640 },
          { order_date: "2024-10-12", product: "Widget B", amount: 550 },
          { order_date: "2024-11-19", product: "Widget C", amount: 490 },
          { order_date: "2024-12-06", product: "Widget A", amount: 430 },
        ],
        fields: [
          { id: "order_date", name: "Order Date", format: "dateFormat" },
          { id: "product", name: "Product", format: "textFormat" },
          { id: "amount", name: "Amount", format: "currencyFormat" },
        ] as any,
      },
      {
        id: "dim_month",
        name: "Month Dimension",
        data: [
          { month_num: 1, season: "Winter" },
          { month_num: 2, season: "Winter" },
          { month_num: 3, season: "Spring" },
          { month_num: 4, season: "Spring" },
          { month_num: 5, season: "Spring" },
          { month_num: 6, season: "Summer" },
          { month_num: 7, season: "Summer" },
          { month_num: 8, season: "Summer" },
          { month_num: 9, season: "Autumn" },
          { month_num: 10, season: "Autumn" },
          { month_num: 11, season: "Autumn" },
          { month_num: 12, season: "Winter" },
        ],
        fields: [
          {
            id: "month_num",
            name: "Month #",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          { id: "season", name: "Season", format: "textFormat" },
        ] as any,
      },
    ],
    relationships: [
      {
        id: "sales-dim_month",
        source: { tableId: "sales", fieldId: "order_date::monthOfYear" },
        target: { tableId: "dim_month", fieldId: "month_num" },
        type: "many-to-one",
      },
    ],
  },
};

const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
createStudio(studioDiv, studioProperties);
```

[Live example: Cross-Granularity Date Joining](https://www.ag-grid.com/studio/examples/data/cross-granularity-date/typescript/)

The example above demonstrates joining at different granularities.

## Modelling Data

A dashboard's queries mostly do two things: filter/group by an attribute, and summarise a measure against it. Splitting tables along that line - tables you group by, and tables you summarise - is the basis of a data model that suits Studio well.

### Star Schema

A **star schema** puts this into practice: one fact table - the table holding your measures - related to several dimension tables, each by its own `many-to-one` relationship. It's named for the shape: the fact table sits at the centre with its dimensions radiating out around it.

```d2
grid-rows: 3
grid-columns: 3

spacerR1C1: "" { style.opacity: 0 }
spacerR1C2: "" { style.opacity: 0 }
products.class: dimension
orderItems.class: fact
spacerR2C2: "" { style.opacity: 0 }
customers.class: dimension
spacerR3C1: "" { style.opacity: 0 }
spacerR3C2: "" { style.opacity: 0 }
regions.class: dimension

orderItems -> customers: many-to-one { class: relationship }
orderItems -> products: many-to-one { class: relationship }
orderItems -> regions: many-to-one { class: relationship }
```

#### Star Schema

```ts
import { AgReportState, AgStudioProperties, createStudio } from "ag-studio";

const initialState: AgReportState = {
  pages: [
    {
      id: "page1",
      widgets: {
        "star-summary": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "products.name" },
              { id: "regions.name" },
              { id: "orderItems.netSales", aggregation: "sum" },
            ],
          },
          format: {
            title: { enabled: true, text: "Net Sales by Product & Region" },
            subtitle: {
              enabled: true,
              text: "orderItems is the fact table; products, customers and regions are its dimensions, each joined many-to-one",
            },
          },
        },
        "order-items-raw": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "orderItems.productId" },
              { id: "orderItems.customerId" },
              { id: "orderItems.regionId" },
              { id: "orderItems.netSales" },
            ],
          },
          format: {
            title: { enabled: true, text: "Order Items (fact table)" },
            subtitle: { enabled: true, text: "One row per order line" },
          },
        },
      },
      widgetLayout: {
        "star-summary": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 16 },
        "order-items-raw": { xTrack: 0, yTrack: 16, xSpan: 24, ySpan: 12 },
      },
    },
  ],
  selectedPageId: "page1",
  panels: {
    filters: { collapsed: true },
    edit: { collapsed: true },
  },
};

const studioProperties: AgStudioProperties = {
  mode: "view",
  initialState,
  data: {
    sources: [
      {
        id: "products",
        name: "Products",
        data: [
          { id: 1, name: "Widget A" },
          { id: 2, name: "Widget B" },
          { id: 3, name: "Widget C" },
        ],
        fields: [
          {
            id: "id",
            name: "ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          { id: "name", name: "Name", format: "textFormat" },
        ] as any,
      },
      {
        id: "customers",
        name: "Customers",
        data: [
          { id: 1, name: "Acme Corp" },
          { id: 2, name: "Globex" },
          { id: 3, name: "Initech" },
        ],
        fields: [
          {
            id: "id",
            name: "ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          { id: "name", name: "Name", format: "textFormat" },
        ] as any,
      },
      {
        id: "regions",
        name: "Regions",
        data: [
          { id: 1, name: "North" },
          { id: 2, name: "South" },
        ],
        fields: [
          {
            id: "id",
            name: "ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          { id: "name", name: "Name", format: "textFormat" },
        ] as any,
      },
      {
        id: "orderItems",
        name: "Order Items",
        data: [
          { productId: 1, customerId: 1, regionId: 1, netSales: 200 },
          { productId: 1, customerId: 2, regionId: 2, netSales: 150 },
          { productId: 2, customerId: 1, regionId: 1, netSales: 300 },
          { productId: 2, customerId: 3, regionId: 2, netSales: 100 },
          { productId: 3, customerId: 2, regionId: 1, netSales: 250 },
          { productId: 3, customerId: 3, regionId: 2, netSales: 175 },
          { productId: 1, customerId: 3, regionId: 2, netSales: 90 },
          { productId: 2, customerId: 2, regionId: 1, netSales: 60 },
          { productId: 3, customerId: 1, regionId: 2, netSales: 210 },
          { productId: 1, customerId: 1, regionId: 1, netSales: 40 },
        ],
        fields: [
          {
            id: "productId",
            name: "Product ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          {
            id: "customerId",
            name: "Customer ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          {
            id: "regionId",
            name: "Region ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          { id: "netSales", name: "Net Sales", format: "currencyFormat" },
        ] as any,
      },
    ],
    relationships: [
      {
        id: "order-items-products",
        source: { tableId: "orderItems", fieldId: "productId" },
        target: { tableId: "products", fieldId: "id" },
        type: "many-to-one",
      },
      {
        id: "order-items-customers",
        source: { tableId: "orderItems", fieldId: "customerId" },
        target: { tableId: "customers", fieldId: "id" },
        type: "many-to-one",
      },
      {
        id: "order-items-regions",
        source: { tableId: "orderItems", fieldId: "regionId" },
        target: { tableId: "regions", fieldId: "id" },
        type: "many-to-one",
      },
    ],
  },
};

const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
createStudio(studioDiv, studioProperties);
```

[Live example: Star Schema](https://www.ag-grid.com/studio/examples/data/star-schema/typescript/)

The example above groups the `orderItems` fact table by two of its dimensions, `products` and `regions`, in a single widget - each dimension contributes its own `many-to-one` relationship, with no join between dimensions themselves.

> **Note**
>
> This application of denormalised dimension tables produces fewer joins per query, leading to faster query times. This is why the star schema is the standard recommendation across the BI and analytics industry for read-heavy workloads such as dashboards and reporting.

### Galaxy Schema

Sometimes the data behind a dashboard arrives at more than one grain - daily order items and individual shipments, for example.

Avoid joining tables like these to each other directly. Instead, give each fact its own star, reusing the same dimension tables where they apply - a **conformed dimension** is a dimension table shared by more than one fact's star.

Multiple stars linked by conformed dimensions like this form a **galaxy schema** (also called a fact constellation). Joining the two facts directly can multiply rows on both sides, inflating measures in a way that's easy to miss.

The example below extends the star above with a second fact table: `shipments` (one row per shipment, a coarser grain than `orderItems`). Both facts relate to `products` and `regions` - now conformed dimensions, shared across both stars - while `customers` still relates to `orderItems` only:

```d2
grid-rows: 3
grid-columns: 5

spacerR1C1: "" { style.opacity: 0 }
spacerR1C2: "" { style.opacity: 0 }
products.class: dimension
spacerR1C4: "" { style.opacity: 0 }
spacerR1C5: "" { style.opacity: 0 }
orderItems.class: fact
spacerR2C2: "" { style.opacity: 0 }
customers.class: dimension
spacerR2C4: "" { style.opacity: 0 }
shipments.class: fact
spacerR3C1: "" { style.opacity: 0 }
spacerR3C2: "" { style.opacity: 0 }
regions.class: dimension
spacerR3C4: "" { style.opacity: 0 }
spacerR3C5: "" { style.opacity: 0 }

orderItems -> products: many-to-one { class: relationship }
orderItems -> customers: many-to-one { class: relationship }
orderItems -> regions: many-to-one { class: relationship }
shipments -> products: many-to-one { class: relationship }
shipments -> regions: many-to-one { class: relationship }
```

```js
const studioProperties = {
    data: {
        sources: [
            { id: 'products', data: [/* ... */] },
            { id: 'customers', data: [/* ... */] },
            { id: 'regions', data: [/* ... */] },
            { id: 'orderItems', data: [/* ... */] },
            { id: 'shipments', data: [/* ... */] },
        ],
        relationships: [
            {
                id: 'order-items-products',
                source: { tableId: 'orderItems', fieldId: 'productId' },
                target: { tableId: 'products', fieldId: 'id' },
                type: 'many-to-one',
            },
            {
                id: 'order-items-customers',
                source: { tableId: 'orderItems', fieldId: 'customerId' },
                target: { tableId: 'customers', fieldId: 'id' },
                type: 'many-to-one',
            },
            {
                id: 'order-items-regions',
                source: { tableId: 'orderItems', fieldId: 'regionId' },
                target: { tableId: 'regions', fieldId: 'id' },
                type: 'many-to-one',
            },
            {
                id: 'shipments-products',
                source: { tableId: 'shipments', fieldId: 'productId' },
                target: { tableId: 'products', fieldId: 'id' },
                type: 'many-to-one',
            },
            {
                id: 'shipments-regions',
                source: { tableId: 'shipments', fieldId: 'regionId' },
                target: { tableId: 'regions', fieldId: 'id' },
                type: 'many-to-one',
            },
        ],
    },

    // other studio properties ...
}
```

Keep each fact's measure in its own widget, both grouped by `products.name`: one widget for `sum(orderItems.netSales)`, another for `count(shipments.id)`. Both widgets agree on which products they're showing, since they share the same `products` dimension.

#### Galaxy Schema

```ts
import { AgReportState, AgStudioProperties, createStudio } from "ag-studio";

const initialState: AgReportState = {
  pages: [
    {
      id: "page1",
      widgets: {
        "net-sales-by-product": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "products.name" },
              { id: "orderItems.netSales", aggregation: "sum" },
            ],
          },
          format: {
            title: { enabled: true, text: "Net Sales by Product" },
            subtitle: { enabled: true, text: "orderItems' own star" },
          },
        },
        "shipments-by-product": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "products.name" },
              { id: "shipments.id", aggregation: "count" },
            ],
          },
          format: {
            title: { enabled: true, text: "Shipment Count by Product" },
            subtitle: {
              enabled: true,
              text: "shipments' own star, same conformed products dimension",
            },
          },
        },
        "order-items-raw": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "orderItems.productId" },
              { id: "orderItems.regionId" },
              { id: "orderItems.netSales" },
            ],
          },
          format: {
            title: { enabled: true, text: "Order Items" },
            subtitle: {
              enabled: true,
              text: "Fine-grained fact: one row per order line",
            },
          },
        },
        "shipments-raw": {
          type: "grid",
          dataMapping: {
            cols: [{ id: "shipments.productId" }, { id: "shipments.regionId" }],
          },
          format: {
            title: { enabled: true, text: "Shipments" },
            subtitle: {
              enabled: true,
              text: "Coarser fact: one row per shipment",
            },
          },
        },
      },
      widgetLayout: {
        "net-sales-by-product": { xTrack: 0, yTrack: 0, xSpan: 12, ySpan: 14 },
        "shipments-by-product": { xTrack: 12, yTrack: 0, xSpan: 12, ySpan: 14 },
        "order-items-raw": { xTrack: 0, yTrack: 14, xSpan: 12, ySpan: 14 },
        "shipments-raw": { xTrack: 12, yTrack: 14, xSpan: 12, ySpan: 14 },
      },
    },
  ],
  selectedPageId: "page1",
  panels: {
    filters: { collapsed: true },
    edit: { collapsed: true },
  },
};

const studioProperties: AgStudioProperties = {
  mode: "view",
  initialState,
  data: {
    sources: [
      {
        id: "products",
        name: "Products",
        data: [
          { id: 1, name: "Widget A" },
          { id: 2, name: "Widget B" },
          { id: 3, name: "Widget C" },
        ],
        fields: [
          {
            id: "id",
            name: "ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          { id: "name", name: "Name", format: "textFormat" },
        ] as any,
      },
      {
        id: "customers",
        name: "Customers",
        data: [
          { id: 1, name: "Acme Corp" },
          { id: 2, name: "Globex" },
        ],
        fields: [
          {
            id: "id",
            name: "ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          { id: "name", name: "Name", format: "textFormat" },
        ] as any,
      },
      {
        id: "regions",
        name: "Regions",
        data: [
          { id: 1, name: "North" },
          { id: 2, name: "South" },
        ],
        fields: [
          {
            id: "id",
            name: "ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          { id: "name", name: "Name", format: "textFormat" },
        ] as any,
      },
      {
        id: "orderItems",
        name: "Order Items",
        data: [
          { productId: 1, customerId: 1, regionId: 1, netSales: 200 },
          { productId: 1, customerId: 2, regionId: 2, netSales: 150 },
          { productId: 2, customerId: 1, regionId: 1, netSales: 300 },
          { productId: 2, customerId: 2, regionId: 2, netSales: 100 },
          { productId: 3, customerId: 1, regionId: 1, netSales: 250 },
          { productId: 3, customerId: 2, regionId: 2, netSales: 175 },
        ],
        fields: [
          {
            id: "productId",
            name: "Product ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          {
            id: "customerId",
            name: "Customer ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          {
            id: "regionId",
            name: "Region ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          { id: "netSales", name: "Net Sales", format: "currencyFormat" },
        ] as any,
      },
      {
        id: "shipments",
        name: "Shipments",
        data: [
          { id: 1, productId: 1, regionId: 1 },
          { id: 2, productId: 1, regionId: 2 },
          { id: 3, productId: 2, regionId: 1 },
          { id: 4, productId: 3, regionId: 1 },
          { id: 5, productId: 3, regionId: 2 },
        ],
        fields: [
          {
            id: "id",
            name: "Shipment ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          {
            id: "productId",
            name: "Product ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          {
            id: "regionId",
            name: "Region ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
        ] as any,
      },
    ],
    relationships: [
      {
        id: "order-items-products",
        source: { tableId: "orderItems", fieldId: "productId" },
        target: { tableId: "products", fieldId: "id" },
        type: "many-to-one",
      },
      {
        id: "order-items-customers",
        source: { tableId: "orderItems", fieldId: "customerId" },
        target: { tableId: "customers", fieldId: "id" },
        type: "many-to-one",
      },
      {
        id: "order-items-regions",
        source: { tableId: "orderItems", fieldId: "regionId" },
        target: { tableId: "regions", fieldId: "id" },
        type: "many-to-one",
      },
      {
        id: "shipments-products",
        source: { tableId: "shipments", fieldId: "productId" },
        target: { tableId: "products", fieldId: "id" },
        type: "many-to-one",
      },
      {
        id: "shipments-regions",
        source: { tableId: "shipments", fieldId: "regionId" },
        target: { tableId: "regions", fieldId: "id" },
        type: "many-to-one",
      },
    ],
  },
};

const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
createStudio(studioDiv, studioProperties);
```

[Live example: Galaxy Schema](https://www.ag-grid.com/studio/examples/data/galaxy-schema/typescript/)

The example above shows each fact as its own widget, both grouped by `products.name`.

> **Note**
>
> Putting `sum(orderItems.netSales)` and `count(shipments.id)` in the *same* widget is safe: Studio detects that both measures come from facts sharing the `products` dimension and aggregates each fact independently before combining the results - the same handling described for [calendars](https://www.ag-grid.com/studio/javascript/calendars/), not a special case limited to them.
>
> Fan-out is still worth designing around, though. Joining two facts directly instead of through a shared dimension, or relating a dimension to a fact through a many-to-many relationship, are both shapes Studio can't safely combine automatically. See [Fan-out Detection](#fan-out-detection) below for how Studio reports and controls these cases.

> **Note**
>
> The [`::unit` override](#joining-dates) is intended for date fields, and only changes the granularity of a single join.

### Snowflake Schema

A dimension table can itself be normalised into further tables - splitting `products` into `products` and `categories`, for example, so each category's name is stored once rather than repeated on every product row. Following a dimension's relationships out another hop like this is called a **snowflake schema**, named for the way its dimensions branch further outward than a star's.

```d2
grid-rows: 3
grid-columns: 5

spacerR1C1: "" { style.opacity: 0 }
spacerR1C2: "" { style.opacity: 0 }
products.class: dimension
spacerR1C4: "" { style.opacity: 0 }
categories.class: dimension
orderItems.class: fact
spacerR2C2: "" { style.opacity: 0 }
customers.class: dimension
spacerR2C4: "" { style.opacity: 0 }
spacerR2C5: "" { style.opacity: 0 }
spacerR3C1: "" { style.opacity: 0 }
spacerR3C2: "" { style.opacity: 0 }
regions.class: dimension
spacerR3C4: "" { style.opacity: 0 }
spacerR3C5: "" { style.opacity: 0 }

orderItems -> customers: many-to-one { class: relationship }
orderItems -> products: many-to-one { class: relationship }
orderItems -> regions: many-to-one { class: relationship }
products -> categories: many-to-one { class: relationship }
```

#### Snowflake Schema

```ts
import { AgReportState, AgStudioProperties, createStudio } from "ag-studio";

const initialState: AgReportState = {
  pages: [
    {
      id: "page1",
      widgets: {
        "by-category-product": {
          type: "grid",
          dataMapping: {
            cols: [
              { id: "categories.name" },
              { id: "products.name" },
              { id: "orderItems.netSales", aggregation: "sum" },
            ],
          },
          format: {
            title: { enabled: true, text: "Net Sales by Category & Product" },
            subtitle: {
              enabled: true,
              text: "categories.name is reached by following products' own many-to-one relationship out one more hop",
            },
          },
        },
        "products-raw": {
          type: "grid",
          dataMapping: {
            cols: [{ id: "products.name" }, { id: "products.categoryId" }],
          },
          format: {
            title: { enabled: true, text: "Products" },
            subtitle: {
              enabled: true,
              text: "Dimension table, one hop from categories",
            },
          },
        },
        "categories-raw": {
          type: "grid",
          dataMapping: {
            cols: [{ id: "categories.name" }],
          },
          format: {
            title: { enabled: true, text: "Categories" },
            subtitle: { enabled: true, text: "Normalised out of products" },
          },
        },
      },
      widgetLayout: {
        "by-category-product": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 16 },
        "products-raw": { xTrack: 0, yTrack: 16, xSpan: 14, ySpan: 12 },
        "categories-raw": { xTrack: 14, yTrack: 16, xSpan: 10, ySpan: 12 },
      },
    },
  ],
  selectedPageId: "page1",
  panels: {
    filters: { collapsed: true },
    edit: { collapsed: true },
  },
};

const studioProperties: AgStudioProperties = {
  mode: "view",
  initialState,
  data: {
    sources: [
      {
        id: "categories",
        name: "Categories",
        data: [
          { id: 1, name: "Electronics" },
          { id: 2, name: "Furniture" },
        ],
        fields: [
          {
            id: "id",
            name: "ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          { id: "name", name: "Name", format: "textFormat" },
        ] as any,
      },
      {
        id: "products",
        name: "Products",
        data: [
          { id: 1, name: "Widget A", categoryId: 1 },
          { id: 2, name: "Widget B", categoryId: 1 },
          { id: 3, name: "Chair", categoryId: 2 },
          { id: 4, name: "Desk", categoryId: 2 },
        ],
        fields: [
          {
            id: "id",
            name: "ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          { id: "name", name: "Name", format: "textFormat" },
          {
            id: "categoryId",
            name: "Category ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
        ] as any,
      },
      {
        id: "customers",
        name: "Customers",
        data: [
          { id: 1, name: "Acme Corp" },
          { id: 2, name: "Globex" },
          { id: 3, name: "Initech" },
        ],
        fields: [
          {
            id: "id",
            name: "ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          { id: "name", name: "Name", format: "textFormat" },
        ] as any,
      },
      {
        id: "regions",
        name: "Regions",
        data: [
          { id: 1, name: "North" },
          { id: 2, name: "South" },
        ],
        fields: [
          {
            id: "id",
            name: "ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          { id: "name", name: "Name", format: "textFormat" },
        ] as any,
      },
      {
        id: "orderItems",
        name: "Order Items",
        data: [
          { productId: 1, customerId: 1, regionId: 1, netSales: 200 },
          { productId: 1, customerId: 2, regionId: 2, netSales: 150 },
          { productId: 2, customerId: 1, regionId: 1, netSales: 300 },
          { productId: 2, customerId: 3, regionId: 2, netSales: 100 },
          { productId: 3, customerId: 2, regionId: 1, netSales: 250 },
          { productId: 3, customerId: 3, regionId: 2, netSales: 175 },
          { productId: 4, customerId: 1, regionId: 1, netSales: 90 },
          { productId: 4, customerId: 2, regionId: 2, netSales: 60 },
        ],
        fields: [
          {
            id: "productId",
            name: "Product ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          {
            id: "customerId",
            name: "Customer ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          {
            id: "regionId",
            name: "Region ID",
            format: "integerFormat",
            formatOptions: { format: "0" },
          },
          { id: "netSales", name: "Net Sales", format: "currencyFormat" },
        ] as any,
      },
    ],
    relationships: [
      {
        id: "order-items-products",
        source: { tableId: "orderItems", fieldId: "productId" },
        target: { tableId: "products", fieldId: "id" },
        type: "many-to-one",
      },
      {
        id: "order-items-customers",
        source: { tableId: "orderItems", fieldId: "customerId" },
        target: { tableId: "customers", fieldId: "id" },
        type: "many-to-one",
      },
      {
        id: "order-items-regions",
        source: { tableId: "orderItems", fieldId: "regionId" },
        target: { tableId: "regions", fieldId: "id" },
        type: "many-to-one",
      },
      {
        id: "products-categories",
        source: { tableId: "products", fieldId: "categoryId" },
        target: { tableId: "categories", fieldId: "id" },
        type: "many-to-one",
      },
    ],
  },
};

const studioDiv = document.querySelector<HTMLElement>("#myStudio")!;
createStudio(studioDiv, studioProperties);
```

[Live example: Snowflake Schema](https://www.ag-grid.com/studio/examples/data/snowflake-schema/typescript/)

The example above groups `orderItems.netSales` by `categories.name` and `products.name` together. `categories` is not a dimension of `orderItems` directly - the join reaches it by following `products`' own relationship out one more hop.

> **Note**
>
> Snowflaking trades query speed for storage: each extra hop is another join a query must perform. Prefer denormalising a dimension - keeping `category` as a plain column on `products` - unless the normalised attribute is large, changes independently of `products`, or is shared by dimensions outside this star.

### Fan-out Detection

Fan-out is row duplication introduced by a join: a shape where a single fact row can match more than one row on the other side, so a measure summed across the join counts some rows more than once. Two relationship shapes trigger it:

- Two fact tables joined directly, rather than through a shared conformed dimension (see [Galaxy Schema](#galaxy-schema) above).
- A dimension related to a fact through a many-to-many relationship, so a fact row can attribute to more than one dimension member and vice versa.

```d2
grid-rows: 1
grid-columns: 3

orderItems.class: fact
spacerC2: "" { style.opacity: 0 }
tags.class: dimension

orderItems -> tags: many-to-many { class: relationship }
```

In the shape above, an order item tagged with three tags contributes to `sum(orderItems.netSales)` three times over once grouped by `tags.name` - once per tag - rather than once.

> **Warning**
>
> Fan-out is almost never what you want. Inflated measures are easy to miss and hard to spot in review, since the numbers still look plausible - just wrong. Prefer remodelling the relationship (adding a conformed dimension, or normalising a many-to-many join through a bridge table) over accepting the duplication.

By default, Studio detects both shapes and reports a warning through the same collector used for other query validation issues, then runs the query anyway.

Accept the duplication for a specific relationship with `acceptFanout: true`, silencing its warning regardless of the `data.options.fanout` policy below:

```ts
{
    id: 'order-items-tags',
    source: { tableId: 'orderItems', fieldId: 'tagId' },
    target: { tableId: 'tags', fieldId: 'id' },
    type: 'many-to-many',
    acceptFanout: true,
}
```

Or control fan-out detection for the whole data model with `data.options.fanout`:

```js
const studioProperties = {
    data: {
        sources: [/* ... */],
        options: {
            fanout: {
                warning: true, // default - report a warning for any unaccepted fan-out finding
                execute: 'allow', // default - run the query anyway; set to 'prevent' to block it instead
            },
        },
    },

    // other studio properties ...
}
```

```js
const studioProperties = {
    data: {
        sources: [/* ... */],
        options: {
            fanout: false, // shorthand for { warning: false, execute: 'allow' } - never warn, never block
        },
    },

    // other studio properties ...
}
```

> **Note**
>
> Set `acceptFanout` on a relationship only once you've confirmed the duplication is what you want - for example, a many-to-many relationship onto a table that's already pre-aggregated to a grain with no finer key available. Downstream aggregates still need to account for the duplication; Studio just stops warning about it.

## Data API

Properties available on the `AgDataSourcesDefinition&lt;TRegistry extends AgBaseRegistry = AgDefaultRegistry&gt;` interface.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `sources` | `AgDataSource<TRegistry>[]` |  | One or more data sources. |
| `relationships` | `AgRelationDefinition[]` |  | When using multiple related tables, this describes the fields that link the tables together. |
| `expressions` | `AgExpressionFieldDefinition<TRegistry, AgFormat<TRegistry>, any>[]` |  | Expression field definitions for calculated columns. |
| `formats` | `TRegistry["formats"]` |  | Overrides to existing formats, or additional custom formats. |
| `description` | `string` |  | AI-facing overview of the entire dataset: what it contains, what it's for, domain quirks. |
| `calendars` | `AgCalendar[]` |  | Named time dimensions (calendars) that supply date fragments and a continuous date spine. |
| `buckets` | `TRegistry["buckets"]` |  | Additional date-fragment bucket definitions to register alongside the built-in set (year, quarter, month, week, day, monthOfYear, dayOfWeek, …). Use this to add project-specific groupings such as `weekend`, `dayOfMonth`, or `hour` that the built-in registry does not include. Provide via createBuckets so type-level registry inference works correctly. |
| `options` | `AgDataSourcesOptions` |  | Engine-wide behavioural options, such as fan-out detection policy. |
