Semaphor
Data Apps

Patterns

Reusable Data App patterns with business scenarios and code.

These patterns use the same commerce schema from the relationship guide. Adapt the domainId, datasetName, and field names to your Semaphor project.

The Generated SQL tabs show the kind of governed SQL Semaphor can compile from the SDK intent. They are illustrative: the exact SQL can vary by database dialect, security policy, relationship model, and optimizer strategy.

Each pattern is named by the technical concept it teaches. The business scenario underneath grounds that concept in a realistic user request, then the source-bearing fields and code show how to implement it.

ConceptBusiness scenario
Metric query with period comparisonRevenue leader wants a KPI and previous-period change.
Joined projectionMarketing manager wants revenue by campaign name.
Joined input filtering a fact viewUser picks campaign names to filter order-backed visuals.
Same-source cascading inputsRegion narrows State, and State narrows Region.
Server-paginated records tableOperations user inspects recent orders without loading every row.
Conformed dimension filter across factsOne Material Family filter updates purchase and sales views.
Shared date range across factsOne Date Range control filters purchase and sales views using each fact's date field.
Fact-bridged option populationStore selection narrows Product options through inventory.

Shared Setup

import { useState } from 'react';
import {
  SemaphorDataAppProvider,
  semaphor,
  useClearInvalidSemaphorInputValue,
  useSemaphorInput,
  useSemaphorQuery,
} from 'react-semaphor/data-app-sdk';

export const orders = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'fact_orders',
});

export const campaign = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'dim_campaign',
});

export const geo = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'dim_geo',
});

export const store = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'dim_store',
});

export const revenue = semaphor.field.measure('revenue', {
  source: orders,
  aggregate: 'SUM',
});

export const orderDate = semaphor.field.date('order_date', {
  source: orders,
});

export const orderId = semaphor.field.id('order_id', {
  source: orders,
});

export const campaignId = semaphor.field.id('campaign_id', {
  source: campaign,
});

export const campaignName = semaphor.field.dimension('campaign_name', {
  source: campaign,
});

export const storeId = semaphor.field.id('store_id', {
  source: store,
});

export const storeName = semaphor.field.dimension('store_name', {
  source: store,
});

export const purchaseLine = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'fact_purchase_line',
});

export const salesLine = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'fact_sales_line',
});

export const material = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'dim_material',
});

export const materialFamily = semaphor.field.dimension('material_family', {
  source: material,
});

export const purchaseDate = semaphor.field.date('purchase_date', {
  source: purchaseLine,
});

export const saleDate = semaphor.field.date('sale_date', {
  source: salesLine,
});

export const netPurchaseValue = semaphor.field.measure('net_purchase_value', {
  source: purchaseLine,
  aggregate: 'SUM',
});

export const salesValue = semaphor.field.measure('sales_value', {
  source: salesLine,
  aggregate: 'SUM',
});

function SelectInput({
  input,
  options,
}: {
  input: { value: unknown; setValue(value: unknown): void };
  options: Array<{ label: string; value: string | number | boolean }>;
}) {
  return (
    <select
      value={String(input.value ?? '')}
      onChange={(event) => input.setValue(event.currentTarget.value)}
    >
      <option value="">All</option>
      {options.map((option) => (
        <option key={String(option.value)} value={String(option.value)}>
          {option.label}
        </option>
      ))}
    </select>
  );
}

Metric Query With Period Comparison

Business scenario

A revenue leader wants a simple headline KPI that answers: "How much revenue did we make, and how does it compare with the previous period?" This is the kind of number that usually sits at the top of a dashboard.

Technical concept

Use a metric query when the visual is a single KPI. The query has one base source, one or more measures, and an optional comparison. When a query has multiple measures, comparison fields apply only to primaryMeasure; use separate single-measure metric queries when every KPI card needs its own period-over-period delta. Here, fact_orders is the base source and revenue is the measure Semaphor sums.

How it translates to code

  • source: orders tells Semaphor the KPI is based on fact_orders.
  • measures: [revenue] tells Semaphor which numeric field to aggregate.
  • primaryMeasure: revenue tells the UI which value is the hero number.
  • comparison.kind = 'previous_period' asks Semaphor to calculate the previous-period delta server-side.

Source-bearing fields for this scenario:

const orders = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'fact_orders',
});

const revenue = semaphor.field.measure('revenue', {
  source: orders,
  aggregate: 'SUM',
});

const orderDate = semaphor.field.date('order_date', {
  source: orders,
});
const revenueKpi = semaphor.metric({
  id: 'revenue_kpi',
  label: 'Revenue',
  source: orders,
  measures: [revenue],
  primaryMeasure: revenue,
  dateField: orderDate,
  comparison: {
    kind: 'previous_period',
  },
});

function RevenueKpi() {
  const result = useSemaphorQuery(revenueKpi);

  if (result.isLoading) return <div>Loading revenue...</div>;
  if (result.error) return <div>{result.error.message}</div>;

  return (
    <div>
      <div>Revenue</div>
      <strong>{String(result.value ?? '')}</strong>
      <span>{result.deltaPercent ?? 0}%</span>
    </div>
  );
}

Joined Projection

Business scenario

A marketing manager wants to see which campaigns are driving the most revenue. They do not want to see campaign ids like cmp_123; they want readable campaign names such as "Holiday Retargeting."

Technical concept

This is a joined projection. The measure comes from fact_orders, but the display dimension comes from dim_campaign. Semaphor uses the modeled relationship between orders and campaigns to group revenue by campaign name.

How it translates to code

  • source: orders keeps the query grain at the orders fact table.
  • fields: [campaignName, revenue] asks for a related dimension and a measure.
  • orderBy: revenue desc ranks the campaigns by revenue.
  • The app does not join ids to labels in client code; Semaphor resolves the relationship server-side.

Source-bearing fields for this scenario:

const orders = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'fact_orders',
});

const campaign = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'dim_campaign',
});

const revenue = semaphor.field.measure('revenue', {
  source: orders,
  aggregate: 'SUM',
});

const campaignName = semaphor.field.dimension('campaign_name', {
  source: campaign,
});
const revenueByCampaign = semaphor.records({
  id: 'revenue_by_campaign',
  label: 'Revenue by Campaign',
  source: orders,
  fields: [campaignName, revenue],
  orderBy: {
    field: revenue,
    direction: 'desc',
  },
  limit: 10,
});

function RevenueByCampaignList() {
  const result = useSemaphorQuery(revenueByCampaign);

  if (result.isLoading) return <div>Loading campaigns...</div>;
  if (result.error) return <div>{result.error.message}</div>;

  const [campaignColumn, revenueColumn] = result.columns || [];

  return (
    <ul>
      {result.records.map((row, index) => (
        <li key={index}>
          {String(row[campaignColumn?.key || 'campaign_name'] ?? '')}:{' '}
          {String(row[revenueColumn?.key || 'revenue'] ?? '')}
        </li>
      ))}
    </ul>
  );
}

Joined Input Filtering A Fact View

Business scenario

A user wants to pick one or more campaigns from a dropdown and have every campaign-backed visual update. The dropdown should show campaign names, but the selected value should be the stable campaign id.

Technical concept

This combines an inputOptions query with a filter input. The option query loads dropdown choices from dim_campaign. The filter input carries the selected values into a records query.

How it translates to code

  • labelField: campaignName is what the user sees.
  • valueField: campaignId is what the input stores and sends to Semaphor.
  • useSemaphorInput creates the active filter state.
  • Passing inputs: [campaignInput] to useSemaphorQuery makes filtering server-side.

Source-bearing fields for this scenario:

const campaign = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'dim_campaign',
});

const campaignName = semaphor.field.dimension('campaign_name', {
  source: campaign,
});

const campaignId = semaphor.field.id('campaign_id', {
  source: campaign,
});
const campaignOptions = semaphor.inputOptions({
  id: 'campaign_options',
  inputId: 'campaign',
  label: 'Campaign options',
  source: campaign,
  labelField: campaignName,
  valueField: campaignId,
  searchField: campaignName,
  limit: 100,
});

function CampaignFilterAndChart() {
  const campaignInput = useSemaphorInput({
    id: 'campaign',
    label: 'Campaign',
    kind: 'filter',
    field: campaignId,
    operator: 'in',
    multi: true,
  });

  const options = useSemaphorQuery(campaignOptions);
  const result = useSemaphorQuery(revenueByCampaign, {
    inputs: [campaignInput],
  });

  return (
    <>
      <select
        multiple
        value={(campaignInput.value as string[] | undefined) || []}
        onChange={(event) => {
          campaignInput.setValue(
            Array.from(event.currentTarget.selectedOptions).map(
              (option) => option.value,
            ),
          );
        }}
      >
        {options.options.map((option) => (
          <option key={String(option.value)} value={String(option.value)}>
            {option.label}
          </option>
        ))}
      </select>

      <pre>{JSON.stringify(result.records, null, 2)}</pre>
    </>
  );
}

Same-Source Cascading Inputs

Business scenario

A sales leader wants geography filters. If they choose South for Region, the State dropdown should show only southern states. If they choose Texas, the Region dropdown can narrow to South. The filters should narrow option lists without automatically changing other input values.

Technical concept

This is same-source cascading. Region and State both live in dim_geo, so Semaphor can narrow each option query using the other active input. The default dependency behavior is auto, so the option queries can use compatible active inputs without authoring a custom dependency list.

How it translates to code

  • regionOptions and stateOptions both query dim_geo.
  • stateOptions receives inputs: [regionInput], so selected Region narrows State options.
  • regionOptions receives inputs: [stateInput], so selected State narrows Region options.
  • useClearInvalidSemaphorInputValue clears a stale value only after the narrowed option query succeeds.

Source-bearing fields for this scenario:

const geo = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'dim_geo',
});

const regionId = semaphor.field.id('region_id', { source: geo });
const regionName = semaphor.field.dimension('region_name', {
  source: geo,
});
const stateId = semaphor.field.id('state_id', { source: geo });
const stateName = semaphor.field.dimension('state_name', {
  source: geo,
});
const regionOptions = semaphor.inputOptions({
  id: 'region_options',
  inputId: 'region',
  source: geo,
  labelField: regionName,
  valueField: regionId,
});

const stateOptions = semaphor.inputOptions({
  id: 'state_options',
  inputId: 'state',
  source: geo,
  labelField: stateName,
  valueField: stateId,
});

function GeoFilters() {
  const regionInput = useSemaphorInput({
    id: 'region',
    label: 'Region',
    kind: 'filter',
    field: regionId,
    operator: 'in',
    multi: true,
  });

  const stateInput = useSemaphorInput({
    id: 'state',
    label: 'State',
    kind: 'filter',
    field: stateId,
    operator: 'in',
    multi: true,
  });

  const regionOptionResult = useSemaphorQuery(regionOptions, {
    inputs: [stateInput],
  });

  const stateOptionResult = useSemaphorQuery(stateOptions, {
    inputs: [regionInput],
  });

  useClearInvalidSemaphorInputValue(regionInput, regionOptionResult);
  useClearInvalidSemaphorInputValue(stateInput, stateOptionResult);

  return (
    <div>
      <SelectInput input={regionInput} options={regionOptionResult.options} />
      <SelectInput input={stateInput} options={stateOptionResult.options} />
    </div>
  );
}

Server-Paginated Records Table

Business scenario

An operations user wants to inspect recent orders. There may be millions of orders, so the table must not load every row into the browser. Users should page through server-backed results and keep sorting consistent across the whole dataset.

Technical concept

Use a records query with pagination. The current page and page size are part of the Semaphor query spec, so the server returns only the requested slice. The table renders returned rows and uses pagination metadata to enable or disable navigation.

How it translates to code

  • pagination: { page, pageSize } asks Semaphor for one server page.
  • orderBy: orderDate desc sorts the full result set server-side.
  • result.columns gives stable column keys for rendering cells.
  • result.pagination.hasPrevPage and hasNextPage drive the page buttons.

Source-bearing fields for this scenario:

const orders = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'fact_orders',
});

const campaign = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'dim_campaign',
});

const orderId = semaphor.field.id('order_id', { source: orders });
const orderDate = semaphor.field.date('order_date', { source: orders });
const revenue = semaphor.field.measure('revenue', {
  source: orders,
  aggregate: 'SUM',
});
const campaignName = semaphor.field.dimension('campaign_name', {
  source: campaign,
});
function OrdersTable() {
  const [page, setPage] = useState(1);
  const [pageSize, setPageSize] = useState(50);

  const query = semaphor.records({
    id: 'orders_table',
    label: 'Orders',
    source: orders,
    fields: [orderId, orderDate, campaignName, revenue],
    orderBy: {
      field: orderDate,
      direction: 'desc',
    },
    pagination: {
      page,
      pageSize,
    },
  });

  const result = useSemaphorQuery(query);

  if (result.isLoading) return <div>Loading orders...</div>;
  if (result.error) return <div>{result.error.message}</div>;

  return (
    <>
      <table>
        <thead>
          <tr>
            {result.columns?.map((column) => (
              <th key={column.key}>{column.label || column.key}</th>
            ))}
          </tr>
        </thead>
        <tbody>
          {result.records.map((row, rowIndex) => (
            <tr key={rowIndex}>
              {result.columns?.map((column) => (
                <td key={column.key}>{String(row[column.key] ?? '')}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>

      <button
        disabled={!result.pagination?.hasPrevPage}
        onClick={() => setPage((current) => current - 1)}
      >
        Previous
      </button>
      <button
        disabled={!result.pagination?.hasNextPage}
        onClick={() => setPage((current) => current + 1)}
      >
        Next
      </button>
    </>
  );
}

Conformed Dimension Filter Across Facts

Business scenario

A merchandising leader wants one Material Family filter at the top of the app. When the user selects Metals, the purchase spend chart and the sales revenue chart should both update, even though one view reads from purchase lines and the other reads from sales lines.

Technical concept

This is a conformed dimension filter across facts. The visible input is authored once from dim_material.material_family, but each query binds that same input to the relationship path that connects its own fact table to dim_material.

How it translates to code

  • materialFamilyInput is the one visible input the user controls.
  • The purchase query uses semaphor.bindInput(...) with the purchase-to-material relationship hint.
  • The sales query uses the same input value, but with the sales-to-material relationship hint.
  • Semaphor proves each relationship independently, so a filter can apply to multiple fact-backed views without client-side joins.

Source-bearing fields for this scenario:

const purchaseLine = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'fact_purchase_line',
});

const salesLine = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'fact_sales_line',
});

const material = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'dim_material',
});

const materialFamily = semaphor.field.dimension('material_family', {
  source: material,
});

const netPurchaseValue = semaphor.field.measure('net_purchase_value', {
  source: purchaseLine,
  aggregate: 'SUM',
});

const salesValue = semaphor.field.measure('sales_value', {
  source: salesLine,
  aggregate: 'SUM',
});
function MaterialFamilyDashboard() {
  const materialFamilyInput = useSemaphorInput({
    id: 'material_family',
    label: 'Material Family',
    kind: 'filter',
    field: materialFamily,
    operator: 'in',
    multi: true,
  });

  const purchaseByMaterial = semaphor.records({
    id: 'purchase_by_material',
    label: 'Purchases by Material Family',
    source: purchaseLine,
    fields: [materialFamily, netPurchaseValue],
    inputs: [
      semaphor.bindInput(materialFamilyInput, {
        field: materialFamily,
        operator: 'in',
        relationshipHint: {
          relationshipIds: ['purchase_line_to_material'],
        },
      }),
    ],
    limit: 10,
  });

  const salesByMaterial = semaphor.records({
    id: 'sales_by_material',
    label: 'Sales by Material Family',
    source: salesLine,
    fields: [materialFamily, salesValue],
    inputs: [
      semaphor.bindInput(materialFamilyInput, {
        field: materialFamily,
        operator: 'in',
        relationshipHint: {
          relationshipIds: ['sales_line_to_material'],
        },
      }),
    ],
    limit: 10,
  });

  const purchaseResult = useSemaphorQuery(purchaseByMaterial);
  const salesResult = useSemaphorQuery(salesByMaterial);

  return (
    <div>
      <MaterialFamilySelect input={materialFamilyInput} />
      <pre>{JSON.stringify(purchaseResult.records, null, 2)}</pre>
      <pre>{JSON.stringify(salesResult.records, null, 2)}</pre>
    </div>
  );
}

Shared Date Range Across Facts

Business scenario

An operations leader wants one Date Range control for the whole app. Purchases should filter by purchase date, while sales should filter by sale date. The user should not have to manage separate date controls for each view.

Technical concept

This is a shared date input with query-specific bindings. The visible input stores one date range, but each fact-backed query maps that same input to the date field that belongs to the query's source.

How it translates to code

  • dateRangeInput is the one visible input.
  • The purchase query binds it to fact_purchase_line.purchase_date.
  • The sales query binds it to fact_sales_line.sale_date.
  • This pattern is for simple event or fact dates. Snapshot/latest/as-of selectors need explicit modeled semantics before the app should treat them as equivalent.

Source-bearing fields for this scenario:

const purchaseDate = semaphor.field.date('purchase_date', {
  source: purchaseLine,
});

const saleDate = semaphor.field.date('sale_date', {
  source: salesLine,
});
function PurchaseAndSalesTrends() {
  const dateRangeInput = useSemaphorInput({
    id: 'date_range',
    label: 'Date Range',
    kind: 'filter',
    field: purchaseDate,
    operator: 'between',
    defaultValue: ['2026-01-01', '2026-01-31'],
  });

  const purchaseTrend = semaphor.records({
    id: 'purchase_trend',
    label: 'Purchase Trend',
    source: purchaseLine,
    fields: [purchaseDate, netPurchaseValue],
    inputs: [
      semaphor.bindInput(dateRangeInput, {
        field: purchaseDate,
        operator: 'between',
      }),
    ],
    orderBy: {
      field: purchaseDate,
      direction: 'asc',
    },
    limit: 100,
  });

  const salesTrend = semaphor.records({
    id: 'sales_trend',
    label: 'Sales Trend',
    source: salesLine,
    fields: [saleDate, salesValue],
    inputs: [
      semaphor.bindInput(dateRangeInput, {
        field: saleDate,
        operator: 'between',
      }),
    ],
    orderBy: {
      field: saleDate,
      direction: 'asc',
    },
    limit: 100,
  });

  const purchaseResult = useSemaphorQuery(purchaseTrend);
  const salesResult = useSemaphorQuery(salesTrend);

  return (
    <div>
      <DateRangePicker input={dateRangeInput} />
      <pre>{JSON.stringify(purchaseResult.records, null, 2)}</pre>
      <pre>{JSON.stringify(salesResult.records, null, 2)}</pre>
    </div>
  );
}

Fact-Bridged Option Population

Business scenario

An inventory manager selects a store and wants the Product dropdown to show only products stocked in that store. The product list should not show every product in the catalog if the selected store has no inventory for it.

Technical concept

This is a fact-bridged option population. Store and Product are different dimensions, and they are not directly related. Inventory is the fact population that connects stores to products.

How it translates to code

  • source: product says the dropdown choices are products.
  • labelField: productName and valueField: productId define what the user sees and what gets stored.
  • population.kind = 'related_population' tells Semaphor that option relevance comes from another source.
  • baseSource: inventory tells Semaphor to use fact_inventory_snapshot as the population that connects Store to Product.
  • Passing inputs: [storeInput] narrows the product options by the selected store.

Source-bearing fields for this scenario:

const inventory = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'fact_inventory_snapshot',
});

const product = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'dim_product',
});

const store = semaphor.source.semantic({
  domainId: 'commerce',
  datasetName: 'dim_store',
});

const productId = semaphor.field.id('product_id', {
  source: product,
});
const productName = semaphor.field.dimension('product_name', {
  source: product,
});
const storeId = semaphor.field.id('store_id', {
  source: store,
});
const productOptions = semaphor.inputOptions({
  id: 'product_options',
  inputId: 'product',
  source: product,
  labelField: productName,
  valueField: productId,
  population: {
    kind: 'related_population',
    baseSource: inventory,
  },
  limit: 100,
});

const storeInput = useSemaphorInput({
  id: 'store',
  label: 'Store',
  kind: 'filter',
  field: storeId,
  operator: 'in',
  multi: true,
});

const productOptionResult = useSemaphorQuery(productOptions, {
  inputs: [storeInput],
});

Without related_population, Semaphor could return all products in the catalog. With it, Semaphor can return products relevant to the inventory population and active store filter.

On this page