Add RelationRegistry API for custom relations - #1186
Conversation
📝 WalkthroughWalkthroughThe PR adds per-model relation registries, custom relation decorators, extensible relation contracts, and cardinality-aware relation handling. Built-in relations resolve through registry factories, while custom singular relations preload as single values. ChangesCustom relation support
Estimated code review effort: 4 (Complex) | ~45 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: retrieve relation factory
Registry-->>Model: return factory
Model->>Factory: create relation
Factory-->>Model: return relation instance
Model->>Model: store relation definition
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 winFail 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
📒 Files selected for processing (9)
src/factories/factory_model.tssrc/orm/base_model/index.tssrc/orm/decorators/create_relation_decorator.tssrc/orm/main.tssrc/orm/relations/main.tssrc/orm/relations/relation_registry.tssrc/types/model.tssrc/types/relations.tstest/orm/relation_registry.spec.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
|
I've addressed all issues: 1. Relation multiplicity stored on instances (not registry lookup)Problem: Relations derived their many-vs-one multiplicity from Solution: Each relation instance now has a Benefits:
Changes:
2. Reject built-in relation type namesProblem: Registering Solution: Changes:
|
…egistry
Reworks the RelationRegistry API so that extensibility does not come at the
cost of the type system, and so that custom relations travel the same code
path as the built-in ones.
Type system
Folding "KnownCustomRelations[keyof KnownCustomRelations]" straight into
"RelationshipsContract" destroyed discriminated-union narrowing: the
registered contracts extend "BaseRelationContract", whose "type" is the
full union, so such a member survives every `case` of a
`switch (relation.type)`. That is why four "as any" casts had to be added
to factory_model.ts -- the test file's own augmentation was enough to
break narrowing inside Lucid.
BaseRelationContract is now generic over its discriminant and its
multiplicity, so every relation carries literals:
BaseRelationContract<ParentModel, RelatedModel, Type, IsMany>
Built-in contracts parameterise it ('hasOne', false) instead of
redeclaring "type", "isMany", "setRelated" and "pushRelated" by hand,
which removes 15 duplicated declarations. Custom relations use the very
same contract -- there is no separate one to learn.
Entries are then admitted to the unions only when their "type" (or
"__opaque_type") matches the key they were registered under. A malformed
augmentation collapses to "never" instead of silently disabling narrowing
for every consumer of Lucid.
"$relationsDefinitions" goes back to Map<string, RelationshipsContract>.
Widening it to BaseRelationContract lost every per-relation member and
made "setRelated(parent, [a, b])" type-check against a hasOne. A single
documented cast at the registry boundary replaces it.
Registry
Built-in relations are ordinary registry entries, seeded in the
constructor. All five already share the same constructor signature, so
"$addBuiltInRelation" and its switch were pure ceremony; "$addRelation"
is now one lookup. "$addHasOne" and friends are kept for backwards
compatibility and delegate to it, so there is still only one place a
relation gets constructed.
Registration is monotonic: a type can be added, never removed, replaced
or shadowed -- for built-in and custom relations alike. "unregister" and
"clear" could not undo anything anyway, because booted models hold
relation instances rather than factories; they only broke the next module
to define that relation. Isolation now comes from replacing the registry
("BaseModel.$relationRegistry = new RelationRegistry()") rather than
mutating a shared one, matching how $adapter and namingStrategy work.
Preloading
Preloader.processRelation branched on hardcoded 'hasOne' | 'belongsTo',
so a custom singular relation was handed the raw result array and threw
'"X.y" cannot reference more than one instance'. It now branches on
"isMany", which narrows the union at the type level too.
Also: errors moved to the errors module (E_UNSUPPORTED_RELATION_TYPE,
E_DUPLICATE_RELATION_TYPE), createRelationDecorator returns DecoratorFn to
match the other decorators, and the unused ModelRelationOptions added to
types/relations.ts is removed (it collided with the existing export in
types/model.ts).
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/types/relations.ts (1)
346-369: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winExtend
GetRelationModelInstancefor singular custom relations.
ModelRelationsincludesValidatedCustomOpaqueRelations, butGetRelationModelInstanceonly selectsRelation['instance']for'hasOne' | 'belongsTo'. Any custom relation withisMany: false, such asmorphTo, is typed asRelation['instance'][]. Carry theisManydiscriminator through the opaque type, or extend the singular selection to registered singular custom relations.🤖 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/types/relations.ts` around lines 346 - 369, Update GetRelationModelInstance to recognize registered custom relations with isMany: false, including singular opaque relations such as morphTo, and return their relation instance rather than an array. Preserve the existing hasOne/belongsTo behavior and continue returning arrays for custom relations marked isMany: true, using the existing opaque relation discriminator.
🧹 Nitpick comments (1)
src/types/model.ts (1)
806-811: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueStale doc comments after inserting
$relationRegistry. In both files,$relationRegistrywas inserted between the existing$relationsDefinitionsdoc comment and its property. Two doc blocks now stack on$relationRegistry, and$relationsDefinitionshas no doc comment.
src/types/model.ts#L806-L811: move the "A map of defined relationships" comment down to$relationsDefinitions.src/orm/base_model/index.ts#L157-L164: move the "Registered relationships for the given model" comment down to$relationsDefinitions.🤖 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/types/model.ts` around lines 806 - 811, Move the existing relationships doc comment from above $relationRegistry to directly above $relationsDefinitions in src/types/model.ts lines 806-811 and src/orm/base_model/index.ts lines 157-164. Keep the registry comment attached only to $relationRegistry and restore the appropriate documentation for $relationsDefinitions in both files.
🤖 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.
Outside diff comments:
In `@src/types/relations.ts`:
- Around line 346-369: Update GetRelationModelInstance to recognize registered
custom relations with isMany: false, including singular opaque relations such as
morphTo, and return their relation instance rather than an array. Preserve the
existing hasOne/belongsTo behavior and continue returning arrays for custom
relations marked isMany: true, using the existing opaque relation discriminator.
---
Nitpick comments:
In `@src/types/model.ts`:
- Around line 806-811: Move the existing relationships doc comment from above
$relationRegistry to directly above $relationsDefinitions in src/types/model.ts
lines 806-811 and src/orm/base_model/index.ts lines 157-164. Keep the registry
comment attached only to $relationRegistry and restore the appropriate
documentation for $relationsDefinitions in both files.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 8d8bbc49-d5e3-4d83-8492-d81492dbbbdb
📒 Files selected for processing (9)
src/errors.tssrc/orm/base_model/index.tssrc/orm/decorators/create_relation_decorator.tssrc/orm/preloader/index.tssrc/orm/relations/relation_registry.tssrc/types/model.tssrc/types/relations.tstest/orm/custom_relations.spec.tstest/orm/relation_registry.spec.ts
|
I pushed 2c95789 to the branch. Here is what I found and what I changed. Problems
DirectionOne contract, one registry, no special cases for built-ins.
528 added, 616 removed. 1499 tests pass; the 1 failure is the MySQL check, which also fails on 22.x. For you
|
| export * from './decorators/date_time.js' | ||
| export { createRelationDecorator } from './decorators/create_relation_decorator.js' | ||
| export { RelationRegistry } from './relations/relation_registry.js' | ||
| export type { RelationFactory, RelationFactoryConfig } from '../types/relations.js' |
There was a problem hiding this comment.
These must be exported from the types folder. I avoid missing runtime and types exports from the same module
❓ Type of change
📚 Description
Add RelationRegistry API for custom relations
Summary
This PR introduces a new
RelationRegistryAPI 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:
Without an extension API, these features would require:
This PR solves the problem by providing an official, supported API for custom relations.
Changes Overview
1. Core API:
RelationRegistryClassNew file:
src/orm/relations/relation_registry.tsA centralized registry for managing custom relation types.
Key methods:
register(type, factory)- Register a new relation typeget(type)- Retrieve a relation factoryhas(type)- Check if a relation type existsgetManyRelationTypes()- Get all "many" relation types (for preloader)unregister(type)- Remove a relation (for testing)clear()- Clear all custom relations (for testing)Design decisions:
Why this approach?
2. Helper:
createRelationDecoratorFunctionNew file:
src/orm/decorators/create_relation_decorator.tsA utility to create type-safe relation decorators.
Usage:
Type constraints:
TRelationTypeis constrained toModelRelationTypes['__opaque_type']Why a helper?
3. Type System Integration
Modified file:
src/types/relations.tsAdded two new interfaces for module augmentation:
A.
KnownCustomRelationsPurpose: Register relation contracts (implementation classes)
Used in:
RelationshipsContractunion typeContains: Relation class types with methods like
boot(),eagerQuery(),setRelated()Example augmentation:
B.
KnownCustomOpaqueRelationsPurpose: Register relation opaque types (user-facing types)
Used in:
ModelRelationsunion typeContains: Opaque array types representing the relation data
Example augmentation:
Why Two Interfaces?
They serve different roles in the type system:
KnownCustomRelations$getRelation()KnownCustomOpaqueRelations4. Factory Types
Modified file:
src/types/relations.tsAdded two new interfaces for relation factories:
A.
RelationFactoryConfigPurpose: Configuration passed to
RelationRegistry.register()B.
RelationFactoryPurpose: Complete factory stored in registry
Note: Includes
typepropertyWhy separate?
5. Extensible Union Types
Modified file:
src/types/relations.tsMade union types extensible by including custom relations:
Before (closed unions):
❌ Cannot add custom relations
After (open unions):
Why this matters:
anytypes needed6. BaseModel Integration
Modified file:
src/orm/base_model/index.tsUpdated
$addRelation()to support custom relations:Changes:
Refactored built-in relation handling
Check registry for custom relations
Replaced static MANY_RELATIONS with getManyRelations() to support custom relations
Why these changes?
7. Type System Compatibility
Modified files:
src/types/model.ts- Updated$relationsDefinitionssignaturesrc/types/relations.ts- AddedsetRelated,pushRelated,setRelatedForManytoBaseRelationContractsrc/factories/factory_model.ts- Added type assertions for backward compatibilityWhy needed?
8. Exports
Modified file:
src/orm/main.tsAdded new exports:
Modified file:
src/orm/relations/main.ts9. Tests
New file:
test/orm/relation_registry.spec.tsComprehensive test coverage:
Breaking Changes
None. This PR is 100% backward compatible.
Performance Considerations
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?
Testing Checklist
Summary of Files Changed
New Files (6)
src/orm/relations/relation_registry.ts- Core registry implementationsrc/orm/decorators/create_relation_decorator.ts- Decorator helpertest/orm/relation_registry.spec.ts- Registry testsModified Files (6)
src/orm/base_model/index.ts- Custom relation support in $addRelationsrc/orm/main.ts- Export new APIssrc/types/relations.ts- Type augmentation interfaces, extensible unionssrc/types/model.ts- Updated signaturessrc/factories/factory_model.ts- Type compatibilitysrc/orm/relations/main.ts- (if any changes)Other notes
Summary by CodeRabbit
New Features
Bug Fixes
Tests