Joins
Joins in Drizzle Cube enable you to combine data from multiple tables and cubes, creating rich, interconnected analytics. There are two types of joins: table-level joins within individual cubes and cube-level joins for multi-cube queries.
Overview
Section titled “Overview”Drizzle Cube’s join system leverages Drizzle ORM’s type-safe join capabilities to provide secure, performant data relationships. All joins maintain security context and prevent SQL injection through parameterized queries.
Table-Level Joins
Section titled “Table-Level Joins”Table-level joins occur within a single cube’s SQL definition, allowing you to join multiple database tables into one logical dataset.
Basic Table Join Structure
Section titled “Basic Table Join Structure”sql: (ctx: QueryContext<Schema>): BaseQueryDefinition => ({ from: employees, joins: [ { table: departments, on: and( eq(employees.departmentId, departments.id), eq(departments.organisationId, ctx.securityContext.organisationId) ), type: 'left' } ], where: eq(employees.organisationId, ctx.securityContext.organisationId)})Join Types
Section titled “Join Types”Left Join - Most common, includes all records from the main table:
joins: [ { table: departments, on: eq(employees.departmentId, departments.id), type: 'left' }]Inner Join - Only records that exist in both tables:
joins: [ { table: departments, on: eq(employees.departmentId, departments.id), type: 'inner' }]Right Join - Includes all records from the joined table:
joins: [ { table: departments, on: eq(employees.departmentId, departments.id), type: 'right' }]Multi-Table Joins
Section titled “Multi-Table Joins”Join multiple tables in a single cube:
export const productivityCube: Cube<Schema> = defineCube('Productivity', { title: 'Productivity Analytics', description: 'Employee productivity with department and project data',
sql: (ctx: QueryContext<Schema>): BaseQueryDefinition => ({ from: productivity, joins: [ // Join to employees table { table: employees, on: and( eq(productivity.employeeId, employees.id), eq(employees.organisationId, ctx.securityContext.organisationId) ), type: 'left' }, // Join to departments through employees { table: departments, on: and( eq(employees.departmentId, departments.id), eq(departments.organisationId, ctx.securityContext.organisationId) ), type: 'left' }, // Join to projects table { table: projects, on: and( eq(productivity.projectId, projects.id), eq(projects.organisationId, ctx.securityContext.organisationId) ), type: 'left' } ], where: eq(productivity.organisationId, ctx.securityContext.organisationId) }),
dimensions: { employeeName: { name: 'employeeName', title: 'Employee', type: 'string', sql: employees.name }, departmentName: { name: 'departmentName', title: 'Department', type: 'string', sql: departments.name }, projectName: { name: 'projectName', title: 'Project', type: 'string', sql: projects.name } } // ... measures})Security in Table Joins
Section titled “Security in Table Joins”Critical: Always include security context filtering in join conditions:
joins: [ { table: departments, on: and( eq(employees.departmentId, departments.id), // REQUIRED: Security filtering on joined table eq(departments.organisationId, ctx.securityContext.organisationId) ), type: 'left' }]Cube-Level Joins
Section titled “Cube-Level Joins”Cube-level joins define relationships between different cubes, enabling multi-cube queries and cross-cube analysis.
Basic Cube Join Structure
Section titled “Basic Cube Join Structure”// In the Employees cubejoins: { Departments: { targetCube: 'Departments', relationship: 'belongsTo', on: [ { source: employees.departmentId, target: departments.id } ] }}
// In the Productivity cubejoins: { Employees: { targetCube: 'Employees', relationship: 'belongsTo', on: [ { source: productivity.employeeId, target: employees.id } ] }, Departments: { targetCube: 'Departments', relationship: 'belongsTo', on: [ { source: productivity.employeeId, target: employees.id }, { source: employees.departmentId, target: departments.id } ] }}Cube Reference Types
Section titled “Cube Reference Types”The targetCube property supports three reference styles:
String reference (recommended) — resolved from the compiler’s cube registry at query time. No imports needed, works across files:
targetCube: 'Departments'Lazy function reference — arrow function that returns a cube object. Useful when cubes are defined in the same file and you want compile-time type checking:
targetCube: () => departmentsCubeDirect object reference — the cube object itself. Only works when the target is already defined (no forward references):
targetCube: departmentsCubeString References (Recommended)
Section titled “String References (Recommended)”String references are the simplest way to define cross-cube joins. Cubes reference each other by name and the compiler resolves them from its registry at query time:
// employees-cube.ts — no import of departmentsCube neededexport const employeesCube = defineCube('Employees', { // ... cube definition joins: { Departments: { targetCube: 'Departments', // Resolved from registry relationship: 'belongsTo', on: [{ source: employees.departmentId, target: departments.id }] } }})
// departments-cube.ts — no import of employeesCube neededexport const departmentsCube = defineCube('Departments', { // ... cube definition joins: { Employees: { targetCube: 'Employees', // Bidirectional, no circular imports relationship: 'hasMany', on: [{ source: departments.id, target: employees.departmentId }] } }})Key Benefits:
- No Circular Dependencies: Cubes can freely reference each other across files
- No Imports Needed: Reference cubes by name — no need to import cube objects
- Bidirectional Joins: Define A→B and B→A without any special patterns
- Mixed Usage: String and function refs can coexist in the same cube
Validation: After registering all cubes, you can optionally validate that every string reference resolves:
semanticLayer.registerCube(employeesCube)semanticLayer.registerCube(departmentsCube)
// Optional: strict validation at startup (throws if any string ref is unresolved)semanticLayer.validateCubeReferences()Unresolved string references at query time are gracefully skipped with a console warning — the cube still works, it just won’t have that join path available.
Lazy Function References
Section titled “Lazy Function References”Function references use arrow functions to delay resolution until runtime. This is useful when cubes are defined in the same file and you want TypeScript to validate the reference at compile time:
// Forward declarations prevent circular references within one filelet employeesCube: Cube<Schema>let departmentsCube: Cube<Schema>
employeesCube = defineCube('Employees', { joins: { Departments: { targetCube: () => departmentsCube, // Lazy reference relationship: 'belongsTo', on: [{ source: employees.departmentId, target: departments.id }] } }})
departmentsCube = defineCube('Departments', { joins: { Employees: { targetCube: () => employeesCube, // Bidirectional relationship: 'hasMany', on: [{ source: departments.id, target: employees.departmentId }] } }})When to use function refs over string refs:
- All cubes are in the same file and you want compile-time type safety
- You prefer TypeScript to catch renamed cubes at build time
Relationship Types
Section titled “Relationship Types”belongsTo - Many-to-one relationship:
// Employee belongs to DepartmentDepartments: { targetCube: 'Departments', relationship: 'belongsTo', on: [ { source: employees.departmentId, target: departments.id } ]}hasMany - One-to-many relationship:
// Department has many EmployeesEmployees: { targetCube: 'Employees', relationship: 'hasMany', on: [ { source: departments.id, target: employees.departmentId } ]}hasOne - One-to-one relationship:
// Employee has one ProfileUserProfiles: { targetCube: 'UserProfiles', relationship: 'hasOne', on: [ { source: employees.id, target: userProfiles.employeeId } ]}belongsToMany - Many-to-many relationship through junction table:
// Employees connected to Departments through TimeEntries junction tableDepartmentsViaTimeEntries: { targetCube: 'Departments', relationship: 'belongsToMany', on: [], // Not used for belongsToMany through: { table: timeEntries, // Junction/pivot table sourceKey: [ { source: employees.id, target: timeEntries.employeeId } ], targetKey: [ { source: timeEntries.departmentId, target: departments.id } ], // Optional: Security filtering on junction table securitySql: (securityContext) => eq(timeEntries.organisationId, securityContext.organisationId) }}Key Features of belongsToMany:
- Automatic Junction Table Handling - The system transparently handles the intermediate table
- Security Context - Apply security filters to the junction table using
securitySql - Multi-Column Support - Both
sourceKeyandtargetKeysupport multiple columns for composite keys - Transparent Querying - Query dimensions from the target cube normally; the junction table is handled automatically
Example Query Using belongsToMany:
// Query employee count by department (through time entries)const result = await semanticLayer.execute({ measures: ['Employees.count'], dimensions: ['DepartmentsViaTimeEntries.name'] // Uses the many-to-many join}, securityContext)Advanced Cube Join Features
Section titled “Advanced Cube Join Features”Preferred Join Paths - When multiple paths exist between cubes, use preferredFor to specify the canonical route:
// Employees can reach Teams via two paths:// 1. Employees → Departments → Teams (department-based teams)// 2. Employees → EmployeeTeams → Teams (employee team memberships)
joins: { Departments: { targetCube: 'Departments', relationship: 'belongsTo', on: [{ source: employees.departmentId, target: departments.id }] }, EmployeeTeams: { targetCube: 'EmployeeTeams', relationship: 'hasMany', // Prefer this path when reaching Teams - uses junction table preferredFor: ['Teams'], on: [{ source: employees.id, target: employeeTeams.employeeId }] }}Without preferredFor, the query planner may choose a structurally valid path that is not the semantic path you want for the query grain.
preferredFor is applied as a first-hop preference from the current source cube. In this example it biases Employees -> EmployeeTeams when routing to Teams, which keeps team-level membership queries semantically correct.
When to use preferredFor:
- Junction tables that represent the canonical relationship (e.g., employee team memberships vs department assignments)
- When multiple paths exist but one is semantically more appropriate for the domain
- When you need deterministic routing from a specific source cube to a specific target cube
Path Selection Priority:
- Paths using joins with
preferredFortargeting the destination cube (+10 priority) - Paths through cubes used by query members (+1 per cube)
- Paths reusing already-joined cubes
- Shorter paths
preferredFor vs Cube join hints:
preferredForis schema-level and edge-local: you annotate a join edge with preferred targets- Cube join hints are query-level path hints derived from members and can carry ordered path segments
preferredForis simpler and usually sufficient for single-canonical routing decisions- In highly ambiguous graphs, add
preferredForon each relevant first hop and verify with Dry Run / Query AnalysisPath scoringoutput
Multi-Column Joins - Join on multiple columns:
joins: { ProjectTasks: { targetCube: 'ProjectTasks', relationship: 'hasMany', on: [ { source: projects.id, target: tasks.projectId }, { source: projects.version, target: tasks.projectVersion } ] }}Custom Comparators - Use different comparison operators:
joins: { Activities: { targetCube: 'Activities', relationship: 'hasMany', on: [ { source: users.id, target: activities.userId }, { source: users.createdAt, target: activities.timestamp, as: (source, target) => gte(target, source) // timestamp >= user.createdAt } ] }}Override SQL Join Type - Force specific SQL join behavior:
joins: { Departments: { targetCube: 'Departments', relationship: 'belongsTo', on: [ { source: employees.departmentId, target: departments.id } ], sqlJoinType: 'inner' // Override default 'inner' with 'left', 'right', etc. }}Multi-Cube Query Example
Section titled “Multi-Cube Query Example”Query data from multiple cubes using cube joins:
const multiCubeQuery = { measures: [ 'Employees.count', // From Employees cube 'Departments.totalBudget', // From Departments cube 'Productivity.avgLinesOfCode' // From Productivity cube ], dimensions: [ 'Departments.name', // Group by department 'Employees.isActive' // Split by active status ], timeDimensions: [{ dimension: 'Productivity.date', granularity: 'month' }]}Advanced Join Patterns
Section titled “Advanced Join Patterns”Conditional Joins
Section titled “Conditional Joins”Apply conditional logic in join conditions:
sql: (ctx: QueryContext<Schema>): BaseQueryDefinition => ({ from: orders, joins: [ { table: customers, on: and( eq(orders.customerId, customers.id), // Conditional join based on security context ctx.securityContext.userRole === 'admin' ? sql`true` : eq(customers.salesRepId, ctx.securityContext.userId), eq(customers.organisationId, ctx.securityContext.organisationId) ), type: 'left' } ], where: eq(orders.organisationId, ctx.securityContext.organisationId)})Self-Joins
Section titled “Self-Joins”Join a table to itself for hierarchical data:
sql: (ctx: QueryContext<Schema>): BaseQueryDefinition => ({ from: employees, joins: [ { table: alias(employees, 'manager'), on: and( eq(employees.managerId, sql`manager.id`), eq(sql`manager.organisationId`, ctx.securityContext.organisationId) ), type: 'left' } ], where: eq(employees.organisationId, ctx.securityContext.organisationId)}),
dimensions: { name: { name: 'name', title: 'Employee Name', type: 'string', sql: employees.name }, managerName: { name: 'managerName', title: 'Manager Name', type: 'string', sql: sql`manager.name` }}Complex Join Conditions
Section titled “Complex Join Conditions”Use complex conditions for specialized joins:
joins: [ { table: productivityTargets, on: and( eq(employees.id, productivityTargets.employeeId), eq(employees.departmentId, productivityTargets.departmentId), // Join on date range sql`${productivity.date} BETWEEN ${productivityTargets.startDate} AND ${productivityTargets.endDate}`, eq(productivityTargets.organisationId, ctx.securityContext.organisationId) ), type: 'left' }]Join Resolution and Path Finding
Section titled “Join Resolution and Path Finding”Drizzle Cube automatically resolves join paths between cubes using the JoinPathResolver:
// Automatic path finding from Productivity → Employees → Departmentsconst query = { measures: ['Productivity.totalLinesOfCode'], dimensions: ['Departments.name'] // Automatically resolves join path}Manual Join Path Control
Section titled “Manual Join Path Control”Drizzle Cube does not currently support query-level manual join path hints (for example, a joinHints parameter on the query object).
Join paths are resolved automatically from cube relationships. To guide path selection in ambiguous graphs:
- Use
preferredForon the relevant first-hop join in your cube schema - Model explicit cube-level relationships for canonical business paths (for example, membership/junction routes)
- Validate path selection in Dry Run / Query Analysis (
Path scoring). In Analysis Builder, the Debug panel shows scoring outcomes for each join path (strategy, selected rank/score, and candidate score breakdown).
// Schema-level guidance (supported): prefer membership path when routing to Teamsjoins: { EmployeeTeams: { targetCube: 'EmployeeTeams', relationship: 'hasMany', preferredFor: ['Teams'], on: [{ source: employees.id, target: employeeTeams.employeeId }] }}If query-level join hints are added in the future, they will be documented here.
Performance Optimization
Section titled “Performance Optimization”Join Order Optimization
Section titled “Join Order Optimization”Structure joins for optimal performance:
// Good: Start with most selective tablesql: (ctx: QueryContext<Schema>): BaseQueryDefinition => ({ from: activeEmployees, // Pre-filtered view joins: [ { table: departments, on: eq(activeEmployees.departmentId, departments.id), type: 'left' } ], where: eq(activeEmployees.organisationId, ctx.securityContext.organisationId)})Index-Friendly Joins
Section titled “Index-Friendly Joins”Ensure join conditions use indexed columns:
joins: [ { table: departments, // Good: uses indexed foreign key on: eq(employees.departmentId, departments.id), type: 'left' }]Testing Joins
Section titled “Testing Joins”describe('Cube Joins', () => { it('should join employees with departments', async () => { const query = { measures: ['Employees.count'], dimensions: ['Employees.departmentName'] }
const result = await semanticLayer.load(query, { organisationId: 'test-org' })
const data = result.rawData() expect(data.every(row => row['Employees.departmentName'])).toBeTruthy() })
it('should handle multi-cube queries', async () => { const query = { measures: [ 'Employees.count', 'Productivity.avgLinesOfCode' ], dimensions: ['Departments.name'] }
const result = await semanticLayer.load(query, { organisationId: 'test-org' })
const data = result.rawData() expect(data).toHaveLength(3) // 3 departments data.forEach(row => { expect(row['Employees.count']).toBeGreaterThan(0) expect(row['Productivity.avgLinesOfCode']).toBeGreaterThan(0) }) })})Best Practices
Section titled “Best Practices”- Security First: Always include security context in join conditions
- Performance: Use indexed columns for join conditions
- Type Safety: Leverage Drizzle’s type system for join validation
- Clarity: Use descriptive relationship names
- Testing: Verify join correctness and security isolation
- Documentation: Document complex join logic
- Optimization: Structure joins for query performance
Common Patterns
Section titled “Common Patterns”Basic Foreign Key Join
Section titled “Basic Foreign Key Join”joins: [ { table: departments, on: eq(employees.departmentId, departments.id), type: 'left' }]Secure Multi-Table Join
Section titled “Secure Multi-Table Join”joins: [ { table: departments, on: and( eq(employees.departmentId, departments.id), eq(departments.organisationId, ctx.securityContext.organisationId) ), type: 'left' }]Cube-Level Relationship
Section titled “Cube-Level Relationship”joins: { Departments: { targetCube: 'Departments', relationship: 'belongsTo', on: [ { source: employees.departmentId, target: departments.id } ] }}Preferred Path Through Junction Table
Section titled “Preferred Path Through Junction Table”// Use preferredFor when a junction table is the canonical pathjoins: { EmployeeTeams: { targetCube: 'EmployeeTeams', relationship: 'hasMany', preferredFor: ['Teams'], // Prefer this path from Employees when target is Teams on: [ { source: employees.id, target: employeeTeams.employeeId } ] }}Troubleshooting
Section titled “Troubleshooting”Join Issues
Section titled “Join Issues”Problem: Duplicate records in results Solution: Check for many-to-many relationships and use appropriate aggregation
Problem: Missing data after join Solution: Verify join type (left vs inner) and foreign key integrity
Problem: Security context not applied Solution: Ensure all joined tables include security filtering
Performance Issues
Section titled “Performance Issues”Problem: Slow join queries Solution: Add database indexes on join columns and optimize join order
Problem: Cartesian products Solution: Verify join conditions are specific enough
Next Steps
Section titled “Next Steps”- Learn about Security patterns for multi-tenant systems
- Explore Cubes for complete cube definitions
- Understand Dimensions and Measures
- Review database indexing strategies for optimal join performance
Roadmap Ideas
Section titled “Roadmap Ideas”- Visual join relationship designer
- Automatic join path optimization suggestions
- Join performance analysis tools
- Advanced relationship types (polymorphic, conditional)
- Join validation and testing framework