Skip to content

feat: add ID transformation support for relations #10

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2022-2024 Michal Sniatala
Copyright (c) 2022-2025 Michal Sniatala

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
776 changes: 448 additions & 328 deletions composer.lock

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions docs/basic_usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

- [Eager loading](#eager-loading)
- [Lazy loading](#lazy-loading)
- [Transforming relation IDs](#transforming-relation-ids)

## Eager loading

Expand Down Expand Up @@ -141,3 +142,53 @@ foreach ($users as $user) {
```

This will perform `n+1` queries. First one to get all the users and then one for each profile we want to access.

## Transforming relation IDs

Sometimes you may need to transform the IDs before they are used in the relation queries. This is particularly useful when working with different ID formats, such as UUIDs, that need to be converted to binary format. An ideal example is the [codeigniter4-uuid](https://github.com/michalsn/codeigniter4-uuid) package.

You can define transformation methods in your model to automatically transform IDs when loading relations:

```php
use Michalsn\CodeIgniterNestedModel\Relation;
use Michalsn\CodeIgniterNestedModel\Traits\HasRelations;

class UserModel extends Model
{
use HasRelations;

// ...

protected function initialize()
{
$this->initRelations();
}

public function profile(): Relation
{
return $this->hasOne(ProfileModel::class);
}

// Transform IDs specifically for the 'profile' relation
protected function transformProfileRelationIds(array $ids): array
{
return array_map(fn ($id) => $this->uuid->fromValue($id)->getBytes(), $ids);
}
}
```

!!! note
This will be needed only if you store your UUIDs in a byte format.

### Method naming

The transformation methods follow a specific naming convention:

- `transform{RelationName}RelationIds()` for specific relations
- `transformAllRelationIds()` for a general fallback

### Priority order

1. Specific method (e.g., `transformProfileRelationIds()`) - highest priority
2. General method (`transformAllRelationIds()`) - fallback
3. No transformation - if neither method exists
145 changes: 145 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
parameters:
ignoreErrors:
-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
identifier: method.notFound
count: 8
path: src/Relation.php

-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:with\(\)\.$#'
identifier: method.notFound
count: 1
path: src/Relation.php

-
message: '#^Cannot access property \$country on array\|bool\|float\|int\|object\|string\.$#'
identifier: property.nonObject
count: 1
path: tests/EntityTest.php

-
message: '#^Cannot access property \$user_id on array\|bool\|float\|int\|object\|string\.$#'
identifier: property.nonObject
count: 1
path: tests/EntityTest.php

-
message: '#^Cannot access property \$country on array\|bool\|float\|int\|object\|string\.$#'
identifier: property.nonObject
count: 1
path: tests/ModelTest.php

-
message: '#^PHPDoc tag @var with type CodeIgniter\\Entity\\Entity is not subtype of native type Tests\\Support\\Entities\\User\.$#'
identifier: varTag.nativeType
count: 1
path: tests/ModelTest.php

-
message: '#^Parameter \#1 \$row of method Tests\\Support\\Models\\UserModel\:\:insert\(\) expects array\<int\|string, bool\|float\|int\|object\|string\|null\>\|object\|null, array\<string, array\<string, string\>\|string\> given\.$#'
identifier: argument.type
count: 2
path: tests/ModelTest.php

-
message: '#^Parameter \#2 \$row of method Tests\\Support\\Models\\UserModel\:\:update\(\) expects array\<int\|string, bool\|float\|int\|object\|string\|null\>\|object\|null, array\<string, array\<string, string\>\|string\> given\.$#'
identifier: argument.type
count: 2
path: tests/ModelTest.php

-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/TransformRelationIdsTest.php

-
message: '#^Call to function method_exists\(\) with \$this\(CodeIgniter\\Model@anonymous/tests/TransformRelationIdsTest\.php\:52\) and ''transformAllRelatio…'' will always evaluate to false\.$#'
identifier: function.impossibleType
count: 1
path: tests/TransformRelationIdsTest.php

-
message: '#^Parameter \#1 \$row of method Tests\\Support\\Models\\UserModel\:\:insert\(\) expects array\<int\|string, bool\|float\|int\|object\|string\|null\>\|object\|null, array\<string, array\<int\|string, array\<string, int\|list\<array\<string, int\|string\>\>\|string\>\|string\>\|string\> given\.$#'
identifier: argument.type
count: 1
path: tests/_support/Database/Seeds/SeedTests.php

-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/_support/Models/AddressModel.php

-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/_support/Models/CommentModel.php

-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/_support/Models/CompanyModel.php

-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/_support/Models/CountryModel.php

-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/_support/Models/CourseModel.php

-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/_support/Models/PostModel.php

-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/_support/Models/ProfileModel.php

-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/_support/Models/StudentModel.php

-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/_support/Models/UserModel.php

-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/_support/Models/UuidCommentModel.php

-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/_support/Models/UuidPostModel.php

-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/_support/Models/UuidProfileModel.php

-
message: '#^Call to an undefined method CodeIgniter\\Model\:\:getTable\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/_support/Models/UuidUserModel.php
9 changes: 2 additions & 7 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
includes:
- phpstan-baseline.neon
parameters:
tmpDir: build/phpstan
level: 5
Expand All @@ -8,13 +10,6 @@ parameters:
- vendor/codeigniter4/framework/system/Test/bootstrap.php
excludePaths:
- src/Views/*
ignoreErrors:
- '#Call to an undefined method CodeIgniter\\Model::with\(\).#'
- '#Call to an undefined method CodeIgniter\\Model::getTable\(\).#'
- '#Cannot access property \$[a-zA-Z0-9\\_]+ on array\|bool\|float\|int\|object\|string.#'
- '#^.*expects array\<int\|string, bool\|float\|int\|object\|string\|null\>\|object\|null, array\<string, array\<string, string\>\|string\> given\.$#'
- '#^.*expects array\<int\|string, bool\|float\|int\|object\|string\|null\>\|object\|null, array\<string, array\<int\|string, array\<string, int\|list\<array\<string, int\|string\>\>\|string\>\|string\>\|string\> given\.$#'
- '#^PHPDoc tag @var with type CodeIgniter\\Entity\\Entity is not subtype of native type.*#'
universalObjectCratesClasses:
- CodeIgniter\Entity
- CodeIgniter\Entity\Entity
Expand Down
39 changes: 33 additions & 6 deletions src/Traits/HasRelations.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,28 @@ public function with(string $relation, ?Closure $closure = null): static
return $this;
}

/**
* Transform relation IDs before using them in whereIn queries
* This method checks for relation-specific transform methods
*/
private function transformRelationIds(array $ids, string $relationName): array
{
// Check if there's a specific transform method for this relation
// e.g., transformProfileRelationIds() for 'profile' relation
$transformMethod = 'transform' . ucfirst($relationName) . 'RelationIds';

if (method_exists($this, $transformMethod)) {
return $this->{$transformMethod}($ids);
}

// Check for a general relation transform method
if (method_exists($this, 'transformAllRelationIds')) {
return $this->transformAllRelationIds($ids);
}

return $ids;
}

/**
* Validate relation definition.
*/
Expand Down Expand Up @@ -365,17 +387,17 @@ protected function relationsAfterFind(array $eventData): array
if ($eventData['singleton']) {
if ($this->tempReturnType === 'array') {
foreach ($this->relations as $relationName => $relationObject) {
$eventData['data'][$relationName] = $this->getDataForRelationById($eventData['data'][$relationObject->primaryKey], $relationObject);
$eventData['data'][$relationName] = $this->getDataForRelationById($eventData['data'][$relationObject->primaryKey], $relationObject, $relationName);
}
} else {
foreach ($this->relations as $relationName => $relationObject) {
$eventData['data']->{$relationName} = $this->getDataForRelationById($eventData['data']->{$relationObject->primaryKey}, $relationObject);
$eventData['data']->{$relationName} = $this->getDataForRelationById($eventData['data']->{$relationObject->primaryKey}, $relationObject, $relationName);
}
}
} else {
foreach ($this->relations as $relationName => $relationObject) {
$ids = array_column($eventData['data'], $relationObject->primaryKey);
$relationData = $this->getDataForRelationByIds($ids, $relationObject);
$relationData = $this->getDataForRelationByIds($ids, $relationObject, $relationName);

foreach ($eventData['data'] as &$data) {
if ($this->tempReturnType === 'array') {
Expand All @@ -395,9 +417,11 @@ protected function relationsAfterFind(array $eventData): array
/**
* Get relation data for a single item.
*/
protected function getDataForRelationById(int|string $id, Relation $relation)
protected function getDataForRelationById(int|string $id, Relation $relation, string $relationName)
{
$relation->applyWith()->applyRelation([$id], $this->primaryKey)->applyConditions();
$id = $this->transformRelationIds([$id], $relationName);

$relation->applyWith()->applyRelation($id, $this->primaryKey)->applyConditions();

$results = in_array($relation->type, [RelationTypes::hasOne, RelationTypes::belongsTo], true) ?
$relation->model->first() :
Expand All @@ -409,8 +433,11 @@ protected function getDataForRelationById(int|string $id, Relation $relation)
/**
* Get relation data for many items.
*/
protected function getDataForRelationByIds(array $id, Relation $relation): array
protected function getDataForRelationByIds(array $id, Relation $relation, string $relationName): array
{
// Transform the ID before applying relation
$id = $this->transformRelationIds($id, $relationName);

$relation->applyWith()->applyRelation($id, $this->primaryKey)->applyConditions();

if ($relation->type === RelationTypes::hasOne && ($ofMany = $relation->getOfMany()) !== null) {
Expand Down
Loading