Skip to content

Add RelationRegistry API for custom relations#1186

Open
Melchyore wants to merge 5 commits into
adonisjs:22.xfrom
Melchyore:feat/custom-relations-api
Open

Add RelationRegistry API for custom relations#1186
Melchyore wants to merge 5 commits into
adonisjs:22.xfrom
Melchyore:feat/custom-relations-api

Conversation

@Melchyore

@Melchyore Melchyore commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

❓ Type of change

  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change that adds functionality)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

Add RelationRegistry API for custom relations

Summary

This PR introduces a new RelationRegistry API that allows third-party packages to register custom relation types in Lucid ORM. This unlocks powerful extensibility while maintaining full type safety and seamless integration with existing Lucid features (preload, whereHas, withCount, serialization, etc.).

Motivation

Currently, Lucid supports five built-in relation types (hasOne, hasMany, belongsTo, manyToMany, hasManyThrough). However, users frequently request specialized relations like:

  • Polymorphic relations (morphTo, morphMany, morphToMany)
  • Hierarchical relations (ancestors, descendants, tree structures)
  • Custom join strategies (lateral joins, recursive CTEs)

Without an extension API, these features would require:

  1. Forking Lucid (not sustainable)
  2. Monkey-patching internal APIs (fragile)
  3. Building separate ORMs (ecosystem fragmentation)

This PR solves the problem by providing an official, supported API for custom relations.


Changes Overview

1. Core API: RelationRegistry Class

New file: src/orm/relations/relation_registry.ts

A centralized registry for managing custom relation types.

Key methods:

  • register(type, factory) - Register a new relation type
  • get(type) - Retrieve a relation factory
  • has(type) - Check if a relation type exists
  • getManyRelationTypes() - Get all "many" relation types (for preloader)
  • unregister(type) - Remove a relation (for testing)
  • clear() - Clear all custom relations (for testing)

Design decisions:

  1. Static methods - Relations are global, no need for instances
  2. Factory pattern - Registry stores factories, not instances

Why this approach?

  • Simple API surface (6 methods)
  • No breaking changes to existing code
  • Easy to test (can clear registry between tests)
  • Type-safe (TypeScript knows all registered relations)

2. Helper: createRelationDecorator Function

New file: src/orm/decorators/create_relation_decorator.ts

A utility to create type-safe relation decorators.

Usage:

// In your custom relation package
export const myRelation = createRelationDecorator('myRelation')

// Users can now use it like built-in relations
class User extends BaseModel {
  @myRelation(() => Post, { /* options */ })
  declare posts: MyRelation<typeof Post>
}

Type constraints:

  • Generic parameter TRelationType is constrained to ModelRelationTypes['__opaque_type']
  • This ensures only registered relation types can be used
  • Provides autocomplete for relation type names

Why a helper?

  • Eliminates boilerplate (decorators are verbose)
  • Ensures consistency with built-in decorators
  • Maintains type safety across custom relations

3. Type System Integration

Modified file: src/types/relations.ts

Added two new interfaces for module augmentation:

A. KnownCustomRelations

export interface KnownCustomRelations {}

Purpose: Register relation contracts (implementation classes)

Used in: RelationshipsContract union type

Contains: Relation class types with methods like boot(), eagerQuery(), setRelated()

Example augmentation:

declare module '@adonisjs/lucid/types/relations' {
  interface KnownCustomRelations {
    myRelation: MyRelationContract<LucidModel, LucidModel>
  }
}

B. KnownCustomOpaqueRelations

export interface KnownCustomOpaqueRelations {}

Purpose: Register relation opaque types (user-facing types)

Used in: ModelRelations union type

Contains: Opaque array types representing the relation data

Example augmentation:

declare module '@adonisjs/lucid/types/relations' {
  interface KnownCustomOpaqueRelations {
    myRelation: MyRelation<LucidModel>  // e.g., Post[] with extra properties
  }
}

Why Two Interfaces?

They serve different roles in the type system:

Interface Purpose Used By Contains
KnownCustomRelations Internal Lucid operations Preloader, serializer, $getRelation() Class contracts with methods
KnownCustomOpaqueRelations User model declarations Model properties, query results Opaque data types (arrays)

4. Factory Types

Modified file: src/types/relations.ts

Added two new interfaces for relation factories:

A. RelationFactoryConfig

export interface RelationFactoryConfig<T> {
  isMany: boolean
  create(relationName, relatedModel, options, model): T
}

Purpose: Configuration passed to RelationRegistry.register()

B. RelationFactory

export interface RelationFactory<T> extends RelationFactoryConfig<T> {
  type: string  // Injected by registry
}

Purpose: Complete factory stored in registry

Note: Includes type property

Why separate?

  • Eliminates redundant type specification
  • Registry injects type automatically
  • Cleaner API for users

5. Extensible Union Types

Modified file: src/types/relations.ts

Made union types extensible by including custom relations:

Before (closed unions):

type ModelRelations = HasOne | HasMany | BelongsTo | ManyToMany | HasManyThrough
type RelationshipsContract = HasOneContract | HasManyContract | ...

❌ Cannot add custom relations

After (open unions):

type ModelRelations =
  | HasOne
  | HasMany
  | BelongsTo
  | ManyToMany
  | HasManyThrough
  | KnownCustomOpaqueRelations[keyof KnownCustomOpaqueRelations]  // ✅ Extensible

type RelationshipsContract =
  | HasOneRelationContract
  | HasManyRelationContract
  | ...
  | KnownCustomRelations[keyof KnownCustomRelations]  // ✅ Extensible

Why this matters:

  • TypeScript can now type-check custom relations
  • Autocomplete works for custom relation methods
  • No any types needed

6. BaseModel Integration

Modified file: src/orm/base_model/index.ts

Updated $addRelation() to support custom relations:

Changes:

  1. Refactored built-in relation handling

    private static $addBuiltInRelation(name, type, relatedModel, options) {
      // Existing switch statement for hasOne, hasMany, etc.
      // Returns relation if matched, null otherwise
    }
  2. Check registry for custom relations

    static $addRelation(name, type, relatedModel, options) {
      // Try built-in first
      const builtIn = this.$addBuiltInRelation(name, type, relatedModel, options)
      if (builtIn) return builtIn
    
      // Check registry for custom relations
      const factory = RelationRegistry.get(type)
      if (!factory) {
        throw new Error(`"${type}" is not a supported relation type. Did you forget to register it?`)
      }
    
      const relation = factory.create(name, relatedModel, options, this)
      this.$relationsDefinitions.set(name, relation)
      return relation
    }
  3. Replaced static MANY_RELATIONS with getManyRelations() to support custom relations

    // Before (static)
    const MANY_RELATIONS = ['hasMany', 'manyToMany', 'hasManyThrough']  // ❌ Computed once at module load
    
    // After (dynamic)
    function getManyRelations(): string[] {
      return ['hasMany', 'manyToMany', 'hasManyThrough', ...RelationRegistry.getManyRelationTypes()]
    }
    // ✅ Includes newly registered relations

Why these changes?

  • Backward compatible (built-in relations work unchanged)
  • Clear error messages for unregistered relations
  • Dynamic discovery of "many" relations for preloader
  • No performance impact (registry lookup is O(1))

7. Type System Compatibility

Modified files:

  • src/types/model.ts - Updated $relationsDefinitions signature
  • src/types/relations.ts - Added setRelated, pushRelated, setRelatedForMany to BaseRelationContract
  • src/factories/factory_model.ts - Added type assertions for backward compatibility

Why needed?

  • Ensure custom relations work with all Lucid features
  • Maintain strict TypeScript checking
  • Support model factories with custom relations

8. Exports

Modified file: src/orm/main.ts

Added new exports:

export { createRelationDecorator } from './decorators/create_relation_decorator.js'
export { RelationRegistry } from './relations/relation_registry.js'
export type { RelationFactory, RelationFactoryConfig } from '../types/relations.js'

Modified file: src/orm/relations/main.ts

export { BaseQueryBuilder as RelationBaseQueryBuilder } from './base/query_builder.js'
export { BaseSubQueryBuilder as RelationBaseSubQueryBuilder } from './base/sub_query_builder.ts'
export { KeysExtractor } from './keys_extractor.js'

9. Tests

New file: test/orm/relation_registry.spec.ts

Comprehensive test coverage:

  • ✅ Register custom relation
  • ✅ Prevent duplicate registration
  • ✅ Get/has methods work correctly
  • ✅ getManyRelationTypes filters correctly
  • ✅ Unregister removes relations
  • ✅ Integration with BaseModel.$addRelation
  • ✅ createRelationDecorator works
  • ✅ Custom relations work with preload
  • ✅ Custom "many" relations work correctly

Breaking Changes

None. This PR is 100% backward compatible.

  • Existing relations work unchanged
  • No API changes to user-facing code
  • No database schema changes

Performance Considerations

  1. Registry lookup: O(1) Map lookup, negligible overhead
  2. Dynamic MANY_RELATIONS: Called once per model boot, minimal impact
  3. Type checking: Zero runtime cost (TypeScript only)
  4. Memory: Minimal (stores factory functions, not instances)

Type Safety

This PR maintains full type safety:

✅ Autocomplete for custom relation names in $addRelation()

✅ Type-checked relation options

✅ Inferred return types for relation queries

✅ Autocomplete in preload callbacks

✅ Type-safe access to related models

How?

  • Module augmentation extends Lucid's type system
  • Opaque types preserve relation type information
  • Constrained generics prevent invalid relation types

Testing Checklist

  • Unit tests for RelationRegistry
  • Integration tests with BaseModel
  • Type checking tests (TypeScript compilation)
  • Real-world example (ancestors relation)
  • Documentation with complete example
  • Backward compatibility verified
  • Performance benchmarks (no regression)

Summary of Files Changed

New Files (6)

  • src/orm/relations/relation_registry.ts - Core registry implementation
  • src/orm/decorators/create_relation_decorator.ts - Decorator helper
  • test/orm/relation_registry.spec.ts - Registry tests

Modified Files (6)

  • src/orm/base_model/index.ts - Custom relation support in $addRelation
  • src/orm/main.ts - Export new APIs
  • src/types/relations.ts - Type augmentation interfaces, extensible unions
  • src/types/model.ts - Updated signatures
  • src/factories/factory_model.ts - Type compatibility
  • src/orm/relations/main.ts - (if any changes)

Other notes

  • If this PR is accepted, I'll open another PR to backport these features to v21, and another for the documentation.

Summary by CodeRabbit

  • New Features
    • Added runtime support for registering and using custom relationship types via a relation registry.
    • Added a decorator factory to declare custom model relationships.
    • Expanded public exports for the relation registry, relation factory types, and relation query utilities.
  • Bug Fixes
    • Improved handling of “many” relationships using explicit multiplicity behavior, including related collection assignment and preloading.
    • Enhanced error guidance when an unregistered relationship type is used.
  • Tests
    • Added/updated coverage for registry behavior, decorator bootstrapping, and “many” collection semantics.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f9c2613a-10a4-4bf0-b903-aef081365947

📥 Commits

Reviewing files that changed from the base of the PR and between 0296b58 and 4edf3cf.

📒 Files selected for processing (10)
  • src/orm/base_model/index.ts
  • src/orm/relations/belongs_to/index.ts
  • src/orm/relations/has_many/index.ts
  • src/orm/relations/has_many_through/index.ts
  • src/orm/relations/has_one/index.ts
  • src/orm/relations/many_to_many/index.ts
  • src/orm/relations/relation_registry.ts
  • src/types/relations.ts
  • test/orm/orm_schema_builder.spec.ts
  • test/orm/relation_registry.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/types/relations.ts

📝 Walkthrough

Walkthrough

Custom relation factory contracts, runtime registration, decorator creation, model resolution, instance-based many-relation handling, public exports, and integration tests were added. Existing factory relation construction now applies explicit casts.

Changes

Custom relation support

Layer / File(s) Summary
Relation contracts and multiplicity
src/types/relations.ts, src/types/model.ts, src/orm/relations/*
Adds factory contracts, custom relation augmentation points, broader assignment signatures, narrowed relation definition typings, and explicit isMany flags for built-in relations.
Relation registry storage
src/orm/relations/relation_registry.ts
Adds registration, lookup, duplicate detection, built-in-type protection, and removal APIs for custom relation factories.
Model relation resolution and assignment
src/orm/base_model/index.ts, src/factories/factory_model.ts
Resolves built-in or registered custom relations, uses relation-instance multiplicity during assignment and preloading, and applies explicit factory relation casts.
Decorator exports and validation
src/orm/decorators/create_relation_decorator.ts, src/orm/main.ts, src/orm/relations/main.ts, test/orm/relation_registry.spec.ts, test/orm/orm_schema_builder.spec.ts
Adds the custom relation decorator and public exports, with tests covering registry behavior, bootstrapping, missing registrations, many-relation assignment, and unchanged schema metadata.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Decorator as createRelationDecorator
  participant Model as BaseModelImpl
  participant Registry as RelationRegistry
  participant Factory as RelationFactory
  Decorator->>Model: register relation with $addRelation
  Model->>Registry: look up relation type
  Registry-->>Model: return registered factory
  Model->>Factory: create relation
  Factory-->>Model: return relation contract
Loading

Suggested reviewers: thetutlage

Poem

A rabbit hops through types so neat,
Custom relations bloom and meet.
The registry keeps each link in line,
Many-valued hops now work just fine.
Decorators twirl, tests cheer—
“Hop-hop!” says code, “the path is clear!”

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding a RelationRegistry API for custom Lucid relations.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/factories/factory_model.ts (1)

171-191: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fail explicitly for unsupported custom factory relations.

A registered custom relation reaches this switch but matches no case, so relation() returns with no relation registered. Add a default error (or a custom factory adapter contract) rather than silently dropping the requested relation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/factories/factory_model.ts` around lines 171 - 191, Update the
relation-type switch in the factory relation registration method to add a
default branch that throws an explicit error for unsupported custom relation
types. Ensure unmatched types do not return silently or leave the requested
relation unregistered, while preserving the existing built-in relation handling
and hasManyThrough error.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/orm/base_model/index.ts`:
- Around line 76-85: Update relation definition/instance handling and
$setRelated() so each relation retains its many-versus-one multiplicity when
created, rather than deriving it from the mutable RelationRegistry at assignment
time. Keep getManyRelations() for discovering registered custom relation types,
but ensure RelationRegistry.unregister(type) cannot change the behavior of
existing relations or cause valid arrays to be rejected.

In `@src/orm/relations/main.ts`:
- Around line 14-16: Update the BaseSubQueryBuilder re-export in the relations
entry point to use the emitted .js module specifier, matching the existing
BaseQueryBuilder and KeysExtractor exports; leave the exported symbol alias
unchanged.

In `@src/orm/relations/relation_registry.ts`:
- Around line 32-44: Update RelationRegistry.register to reject reserved
built-in relation type names such as hasOne and hasMany before checking for
duplicates or storing the factory. Reuse the existing built-in relation-name
definition if available, and throw a clear error for reserved names while
preserving normal registration for custom types.

---

Outside diff comments:
In `@src/factories/factory_model.ts`:
- Around line 171-191: Update the relation-type switch in the factory relation
registration method to add a default branch that throws an explicit error for
unsupported custom relation types. Ensure unmatched types do not return silently
or leave the requested relation unregistered, while preserving the existing
built-in relation handling and hasManyThrough error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7912a62d-5cce-463a-bd4a-7fd05aacfa6e

📥 Commits

Reviewing files that changed from the base of the PR and between b23d814 and 66bc92e.

📒 Files selected for processing (9)
  • src/factories/factory_model.ts
  • src/orm/base_model/index.ts
  • src/orm/decorators/create_relation_decorator.ts
  • src/orm/main.ts
  • src/orm/relations/main.ts
  • src/orm/relations/relation_registry.ts
  • src/types/model.ts
  • src/types/relations.ts
  • test/orm/relation_registry.spec.ts

Comment thread src/orm/base_model/index.ts Outdated
Comment thread src/orm/relations/main.ts
Comment thread src/orm/relations/relation_registry.ts
Relations now retain their isMany property on the relation instance itself rather than deriving it from mutable RelationRegistry state at assignment time.

Previously, if a custom relation was unregistered after model boot, $setRelated would incorrectly reject arrays because it looked up isMany from the registry. Now each relation instance stores its multiplicity, making it independent of registry state changes.

Changes:
- Add readonly isMany property to BaseRelationContract
- Add isMany = true/false to all built-in relation classes
- Replace getManyRelations().includes() checks with relation.isMany
- Remove isMany from RelationFactoryConfig (no longer needed)
- Remove RelationRegistry.getManyRelationTypes() (no longer needed)
- Add test verifying relations retain multiplicity after unregister

This ensures RelationRegistry.unregister() cannot change the behavior of existing relation instances or cause valid arrays to be rejected.
RelationRegistry.register() now rejects reserved built-in relation type names (hasOne, hasMany, belongsTo, manyToMany, hasManyThrough).

Attempting to register these names is ineffective because BaseModel.$addRelation() resolves built-ins first, so the registry entry would never be used. This prevents confusion and registry pollution.

Changes:
- Add BUILT_IN_TYPES constant in RelationRegistry.register()
- Throw clear error when attempting to register reserved names
- Add test verifying all built-in types are rejected
@Melchyore

Copy link
Copy Markdown
Contributor Author

I've addressed all issues:

1. Relation multiplicity stored on instances (not registry lookup)

Problem: Relations derived their many-vs-one multiplicity from RelationRegistry.getManyRelationTypes() at assignment time. If a custom relation was unregistered after model boot, $setRelated() would incorrectly reject arrays because the type was no longer in the registry.

Solution: Each relation instance now has a readonly isMany: boolean property. Built-in relations define it directly in their class (HasMany.isMany = true, HasOne.isMany = false, etc.). Custom relations do the same. The $setRelated() and $pushRelated() methods now check relation.isMany directly instead of looking it up.

Benefits:

  • Relations are independent of registry state changes
  • RelationRegistry.unregister() cannot affect existing relations
  • Simpler API: no need for isMany in factory config
  • Better encapsulation: multiplicity is part of the relation contract

Changes:

  • Added isMany to BaseRelationContract
  • Added isMany to all built-in relation classes
  • Removed isMany from RelationFactoryConfig
  • Removed getManyRelationTypes() (no longer needed)
  • Replaced registry lookups with relation.isMany checks
  • Added regression test

2. Reject built-in relation type names

Problem: Registering hasOne, hasMany, etc. is ineffective because BaseModel.$addRelation() resolves built-ins first. The registry entry would never be used, causing confusion.

Solution: RelationRegistry.register() now throws an error for reserved built-in type names before checking duplicates or storing the factory.

Changes:

  • Added BUILT_IN_TYPES constant
  • Clear error message listing all reserved names
  • Added test for all built-in types

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant