# Data Modeling — Relationships, Calendar Tables, and Expressions

> **External docs:** [Table Relationships](https://docs.horusbi.com.br/dataviz/02-apps/tables.md) · [Expressions](https://docs.horusbi.com.br/dataviz/02-apps/expressions.md) · [Load Types](https://docs.horusbi.com.br/etl/guides/tipos-de-carga.md) · [ETL Best Practices](https://docs.horusbi.com.br/etl/guides.md)

## Table of Contents

- [Star Schema](#star-schema--the-default-choice)
- [Table Relationships](#table-relationships)
- [Calendar Table](#calendar-table)
- [Expressions](#expressions-calculated-metrics)
- [Widget Data Configuration](#widget-data-configuration)
- [Common Modeling Patterns](#common-modeling-patterns)
- [Anti-Patterns](#anti-patterns-to-avoid)
- [External Documentation](#external-documentation)

## Star Schema — The Default Choice

**Always prefer Star Schema over Snowflake.** Horus BI's query engine (GeneratorTable) is optimized for star schemas:

```
                    dim_calendar
                         │
dim_customers ────► fact_sales ◄──── dim_products
                         │
                    dim_stores
```

### Why Star Schema:
- **Performance:** Fewer JOINs = faster queries. Generator produces one JOIN per dimension.
- **Filter propagation:** Filters on any dimension automatically propagate to fact tables. This only works efficiently in star schemas.
- **Simplicity:** Users drag-and-drop dimensions as filters. One hop from any dimension to fact.
- **Horus limitation:** Only 1:N relationships. Snowflake chains (Category → Subcategory → Product → Sales) work but add complexity without benefit.

### When Snowflake is acceptable:
- Only when the source data is already deeply normalized AND you need to preserve that hierarchy for drill-down (e.g., Geography: Country → State → City → Store → Sales).
- Even then, consider denormalizing into a single dimension table via DuckDB SQL in the ETL.

### Data Modeling Best Practices

1. **One calendar table per app** — connect ALL date-bearing facts to it. Never create separate date dimensions.
2. **Prefix tables:** `dim_` for dimensions, `fact_` for facts, `link_` for N:N bridges.
3. **Dimensions should be small** (< 100k rows). If a "dimension" has millions of rows, it's probably a fact.
4. **Every fact table needs at least one measure** (numeric column for SUM/COUNT/AVG).
5. **Every dimension needs a key column** (unique ID) and a label column (human-readable name).
6. **Date columns in facts should be DATE type**, not STRING. Temporal load type requires it.
7. **Avoid calculated columns in ETL** that can be expressions in the BI layer. Let the Generator compute them at query time.
8. **Use expressions, not views** — Horus expressions (virtual columns) are more flexible than database views and support cross-table calculations.

## Table Relationships

All relationships in Horus are **directional and 1:N (one-to-many)**.

### How Relationships Work

```
Dimension (unique key) ──1:N──► Fact (repeated key)

Example:
  dim_customers.ID ──1:N──► fact_sales.CUSTOMER_ID
  dim_products.ID  ──1:N──► fact_sales.PRODUCT_ID
  dim_calendar.DATA ──1:N──► fact_sales.DATA_VENDA
```

- The "1" side must have unique values for the join column
- The system validates uniqueness when saving — duplicates on the "1" side cause an error
- Relationships function as automatic JOINs with filter propagation

### ignoreTime — Date Relationship Fix

When both the dimension column and fact column are DATE type, you **must** set `ignoreTime: true`:

```yaml
relationships:
  - from_table_id: 100         # dim_calendario
    to_table_id: 200           # fato_vendas
    from_column: "DATA"        # dim_calendario."DATA"
    to_column: "DATA_VENDA"    # fato_vendas."DATA_VENDA"
    ignoreTime: true           # ← REQUIRED for DATE↔DATE
```

**The join is wired by numeric column ID or by column name; either is
enough.** `column_left_id`/`column_right_id` are the form the server stores;
when they're absent, the server resolves `from_column`/`to_column` against
the target table's own columns before saving
(`api/cli/v2/routes/app.ts`). Writing names alone wires the join, there is
no id lookup required first.

`column_left_id`/`column_right_id`, when you do write them, are **not
scoped to this app**: they are the DW table's own column ids
(`dw_table_column`), created together with the table itself, the same id
everywhere that column is referenced across the tenant. `lumo info
app:<id> --full` is currently the only place lumo prints them (for tables
already on that app); `lumo columns table:<id>` shows names, types and
masks, not ids:

```bash
lumo info app:<id> --full
```

Reading them back is useful to have the numeric form on file for the
record, but it is not a prerequisite: writing `from_column`/`to_column`
and pushing wires the relationship in one step.

`relationshipType` is accepted but decorative through `lumo push`: the
server always stores the row as `ONE_TO_MANY` on create and never updates
it on a later push, whatever value is written here. Direction comes from
which table is `from_table_id` (the "1" side) versus `to_table_id` (the
"N" side), not from this field.

**Why:** Without `ignoreTime`, the join compares full timestamps including the time component.
Since DATE columns store `2024-01-15 00:00:00` and timestamps may vary, filters fail to
propagate correctly. This is the #1 cause of "date filter doesn't work" issues.

### Two relationships between the same pair of tables

The #2 cause, and it fails silently. The classic setup: `fato_vendas` joined to
`dim_calendario` by both `DATA_EMISSAO` and `DATA_ENTREGA`.

**The order of `relationships[]` in the YAML is the contract that decides which
join the engine uses.** The engine collapses every relationship between a pair
of tables into a single join and takes the FIRST entry within that pair. Array
position is exactly what the server records as `display_order` on every push,
and it survives clone and publish unchanged.

What you get depends on which side repeats:

| Both relationships point at… | Result |
|---|---|
| the **same** calendar column | one is silently dropped; the survivor is whichever comes first in the array |
| **different** calendar columns | both conditions are **ANDed into one ON clause**, near-certainly zero rows |

A measure that quietly returns 0, or a number that is right in one widget and wrong
in another, is usually this.

**To change which relationship is primary:** reorder the array so the one you want
to win comes first within the pair, and push. One push moves `display_order` on
the server; there is no soft-delete-and-recreate step to run first.

**When only some measures need the other join, prefer `use()` instead of
reordering.** It is explicit and scoped to one expression, not tied to array
order at all:

```sql
use([dim_calendario], [fato_vendas]."DATA_ENTREGA") SUM([fato_vendas]."TOTAL")
```

**Caveat: role-playing dimensions.** The ordering above is scoped per pair of
`bi_app_table` rows, not per DW table. If the same DW table was added to the app
TWICE as a role-playing dimension (a calendar dragged onto the canvas a second
time, say), the two `display_order` sequences land in the same matrix cell and
interleave, so order is not guaranteed in that specific setup. A single calendar
joined to a single fact by two date columns, the classic case above, stays a
single pair and is fully covered by the rule.

**Rule of thumb:** if BOTH columns are DATE type, always set `ignoreTime: true`.

### Filter Propagation Rules

1. **Dimension → Fact:** Filters on dimension tables always propagate to connected facts
2. **Dimension → Dimension:** Only propagates if the query includes a fact table connecting both
3. **Fact → Dimension:** Filters on fact tables do NOT propagate to dimension-only queries
4. **Chain propagation:** Category → Product → Sales works automatically

### Critical Anti-Pattern: Never Connect Facts Directly

```
WRONG:  fact_sales ──── fact_goals    → Cartesian explosion!
                                        1000 sales × 500 goals = 500,000 rows

RIGHT:  dim_calendar ──► fact_sales
        dim_calendar ──► fact_goals   → Each fact relates independently
```

When two fact tables share a dimension, connect each fact to the dimension separately.
The dimension serves as a bridge for cross-fact analysis.

### Linking Tables (Many-to-Many)

Horus doesn't support direct N:N. Use intermediate linking tables:

```
dim_students ──1:N──► link_student_course ◄──N:1── dim_courses
```

Mark the intermediate table as "Linking Table (n-n)" in the table configuration
to relax the uniqueness requirement.

### Secondary Relationships

A table can have multiple join paths to the same dimension:

```
dim_calendar.DATA ──► fact_opportunities.DATA_ABERTURA    (default)
dim_calendar.DATA ──► fact_opportunities.DATA_GANHO       (secondary)
dim_calendar.DATA ──► fact_opportunities.DATA_PERDA       (secondary)
```

Access secondary relationships in expressions with the `use()` function:
```sql
use([dim_calendar], [fact_opportunities].DATA_GANHO) SUM([fact_opportunities].VALOR)
```

## Calendar Table

A calendar dimension table is essential for any time-based analysis. It bridges
fact tables that have different date columns.

### Why Calendar Tables Matter

Without a calendar:
- fact_sales (DATA_VENDA) and fact_goals (DATA_META) can't be compared temporally
- Each fact would need its own date dimension, duplicating configuration

With a calendar:
- Both facts connect to the same dim_calendar
- Filters on dim_calendar automatically propagate to both facts
- Enables year-over-year, month-over-month comparisons

### Calendar Table Structure

Generate with `lumo scaffold calendar`. Standard columns:

| Column | Type | Example | Description |
|--------|------|---------|-------------|
| DATA | date | 2024-01-15 | Primary key (YYYY-MM-DD) |
| ANO | int | 2024 | Year |
| MES | int | 1 | Month number |
| DIA | int | 15 | Day of month |
| DIA_SEMANA | string | Segunda | Day name (locale) |
| DIA_SEMANA_NUM | int | 1 | 1=Monday, 7=Sunday |
| SEMANA_ANO | int | 3 | ISO week number |
| TRIMESTRE | int | 1 | Quarter (1-4) |
| SEMESTRE | int | 1 | Half-year (1-2) |
| ANO_MES | string | 2024-01 | Year-month |
| FIM_SEMANA | boolean | false | Weekend flag |

### Calendar Table Best Practices

- Generate at least 5 years ahead (e.g., 2020–2030)
- Include fiscal year columns if the business uses non-calendar fiscal periods
- Include holiday flags for operational analysis
- Use as the primary time dimension — connect all date-bearing facts to it

## Expressions (Calculated Metrics)

Expressions define calculated fields using SQL-like syntax on the Horus expression engine.

### Column Reference Syntax

```sql
[TableName]."COLUMN_NAME"              -- Physical column
[TableName].[ExpressionName]           -- Reference another expression
["ExpressionName"]                     -- Shorthand for same-table expression
```

### Basic Aggregations

```sql
SUM([fact_sales].TOTAL)
COUNT([fact_sales].ID)
AVG([fact_sales].UNIT_PRICE)
MIN([fact_sales].DATA_VENDA)
MAX([fact_sales].DATA_VENDA)
COUNT(DISTINCT [fact_sales].CUSTOMER_ID)
```

### Arithmetic and Logic

```sql
-- Calculated metric
SUM([fact_sales].TOTAL) / COUNT([fact_sales].ID)

-- Conditional
CASE
  WHEN SUM([fact_sales].TOTAL) > 100000 THEN 'High'
  WHEN SUM([fact_sales].TOTAL) > 50000  THEN 'Medium'
  ELSE 'Low'
END
```

### Analytical Expressions with `${...}`

`${ MEASURE , CONDITION [, CONDITION …] }` gives one measure its own filter
window, independent of the dashboard's period filter. It compiles to an extra
UNION branch that inherits the widget's WHERE and GROUP BY, with the columns
you name swapped out.

```sql
-- Year-over-Year: current period vs same period last year
SUM([fact_sales]."TOTAL") /
${SUM([fact_sales]."TOTAL"),
  [dim_calendar]."DATA" BETWEEN ADDYEARS(<[dim_calendar]."DATA":START, CURRENT_DATE>, -1)
                            AND ADDYEARS(<[dim_calendar]."DATA":END, CURRENT_DATE>, -1)}

-- Year-to-Date
${SUM([fact_sales]."TOTAL"),
  [dim_calendar]."DATA" BETWEEN YEARSTART(<[dim_calendar]."DATA":START, CURRENT_DATE>)
                            AND <[dim_calendar]."DATA":END, CURRENT_DATE>}

-- Full year even while the dashboard is filtered to a single month
${SUM([fact_sales]."TOTAL"),
  [dim_calendar]."DATA" BETWEEN YEARSTART(<[dim_calendar]."DATA":START, TODAY()>)
                            AND YEAREND(<[dim_calendar]."DATA":END, TODAY()>)}
```

**The block holds two different languages.** The split is the first top-level
comma, not the braces:

| Part | Language | `TODAY()` / `ADDDAYS()` / `CURRENT_DATE` |
|---|---|---|
| before the comma (the measure) | Doris SQL | **no** — they don't exist in Doris |
| after the comma (the value side of each condition) | Horus JS DSL | **yes** |

Functions available on the value side (case-insensitive, all date results
`YYYY-MM-DD`): `TODAY()`, `CURRENT_DATE`, `ADDDAYS`, `ADDMONTHS`, `ADDYEARS`,
`SUBDAYS`, `SUBMONTHS`, `SUBYEARS`, `MONTHSTART`/`FIRSTDAYOFMONTH`,
`MONTHEND`/`LASTDAYOFMONTH`, `YEARSTART`, `YEAREND`, `QUARTERSTART`,
`QUARTEREND`, `WEEKSTART`, `WEEKEND`, `DAYOFWEEK`, `DAYOFMONTH`, `MONTH`,
`YEAR`, `DATEDIFF`, `MIN`, `MAX`.

Gotchas that cost real debugging time:

- **An unknown function name does not error** — it is passed through as a
  literal and silently parsed as today. Typos produce wrong numbers, never a
  message. Same for a malformed date literal.
- **`DATEDIFF(a,b)` here is `b − a`**, the opposite of Doris's `DATEDIFF`.
- **The override is keyed on `columnId:granularity`**, and a date condition is
  always normalised to `:DATE`. If the dashboard filters by Year-Month or Year,
  the key doesn't match and your condition is **ANDed on top** instead of
  replacing — the window silently intersects, often to zero. This is the most
  common reason a YoY measure returns 0.
- **Conditions are ANDed. There is no OR** and no parenthesised grouping — use
  `IN (...)`.
- **No nesting, and no `}` anywhere inside the block** (including inside a
  string literal) — the extractor stops at the first closing brace.
- **Usable operators:** `=`, `IN`, `NOT IN`, `LIKE`, `NOT LIKE`, `BETWEEN`, and
  space-padded `>` / `<`. `!=`, `>=`, `<=` are mis-split by the parser and
  throw "Coluna … não encontrada em expressão analítica".
- **`BETWEEN … AND …` splits on the literal substring "AND"**, so a table or
  column name containing those letters (DEMANDA, BRAND, COMMAND) inside a
  BETWEEN clause splits in the wrong place.
- **Every `${…}` is wrapped in an outer `SUM()`** when the branches merge, so a
  non-additive inner aggregate won't behave as written.
- **`use(...)` composes** with `${...}` — it reroutes the analytical branch's
  join too.

**`${EXPR}` with no condition is NOT a grand total.** The branch copies the
widget's GROUP BY verbatim, so it is identical to the plain measure and
`SUM(x) / ${SUM(x)} * 100` returns a constant 100%. Participation is a widget
feature, not an expression: use `enableVerticalAnalysis` on a Matrix, or
`showDataLabelsPercent` on a Pie. See `lumo scaffold expression participation`.

### Reading Applied Filters

```sql
<[dim_calendar]."DATA":START, CURRENT_DATE> -- start of the active filter
<[dim_calendar]."DATA":END, CURRENT_DATE>   -- end of the active filter
<[dim_stores]."STATE", ''>                  -- non-date: the active IN/= list
```

**The default argument is mandatory.** `<[dim_calendar]."DATA":START>` is a
parse error (`expected ',', found '>'`). Omitting `:START`/`:END` defaults to
`:END`. If a filter exists but uses an operator the reader doesn't handle
(`>`, `<`, `IS NULL`, or a `BETWEEN` on a Year-Month filter), the result is
`undefined` — the default is **not** used and the string `"undefined"` reaches
SQL.

### The `use()` Function (Secondary Relationships)

Switches to an alternate join path for a specific calculation:

```sql
-- Revenue by close date instead of default open date
use([dim_calendar], [fact_opportunities].DATA_GANHO) SUM([fact_opportunities].VALOR)
```

Requirements:
- The alternate column must be configured as a secondary key in the Tables tab
- Only accepts physical columns (not expressions)
- No composite keys
- One column per `use()`

### Date Functions

An expression is compiled to SQL and executed by **Doris**, so the vocabulary is
Doris's, not a Horus-specific one. Reach for a name that "looks like" a BI function and
you get `No matching function` at query time, after the app is already built.

**Confirmed working (Doris built-ins):**

| Function | Description | Example |
|----------|-------------|---------|
| CURDATE() | Current date | CURDATE() |
| DATE_ADD(date, INTERVAL n unit) | Shift a date | DATE_ADD(CURDATE(), INTERVAL 90 DAY) |
| DATE_SUB(date, INTERVAL n unit) | Shift backward | DATE_SUB(CURDATE(), INTERVAL 7 DAY) |
| DATEDIFF(d1, d2) | Days between | DATEDIFF(fim, inicio) |

`unit` accepts `DAY`, `MONTH`, `YEAR` and the other Doris interval units, so
`DATE_ADD(x, INTERVAL -1 MONTH)` covers what a month-shift helper would do.

**Do NOT use — these do not exist in the engine:**

| Written | What the engine answers | Use instead |
|---|---|---|
| `TODAY()` | `No matching function today()` | `CURDATE()` |
| `CURRENT_DATE` | `Unknown column 'CURRENT_DATE'` | `CURDATE()` |
| `ADDDAYS(date, n)` | `No matching function adddays(date, tinyint)` | `DATE_ADD(date, INTERVAL n DAY)` |
| `ADDMONTHS` / `ADDYEARS` | same class of error | `DATE_ADD(date, INTERVAL n MONTH \| YEAR)` |
| `YEARSTART` / `YEAREND` / `MONTHSTART` / `MONTHEND` / `QUARTERSTART` | same class of error | compose with `DATE_TRUNC` / `DATE_ADD`, or precompute the boundary as a column in `dim_calendario` |

> Boundary columns (first day of month, last day of year, is-current-year flag) belong in
> the calendar dimension, computed once by the ETL. Cheaper to read, cheaper to query, and
> it keeps expressions free of engine-specific date arithmetic.

Validate any expression before wiring a widget to it:

```bash
lumo app query app:<id> --sql-only   # shows the generated SQL without running it
```

### Common Expression Patterns

**Revenue:**
```sql
SUM([fact_sales].TOTAL)
```

**Average Ticket:**
```sql
SUM([fact_sales].TOTAL) / COUNT(DISTINCT [fact_sales].TRANSACTION_ID)
```

**YoY Growth %:**
```sql
(SUM([fact_sales]."TOTAL") -
 ${SUM([fact_sales]."TOTAL"),
   [dim_calendar]."DATA" BETWEEN ADDYEARS(<[dim_calendar]."DATA":START, CURRENT_DATE>,-1)
                             AND ADDYEARS(<[dim_calendar]."DATA":END, CURRENT_DATE>,-1)}) /
${SUM([fact_sales]."TOTAL"),
  [dim_calendar]."DATA" BETWEEN ADDYEARS(<[dim_calendar]."DATA":START, CURRENT_DATE>,-1)
                            AND ADDYEARS(<[dim_calendar]."DATA":END, CURRENT_DATE>,-1)} * 100
```

**Month-over-Month:**
```sql
SUM([fact_sales]."TOTAL") -
${SUM([fact_sales]."TOTAL"),
  [dim_calendar]."DATA" BETWEEN ADDMONTHS(<[dim_calendar]."DATA":START, CURRENT_DATE>,-1)
                            AND ADDMONTHS(<[dim_calendar]."DATA":END, CURRENT_DATE>,-1)}
```

**YTD (start of year → end of the active filter):**
```sql
${SUM([fact_sales]."TOTAL"),
  [dim_calendar]."DATA" BETWEEN YEARSTART(<[dim_calendar]."DATA":END, CURRENT_DATE>)
                            AND <[dim_calendar]."DATA":END, CURRENT_DATE>}
```

## Widget Data Configuration

### Widget Column References (for app query and chatbot)

Format: `columnId:expression`

| Expression | Type | Description |
|-----------|------|-------------|
| `:AGP` | Dimension | Row-level value (GROUP BY) |
| `:SUM` | Aggregation | Sum |
| `:AVG` | Aggregation | Average |
| `:COUNT` | Aggregation | Count |
| `:MIN` | Aggregation | Minimum |
| `:MAX` | Aggregation | Maximum |
| `:DATE` | Date | Full date |
| `:YEARMONTH` | Date | Year-month grouping |
| `:YEAR` | Date | Year grouping |
| `:MONTH` | Date | Month grouping |
| `:WEEKDAY` | Date | Day of week |
| `:HOUR` | Date | Hour grouping |
| `:EXPR` | Expression | Application expression |

### Widget Types

| Type | Best For |
|------|----------|
| Kpi | Single important number with optional variation |
| Bar / Line / Area | Time series, comparisons, trends |
| Pie / Doughnut | Categorical proportions |
| Table | Detailed tabular data with sorting |
| Matrix | Pivot table (row + column dimensions) |
| ComposedMatrix | Financial statement (DRE/P&L): authored rows × time columns |
| Map | Geographic data visualization |
| Gauge | Progress toward a target |
| ListBox | Interactive filter widget |
| Calendar | Date-based heat maps |
| Punchcard | Time-of-day activity patterns |
| Svelte | Custom JavaScript visualizations |

### Dashboard Grid

Dashboards use a responsive grid system:
- Column options: 12, 24, 36, or 60
- Each widget has position (x, y) and size (w, h)
- 12-column grid is default and works well for most layouts
- Use 24+ columns for dense dashboards with many small widgets

## Common Modeling Patterns

### Retail / E-commerce
```
dim_calendar ──► fact_sales ◄── dim_customers
                     │
dim_products ────────┘  ◄── dim_stores

Expressions:
  Revenue = SUM([fact_sales].TOTAL)
  Avg Ticket = SUM([fact_sales].TOTAL) / COUNT(DISTINCT [fact_sales].TRANSACTION_ID)
  YoY Growth = (Revenue - ${Revenue, year-1}) / ${Revenue, year-1} * 100
```

### Financial / Accounts Receivable
```
dim_calendar ──► fact_receivables ◄── dim_customers
                      │
dim_cost_centers ─────┘

Key: Temporal load on data_emissao (issue date, immutable)
NEVER partition by data_vencimento (due date changes on renegotiation)
```

### HR / People Analytics
```
dim_calendar ──► fact_headcount ◄── dim_departments
                     │
dim_employees ───────┘ ◄── dim_positions

Use monthly snapshots (Total load type, small data)
```

### IoT / Time Series
```
dim_calendar ──► fact_readings ◄── dim_sensors
                     │
dim_locations ───────┘

Incremental load with created_at tracking
unique_key on (sensor_id + timestamp) for upsert
```

## Anti-Patterns to Avoid

| Anti-Pattern | Why | Fix |
|---|---|---|
| Fact → Fact relationship | Cartesian explosion | Use shared dimension as bridge |
| Snowflake chain (4+ levels) | Slow queries, complex filters | Denormalize into single dimension |
| Date as STRING column | Can't use Temporal load, no date functions | Use DATE type |
| Mutable partition column | Duplicates in Temporal load | Use immutable date (created_at, data_emissao) |
| Calculated columns in ETL | Rigid, can't change without re-ETL | Use BI expressions instead |
| One mega-table (no star schema) | No filter propagation, slow | Split into fact + dimensions |
| Missing calendar table | Can't do YoY/MoM comparisons | Always create dim_calendar |
| Expression referencing raw SQL | Not portable, hard to maintain | Use Horus expression syntax |

## External Documentation (docs.horusbi.com.br)

### Data Modeling & BI
- [Tables & Relationships](https://docs.horusbi.com.br/dataviz/02-apps/tables.md) — How to add tables, configure relationships, set types (fact/dimension)
- [Data Modeling Guide](https://docs.horusbi.com.br/dataviz/02-apps/data-modeling.md) — Star schema, filter propagation, best practices
- [Expressions](https://docs.horusbi.com.br/dataviz/02-apps/expressions.md) — Calculated metrics syntax, ${} analytical, date functions
- [Dashboards](https://docs.horusbi.com.br/dataviz/02-apps/dashboards.md) — Grid layout, filters, bookmarks
- [Creating Apps](https://docs.horusbi.com.br/dataviz/01-getting-started/create-app.md) — Step-by-step app creation

### Widgets
- [Widget Overview](https://docs.horusbi.com.br/dataviz/03-widgets.md) — All widget types
- [KPI](https://docs.horusbi.com.br/dataviz/03-widgets/kpi.md) · [Bar/Line](https://docs.horusbi.com.br/dataviz/03-widgets/bar-line.md) · [Pie](https://docs.horusbi.com.br/dataviz/03-widgets/pie.md) · [Table](https://docs.horusbi.com.br/dataviz/03-widgets/table.md) · [Matrix](https://docs.horusbi.com.br/dataviz/03-widgets/matrix.md) · [Map](https://docs.horusbi.com.br/dataviz/03-widgets/map.md) · [Gauge](https://docs.horusbi.com.br/dataviz/03-widgets/gauge.md) · [Svelte Custom](https://docs.horusbi.com.br/dataviz/03-widgets/svelte-custom.md)

### ETL
- [ETL Processors](https://docs.horusbi.com.br/etl/processors.md) — All node types (inputs, transforms, outputs)
- [SQL/DuckDB Transform](https://docs.horusbi.com.br/etl/processors/transforms/sql-duckdb.md) · [Python Transform](https://docs.horusbi.com.br/etl/processors/transforms/python.md) · [HTTP Request](https://docs.horusbi.com.br/etl/processors/inputs/http-request.md)
- [Agents](https://docs.horusbi.com.br/etl/guides/agentes.md) — Installation, configuration, troubleshooting
- [Database Connections](https://docs.horusbi.com.br/etl/guides/conexoes-banco.md) — Credential types, connection strings
- [Desks & Publishing](https://docs.horusbi.com.br/etl/guides/mesas-publicacao.md) — Publishing workflow, desk types
- [Execution & Scheduling](https://docs.horusbi.com.br/etl/guides/execucao-agendamentos.md) — Load types, schedules, triggers
- [Logs & Monitoring](https://docs.horusbi.com.br/etl/guides/logs-monitoramento.md) — Execution logs, error tracking
- [ETL Architecture](https://docs.horusbi.com.br/etl/architecture.md) — Communication flow, security, execution lifecycle

### Data Warehouse
- [DW Tables](https://docs.horusbi.com.br/dw/tables.md) — Column types, keys, partitions
- [DW Desks](https://docs.horusbi.com.br/dw/desks.md) — Organizing tables into desks
- [Upload Data](https://docs.horusbi.com.br/dw/getting-started/upload.md) — Manual data upload

### Governance (HEC)
- [Users & Groups](https://docs.horusbi.com.br/hec/users-groups/users.md) — User management
- [Permissions](https://docs.horusbi.com.br/hec/users-groups/permissions.md) — RBAC model, system functions, data access
- [Desks Access](https://docs.horusbi.com.br/hec/desks/mesas.md) — Desk-level permissions
