drizzle-team / drizzle-team/drizzle-orm

[FEATURE]: `mergeRelationsPart` utility to deep merge modular relation definitions created by `defineRelationsPart`

Open
#5,674 0 comments 0 reactions 0 assignees View on GitHub
enhancement
Dominant language
TypeScript
Stars
35.8k
Forks
1.6k
Avg merge
2d 7h
Merged PRs (30d)
4

Description

### Feature hasn't been suggested before.

- [x] I have verified this feature I'm about to request hasn't been suggested before.

### Describe the enhancement you want to request

## Problem Statement

With the introduction of `defineRelationsPart`, Drizzle has made it much easier to adopt Domain-Driven Design (DDD) by allowing relations to be defined alongside their specific domain schemas. However, combining these parts back together when initializing the database currently relies on standard JavaScript object spreading (`{ ...relations, ...part }`).

As highlighted in the [Drizzle documentation (Relations V2)](https://github.com/drizzle-team/drizzle-orm-docs/blob/522e18b46da91264c8c623e951faecd0c0d4be18/src/content/docs/relations-v2.mdx?plain=1#L474-587), this approach comes with two significant "gotchas" (Rules) that developers must manually navigate:

1. **Rule 1 (Order Sensitivity & Destructive Overwrites):** Because domains often need to attach relations to shared core tables (e.g., both an `identity` module and a `billing` module adding relations to the shared `users` table), object spreading causes the latter part to completely overwrite the former part's table configuration. This means `posts` relations can be silently lost if order isn't perfectly managed.
2. **Rule 2 (Exhaustiveness):** If a table exists in the schema but isn't explicitly mentioned in any `defineRelationsPart`, it will be missing from `db.query` autocomplete. Developers have to manually ensure a "main" part infers the whole schema.

### Concrete Example: The Shared Table Conflict

Consider a multi-tenant app where both `identity` and `billing` modules need to add relations to a shared `users` table:

```typescript
// identity.relations.ts
const identityPart = defineRelationsPart(schema, (r) => ({
users: {
tenants: r.many.tenants({ ... }),
}
}));

// billing.relations.ts
const billingPart = defineRelationsPart(schema, (r) => ({
users: {
invoices: r.many.invoices({ ... }),
},
}));

// ❌ CURRENT BEHAVIOR (Spread)
// The "users" key from billingPart completely replaces the one from identityPart.
// result.users only has "invoices". The "tenants" relation is silently lost.
const relations = { ...identityPart, ...billingPart };

// ✅ PROPOSED BEHAVIOR (mergeRelationsPart)
// The "users" table relations are intersected.
// result.users has BOTH "tenants" AND "invoices".
const relations = mergeRelationsPart(schema, identityPart, billingPart);
```

## Proposed Solution

Introduce a native `mergeRelationsPart` utility function that takes the database schema as its first argument, followed by a variadic list of relation parts.

```typescript
const db = drizzle(dbUrl, {
schema: tableDefinitions,
relations: mergeRelationsPart(
tableDefinitions,
identityRelationsPart,
billingRelationsPart,
invoiceRelationsPart
)
});
```

### How it solves the pain points:

1. **Fixes Rule 2 (Exhaustiveness) natively:** By accepting the `schema` object as the first parameter, the utility can automatically scaffold a base relations map for *all* tables in the schema (with empty `{}` relations). This guarantees 100% autocomplete coverage on `db.query`, regardless of what the parts contain.
2. **Fixes Rule 1 (Destructive Overwrites) natively:** As shown in the example above, instead of a shallow spread, the function performs a deep merge specifically on the `relations` property for each table key. It ensures that relation definitions from different domains are intersected rather than replaced.

### Proposed Implementation

```typescript
import {
type AnyRelationsBuilderConfig,
type ExtractTablesFromSchema,
type ExtractTablesWithRelations,
type Schema,
buildRelations,
extractTablesFromSchema,
} from 'drizzle-orm'

// ---------------------------------------------------------------------------
// Type-level helpers
// ---------------------------------------------------------------------------

/**
* A single entry produced by `defineRelationsPart`: a map of table-name keys
* to `{ table, name, relations }` objects. We only care about the `relations`
* sub-object here, so we keep the constraint loose.
*/
type RelationsPartMap = Record }>

/**
* Deep-merges two `RelationsPartMap` types:
* - Keys exclusive to A or B are passed through unchanged.
* - Keys present in **both** have their `relations` objects intersected so
* neither domain's relations are lost.
*/
type MergeParts = {
[K in keyof A | keyof B]: K extends keyof A & keyof B
? {
table: A[K & keyof A]['table']
name: A[K & keyof A]['name']
relations: A[K & keyof A]['relations'] & B[K & keyof B]['relations']
}
: K extends keyof A
? A[K]
: K extends keyof B
? B[K]
: never
}

/** Recursively folds a tuple of `RelationsPartMap` types into one merged type. */
type MergeAll = T extends [
infer Head extends RelationsPartMap,
...infer Tail extends RelationsPartMap[],
]
? MergeParts>
: {}

// ---------------------------------------------------------------------------
// Runtime implementation
// ---------------------------------------------------------------------------

/**
* Merges any number of `defineRelationsPart(…)` results into a single
* relations object suitable for passing to `drizzle(client, { relations: … })`.
*
* Two problems solved vs. the manual `{ ...a, ...b }` spread:
*
* 1. **Exhaustiveness** – The `schema` argument lets the function scaffold
* empty-relation entries for *every* table, so `db.query.` is always
* available in autocomplete even if no part mentions that table.
*
* 2. **Destructive overwrites** – When two parts both define relations for the
* same table (e.g., `identity` and `billing` each adding entries to `users`),
* the relations are deeply merged instead of the later part silently
* discarding the earlier one.
*
* @example
* ```ts
* const db = drizzle(client, {
* schema: tableDefinitions,
* relations: mergeRelationsPart(
* tableDefinitions,
* identityRelationsPart,
* billingRelationsPart,
* invoiceRelationsPart,
* ),
* });
* ```
*/
function mergeRelationsPart<
TSchema extends Record,
TParts extends RelationsPartMap[],
TTables extends Schema = ExtractTablesFromSchema,
>(schema: TSchema, ...parts: TParts): ExtractTablesWithRelations & AnyRelationsBuilderConfig, TTables> {
const tables = extractTablesFromSchema(schema)

// Runtime deep-merge: fold all parts, merging `relations` for shared keys.
const mergedConfig = parts.reduce(
(acc, part) => {
const result = { ...acc } as Record }>
for (const key of Object.keys(part)) {
if (Object.hasOwn(result, key)) {
result[key] = {
...result[key],
relations: { ...result[key].relations, ...part[key].relations },
}
} else {
result[key] = part[key]
}
}
return result
},
{} as Record }>,
)

// `buildRelations` is Drizzle's own internal helper used by `defineRelations`.
// It accepts the raw tables map + an AnyRelationsBuilderConfig and produces a
// fully-typed TablesRelationalConfig that includes *every* table in the schema
// (with empty `{}` relations for any table not present in the config).
return buildRelations(tables, mergedConfig as AnyRelationsBuilderConfig) as ExtractTablesWithRelations<
MergeAll & AnyRelationsBuilderConfig,
TTables
>
}
```

### Benefits
* **True Modularity:** Teams can drop new domain folders into their project without worrying about relation overlap on core tables.
* **Better DX:** Eliminates the need for warnings about spread order in the documentation.
* **Type Safety:** Centralizes the complex TypeScript recursive intersection logic needed to strongly type a deep array merge, rather than forcing users to write it themselves.

I'd be happy to contribute a PR for this if the maintainers agree this aligns with the vision for modular relations!

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.