diff --git a/docs/mappers/query-building.md b/docs/mappers/query-building.md index 2487c53..14fa382 100644 --- a/docs/mappers/query-building.md +++ b/docs/mappers/query-building.md @@ -596,6 +596,40 @@ $usersWithManyPosts = $userMapper ->get(); ``` +### Relationship Counting + +Use `withCount()` to count related records without loading them: + +```php +// Count single relationship +$users = $userMapper->withCount('posts')->get(); +echo "User has {$users->first()->posts_count} posts"; + +// Count multiple relationships +$users = $userMapper->withCount(['posts', 'comments', 'likes'])->get(); + +// With constraints +$users = $userMapper->withCount([ + 'posts' => function($query) { + $query->where('published', true); + } +])->get(); + +// With aliases +$users = $userMapper->withCount([ + 'posts as total_posts', + 'posts as published_posts' => function($query) { + $query->where('published', true); + } +])->get(); + +foreach ($users as $user) { + echo "Total: {$user->total_posts}, Published: {$user->published_posts}"; +} +``` + +Relationship counts work with all standard relationship types (HasOne, HasMany, BelongsTo, BelongsToMany). Count columns are automatically named using snake_case with a `_count` suffix, but can be customized using the `as` syntax. + ## Error Handling and Debugging ### Query Debugging diff --git a/docs/references/relationship-cheatsheet.md b/docs/references/relationship-cheatsheet.md index 1f71afc..84cb6a9 100644 --- a/docs/references/relationship-cheatsheet.md +++ b/docs/references/relationship-cheatsheet.md @@ -49,6 +49,32 @@ $users = $userMapper - Use dot notation for nested relationships (`serviceJobs.activities`). - Combine with `customOne`/`customMany` names exactly as defined in the mapper. +## Counting relationships + +Use `withCount()` to add count columns without loading the related entities: + +```php +// Single count +$users = $userMapper->withCount('posts')->get(); +echo $users->first()->posts_count; + +// Multiple counts +$users = $userMapper->withCount(['posts', 'comments'])->get(); + +// With constraints +$users = $userMapper->withCount([ + 'posts' => function($query) { + $query->where('published', true); + } +])->get(); + +// With aliases +$users = $userMapper->withCount('posts as total_posts')->get(); +echo $users->first()->total_posts; +``` + +**Supported relationships**: HasOne, HasMany, BelongsTo, BelongsToMany. Custom relationships need a count closure to support `withCount()`. + ## Handling pivot data with `belongsToMany` ```php diff --git a/docs/relationships/eager-loading.md b/docs/relationships/eager-loading.md index 3bd3212..54489f8 100644 --- a/docs/relationships/eager-loading.md +++ b/docs/relationships/eager-loading.md @@ -9,6 +9,7 @@ Eager loading is a crucial performance optimization technique in Holloway that a - [Nested Eager Loading](#nested-eager-loading) - [Conditional Eager Loading](#conditional-eager-loading) - [Custom Eager Loading](#custom-eager-loading) +- [Counting Relationships](#counting-relationships) - [Performance Considerations](#performance-considerations) - [Best Practices](#best-practices) @@ -181,6 +182,214 @@ $posts = $postMapper->withPopularComments()->get(); $activePosts = $postMapper->withRecentActivity()->get(); ``` +## Counting Relationships + +The `withCount()` method allows you to count related records without loading them, which is significantly more efficient when you only need counts rather than the full data. + +### Basic Counting + +```php +// Count a single relationship +$users = $userMapper->withCount('posts')->get(); + +foreach ($users as $user) { + echo "{$user->name} has {$user->posts_count} posts"; +} +``` + +The count is added as an attribute on the entity using the relationship name in snake_case with a `_count` suffix. + +### Multiple Counts + +Count multiple relationships in a single query: + +```php +$users = $userMapper->withCount(['posts', 'comments', 'likes'])->get(); + +foreach ($users as $user) { + echo "Posts: {$user->posts_count}, "; + echo "Comments: {$user->comments_count}, "; + echo "Likes: {$user->likes_count}"; +} +``` + +### Counting with Constraints + +Apply conditions to count queries: + +```php +// Count only published posts +$users = $userMapper->withCount([ + 'posts' => function($query) { + $query->where('published', true); + } +])->get(); + +echo $users->first()->posts_count; // Only counts published posts +``` + +### Multiple Counts with Different Constraints + +Use aliasing to count the same relationship with different conditions: + +```php +$users = $userMapper->withCount([ + 'posts', + 'posts as published_posts' => function($query) { + $query->where('published', true); + }, + 'posts as draft_posts' => function($query) { + $query->where('published', false); + } +])->get(); + +foreach ($users as $user) { + echo "Total: {$user->posts_count}, "; + echo "Published: {$user->published_posts}, "; + echo "Drafts: {$user->draft_posts}"; +} +``` + +### Custom Count Aliases + +Customize the count column name using the `as` syntax: + +```php +$posts = $postMapper->withCount('comments as total_comments')->get(); + +echo $posts->first()->total_comments; // Uses custom alias +``` + +### Counting All Relationship Types + +`withCount()` works with all standard relationship types: + +```php +// HasMany relationships +$users = $userMapper->withCount('posts')->get(); +echo $users->first()->posts_count; + +// HasOne relationships +$users = $userMapper->withCount('profile')->get(); +echo $users->first()->profile_count; // 1 or 0 + +// BelongsTo relationships +$posts = $postMapper->withCount('author')->get(); +echo $posts->first()->author_count; // Always 1 if exists + +// BelongsToMany relationships +$users = $userMapper->withCount('roles')->get(); +echo $users->first()->roles_count; +``` + +### Combining Counts with Eager Loading + +You can combine `withCount()` with `with()` to both count and load relationships: + +```php +$users = $userMapper + ->withCount('posts') + ->with(['posts' => function($query) { + $query->latest()->limit(5); + }]) + ->get(); + +foreach ($users as $user) { + echo "Total posts: {$user->posts_count}"; + echo "Latest 5 posts:"; + foreach ($user->posts as $post) { + echo $post->title; + } +} +``` + +The count includes all posts, but only the 5 latest are actually loaded. + +### Scopes and Global Scopes + +Global scopes (like `SoftDeletes`) are automatically applied to count queries: + +```php +// Soft deleted comments are excluded from count automatically +$posts = $postMapper->withCount('comments')->get(); +echo $posts->first()->comments_count; // Only non-deleted comments +``` + +### Custom Relationship Counts + +Custom relationships can support `withCount()` by providing a count closure: + +```php +class UserMapper extends Mapper +{ + public function defineRelations(): void + { + $this->custom( + 'activeOrders', + $load = function() { + return $this->newQuery()->from('orders'); + }, + $match = function($user, $order) { + return $user->id === $order->user_id && $order->status === 'active'; + }, + Order::class, + false, + $query = fn() => $this->newQuery()->from('orders'), + $count = function($query, $parentTable, $parentKey) { + return $query + ->selectRaw('count(*)') + ->from('orders') + ->where('status', 'active') + ->whereColumn('user_id', '=', "$parentTable.$parentKey"); + } + ); + } +} + +// Now you can count the custom relationship +$users = $userMapper->withCount('activeOrders')->get(); +echo $users->first()->active_orders_count; +``` + +### Performance Benefits + +Using `withCount()` is significantly more efficient than loading relationships just to count them: + +```php +// Efficient: Only counts, doesn't load data +$users = $userMapper->withCount('posts')->get(); +echo $users->first()->posts_count; + +// Inefficient: Loads all posts just to count them +$users = $userMapper->with('posts')->get(); +echo count($users->first()->posts); // Loaded unnecessary data +``` + +### Limitations + +- **Nested counts not supported**: You cannot use dot notation with `withCount()` (e.g., `withCount('posts.comments')` is not supported) +- **Custom relationships require count closure**: Custom relationships must provide a count closure to support `withCount()` + +```php +// Not supported +$users = $userMapper->withCount('posts.comments')->get(); // Won't work + +// Instead, count at each level +$users = $userMapper + ->withCount('posts') + ->with(['posts' => function($query) { + $query->withCount('comments'); + }]) + ->get(); + +foreach ($users as $user) { + echo "User posts: {$user->posts_count}"; + foreach ($user->posts as $post) { + echo "Post comments: {$post->comments_count}"; + } +} +``` + ## Performance Considerations ### Selectivity in Eager Loading @@ -273,26 +482,6 @@ $popularPosts = $postMapper ->get(); ``` -### Counting Related Records - -```php -// Load posts with comment counts -$posts = $postMapper - ->withCount('comments') - ->get(); - -foreach ($posts as $post) { - echo "Post has {$post->comments_count} comments"; -} - -// With constraints -$posts = $postMapper - ->withCount(['comments' => function($query) { - $query->where('status', 'approved'); - }]) - ->get(); -``` - ## Best Practices ### 1. Profile Your Queries diff --git a/src/Builder.php b/src/Builder.php index afe2bca..378d02a 100644 --- a/src/Builder.php +++ b/src/Builder.php @@ -3,6 +3,7 @@ namespace CodeSleeve\Holloway; use BadMethodCallException; +use InvalidArgumentException; use Closure; use CodeSleeve\Holloway\Relationships\Tree; use Illuminate\Contracts\Pagination\{Paginator as PaginatorContract, LengthAwarePaginator as LengthAwarePaginatorContract}; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Pagination\{Paginator, LengthAwarePaginator}; -use Illuminate\Support\Collection; +use Illuminate\Support\{Collection, Str}; use Illuminate\Database\Concerns\BuildsQueries; class Builder @@ -484,6 +485,111 @@ public function without(mixed $relations) : self return $this; } + /** + * Add subselect queries to count the relations. + * + * @param mixed $relations + * @return self + */ + public function withCount(mixed $relations) : self + { + if (is_null($this->query->columns)) { + $this->query->select([$this->query->from . '.*']); + } + + $relations = is_string($relations) ? func_get_args() : $relations; + + foreach ($this->parseWithRelations($relations) as $name => $constraints) { + $segments = explode(' as ', $name); + $relationName = $segments[0]; + $alias = $segments[1] ?? Str::snake($relationName) . '_count'; + + $this->addCountSelect($relationName, $alias, $constraints); + } + + return $this; + } + + /** + * Parse the with relations into a normalized array. + * + * @param array $relations + * @return array + */ + protected function parseWithRelations(array $relations) : array + { + $results = []; + + foreach ($relations as $name => $constraints) { + if (is_numeric($name)) { + $name = $constraints; + $constraints = null; + } + + $results[$name] = $constraints; + } + + return $results; + } + + /** + * Add a count select subquery for the given relationship. + * + * @param string $relationName + * @param string $alias + * @param \Closure|null $constraints + * @return void + */ + protected function addCountSelect(string $relationName, string $alias, ?Closure $constraints) : void + { + if (!$this->mapper->hasRelationship($relationName)) { + throw new InvalidArgumentException("Relationship [{$relationName}] not defined on mapper."); + } + + $relationship = $this->mapper->getRelationship($relationName); + $subquery = $this->buildCountSubquery($relationship, $constraints); + + $this->selectSub($subquery, $alias); + } + + /** + * Build a count subquery for the given relationship. + * + * This method delegates to the relationship's toCountQuery() method, which allows + * each relationship type to build its own count query. This ensures: + * 1. Global scopes (like SoftDeletingScope) are applied + * 2. Relationship constraints are preserved + * 3. Each relationship type controls its own count logic + * + * @param \CodeSleeve\Holloway\Relationships\Relationship $relationship + * @param \Closure|null $constraints + * @return \Illuminate\Database\Query\Builder + */ + protected function buildCountSubquery($relationship, ?Closure $constraints) + { + // Build the base count query through the relationship's toCountQuery() method + // This ensures global scopes and relationship constraints are properly applied + $countQuery = $relationship->toCountQuery( + $this->mapper->getTable(), + $this->mapper->getKeyName() + ); + + // If the user provided additional constraints, apply them + if ($constraints) { + // We need to apply constraints through a Holloway Builder to support + // advanced query methods, then convert back to base QueryBuilder + $relatedMapper = Holloway::instance()->getMapper($relationship->getEntityName()); + $builder = new Builder($countQuery); + $builder->setMapper($relatedMapper); + + $constraints($builder); + + $countQuery = $builder->getQuery(); + } + + return $countQuery; + } + /** * Chunk the results of the query. */ diff --git a/src/Mapper.php b/src/Mapper.php index d7c62a9..cfe586e 100644 --- a/src/Mapper.php +++ b/src/Mapper.php @@ -697,8 +697,16 @@ protected function belongsToMany(string $name, string $entityName, ?string $pivo /** * Define a new custom relationship + * + * @param string $name + * @param callable $load + * @param callable $for + * @param string|callable $mapOrEntityName + * @param bool $limitOne + * @param callable|null $count Optional count query builder for withCount() support + * @return void */ - public function custom(string $name, callable $load, callable $for, $mapOrEntityName, bool $limitOne = false) : void + public function custom(string $name, callable $load, callable $for, $mapOrEntityName, bool $limitOne = false, ?callable $count = null) : void { if (!$load instanceof Closure) { $load = Closure::fromCallable($load); @@ -712,7 +720,11 @@ public function custom(string $name, callable $load, callable $for, $mapOrEntity $mapOrEntityName = $mapOrEntityName = Closure::fromCallable($mapOrEntityName); } - $this->relationships[$name] = new Relationships\Custom($name, $load, $for, $mapOrEntityName, $limitOne, fn() => $this->newQueryWithoutScopes()); + if ($count && !$count instanceof Closure) { + $count = Closure::fromCallable($count); + } + + $this->relationships[$name] = new Relationships\Custom($name, $load, $for, $mapOrEntityName, $limitOne, fn() => $this->newQueryWithoutScopes(), $count); } /** diff --git a/src/Relationships/BaseRelationship.php b/src/Relationships/BaseRelationship.php index e17715e..66a479e 100644 --- a/src/Relationships/BaseRelationship.php +++ b/src/Relationships/BaseRelationship.php @@ -3,6 +3,8 @@ namespace CodeSleeve\Holloway\Relationships; use Illuminate\Support\Collection; +use Illuminate\Database\Query\Builder as QueryBuilder; +use CodeSleeve\Holloway\Builder; use Closure; use stdClass; @@ -66,4 +68,25 @@ public function getName() : string { return $this->name; } + + /** + * Build a base query builder with global scopes applied. + * This method ensures scopes like SoftDeletingScope are respected. + * + * @return \Illuminate\Database\Query\Builder + */ + protected function getBaseQueryWithScopes() : QueryBuilder + { + $query = ($this->query)(); + + // Apply global scopes if the query is a Holloway Builder + if ($query instanceof Builder) { + $query = $query->applyScopes()->toBase(); + } else { + // If it's already a base query, just return it + $query = $query->toBase(); + } + + return $query; + } } \ No newline at end of file diff --git a/src/Relationships/BelongsTo.php b/src/Relationships/BelongsTo.php index 121146c..2cd3d9f 100644 --- a/src/Relationships/BelongsTo.php +++ b/src/Relationships/BelongsTo.php @@ -3,6 +3,7 @@ namespace CodeSleeve\Holloway\Relationships; use Illuminate\Support\Collection; +use Illuminate\Database\Query\Builder as QueryBuilder; use Closure; use stdClass; @@ -55,4 +56,23 @@ public function for(stdClass $record) : ?stdClass }) ->first(); } + + /** + * Build a count subquery for BelongsTo relationships. + * + * @param string $parentTable + * @param string $parentKey + * @return \Illuminate\Database\Query\Builder + */ + public function toCountQuery(string $parentTable, string $parentKey) : QueryBuilder + { + $query = $this->getBaseQueryWithScopes(); + + return $query->selectRaw('count(*)') + ->whereColumn( + $this->table . '.' . $this->localKeyName, + '=', + $parentTable . '.' . $this->foreignKeyName + ); + } } \ No newline at end of file diff --git a/src/Relationships/BelongsToMany.php b/src/Relationships/BelongsToMany.php index 7ba2f54..919d2a8 100644 --- a/src/Relationships/BelongsToMany.php +++ b/src/Relationships/BelongsToMany.php @@ -5,6 +5,7 @@ use Closure; use stdClass; use Illuminate\Support\Collection; +use Illuminate\Database\Query\Builder as QueryBuilder; class BelongsToMany extends BaseRelationship { @@ -121,4 +122,29 @@ public function getPivotLocalKeyName() : string { return $this->pivotLocalKeyName; } + + /** + * Build a count subquery for BelongsToMany relationships. + * + * @param string $parentTable + * @param string $parentKey + * @return \Illuminate\Database\Query\Builder + */ + public function toCountQuery(string $parentTable, string $parentKey) : QueryBuilder + { + $query = $this->getBaseQueryWithScopes(); + + return $query->selectRaw('count(*)') + ->join( + $this->pivotTable, + $this->table . '.' . $this->foreignKeyName, + '=', + $this->pivotTable . '.' . $this->pivotForeignKeyName + ) + ->whereColumn( + $this->pivotTable . '.' . $this->pivotLocalKeyName, + '=', + $parentTable . '.' . $parentKey + ); + } } \ No newline at end of file diff --git a/src/Relationships/Custom.php b/src/Relationships/Custom.php index 9fd7028..cb30aa3 100644 --- a/src/Relationships/Custom.php +++ b/src/Relationships/Custom.php @@ -3,6 +3,7 @@ namespace CodeSleeve\Holloway\Relationships; use Closure; +use BadMethodCallException; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Support\Collection; use stdClass; @@ -16,6 +17,7 @@ class Custom implements Relationship protected ?string $entityName = null; protected bool $shouldLimitToOne; protected Closure $query; + protected ?Closure $count = null; protected ?Collection $data; /** @@ -24,9 +26,10 @@ class Custom implements Relationship * @param Closure $for * @param mixed $mapOrEntityName * @param bool $shouldLimitToOne - * @param QueryBuilder $query + * @param Closure $query + * @param Closure|null $count */ - public function __construct(string $name, Closure $load, Closure $for, $mapOrEntityName, bool $shouldLimitToOne, Closure $query) + public function __construct(string $name, Closure $load, Closure $for, $mapOrEntityName, bool $shouldLimitToOne, Closure $query, ?Closure $count = null) { $this->name = $name; $this->load = $load; @@ -42,6 +45,7 @@ public function __construct(string $name, Closure $load, Closure $for, $mapOrEnt $this->shouldLimitToOne = $shouldLimitToOne; $this->query = $query; + $this->count = $count; } /** @@ -112,4 +116,36 @@ public function getName() : string { return $this->name; } + + /** + * Build a count subquery for Custom relationships. + * Custom relationships must provide a count closure in their constructor to support withCount(). + * + * @param string $parentTable + * @param string $parentKey + * @return \Illuminate\Database\Query\Builder + * @throws \BadMethodCallException + */ + public function toCountQuery(string $parentTable, string $parentKey) : QueryBuilder + { + if (!$this->count) { + throw new BadMethodCallException( + "Custom relationship [{$this->name}] does not support withCount(). " . + "To add count support, provide a count closure as the 7th parameter to the Custom relationship constructor. " . + "The closure should accept (QueryBuilder \$query, string \$parentTable, string \$parentKey) and return a QueryBuilder with count logic." + ); + } + + $query = ($this->query)(); + + // Convert to base query builder if needed + if ($query instanceof \CodeSleeve\Holloway\Builder) { + $query = $query->applyScopes()->toBase(); + } else { + $query = $query->toBase(); + } + + // Let the count closure configure the query + return ($this->count)($query, $parentTable, $parentKey); + } } \ No newline at end of file diff --git a/src/Relationships/HasOneOrMany.php b/src/Relationships/HasOneOrMany.php index 3864e3f..ac8d072 100644 --- a/src/Relationships/HasOneOrMany.php +++ b/src/Relationships/HasOneOrMany.php @@ -3,6 +3,7 @@ namespace CodeSleeve\Holloway\Relationships; use Illuminate\Support\Collection; +use Illuminate\Database\Query\Builder as QueryBuilder; use Closure; abstract class HasOneOrMany extends BaseRelationship @@ -31,4 +32,23 @@ public function load(Collection $records, ?Closure $constraints = null) : void ->toBase() ->get(); } + + /** + * Build a count subquery for HasOne/HasMany relationships. + * + * @param string $parentTable + * @param string $parentKey + * @return \Illuminate\Database\Query\Builder + */ + public function toCountQuery(string $parentTable, string $parentKey) : QueryBuilder + { + $query = $this->getBaseQueryWithScopes(); + + return $query->selectRaw('count(*)') + ->whereColumn( + $this->table . '.' . $this->foreignKeyName, + '=', + $parentTable . '.' . $parentKey + ); + } } \ No newline at end of file diff --git a/src/Relationships/Relationship.php b/src/Relationships/Relationship.php index 72d4497..574cb5e 100644 --- a/src/Relationships/Relationship.php +++ b/src/Relationships/Relationship.php @@ -3,6 +3,7 @@ namespace CodeSleeve\Holloway\Relationships; use Illuminate\Support\Collection; +use Illuminate\Database\Query\Builder as QueryBuilder; use stdClass; interface Relationship @@ -40,4 +41,13 @@ public function getData() : ?Collection; * @return string */ public function getName() : string; + + /** + * Build a count subquery for this relationship. + * + * @param string $parentTable The parent table name + * @param string $parentKey The parent's local key column + * @return \Illuminate\Database\Query\Builder + */ + public function toCountQuery(string $parentTable, string $parentKey) : QueryBuilder; } \ No newline at end of file diff --git a/tests/Fixtures/Mappers/PupFoodMapper.php b/tests/Fixtures/Mappers/PupFoodMapper.php index f5b0241..c6c283e 100644 --- a/tests/Fixtures/Mappers/PupFoodMapper.php +++ b/tests/Fixtures/Mappers/PupFoodMapper.php @@ -17,7 +17,7 @@ class PupFoodMapper extends Mapper */ public function defineRelations() : void { - $this->belongsTo('company', Company::class); // A pup food belongs to a company (NOTE: For testing purposes, we've intentionally left the table name, local key name, and foreign key name parameters null). - $this->belongsToMany('pups', Pup::class); // A pup food belongs to many pups (NOTE: For testing purposes, we've intentionally left the table name, local key name, and foreign key name parameters null). + $this->belongsTo('company', Company::class); // A pup food belongs to a company (NOTE: For testing purposes, we've intentionally left the table name, local key name, and foreign key name parameters null). + $this->belongsToMany('pups', Pup::class, 'pups_pup_foods'); // A pup food belongs to many pups via the pups_pup_foods pivot table. } } \ No newline at end of file diff --git a/tests/Integration/Relationships/WithCountTest.php b/tests/Integration/Relationships/WithCountTest.php new file mode 100644 index 0000000..4282c31 --- /dev/null +++ b/tests/Integration/Relationships/WithCountTest.php @@ -0,0 +1,462 @@ +register([ + 'CodeSleeve\Holloway\Tests\Fixtures\Mappers\CollarMapper', + 'CodeSleeve\Holloway\Tests\Fixtures\Mappers\CompanyMapper', + 'CodeSleeve\Holloway\Tests\Fixtures\Mappers\PackMapper', + 'CodeSleeve\Holloway\Tests\Fixtures\Mappers\PupFoodMapper', + 'CodeSleeve\Holloway\Tests\Fixtures\Mappers\PupMapper', + 'CodeSleeve\Holloway\Tests\Fixtures\Mappers\UserMapper', + ]); + } + + /** @test */ + public function it_counts_has_many_relationships() + { + // given: Two packs - Bennett Pack has 4 pups, Adams Pack has 2 pups + $this->buildFixtures(); + $packMapper = Holloway::instance()->getMapper(Pack::class); + + // when: Load packs with pups count + $packs = $packMapper->withCount('pups')->get(); + + // then: Counts should match the fixture data + $bennettPack = $packs->firstWhere('name', 'Bennett Pack'); + $adamsPack = $packs->firstWhere('name', 'Adams Pack'); + + $this->assertEquals(4, $bennettPack->pups_count, 'Bennett Pack should have 4 pups'); + $this->assertEquals(2, $adamsPack->pups_count, 'Adams Pack should have 2 pups'); + } + + /** @test */ + public function it_counts_has_many_relationships_with_zero_results() + { + // given: A pack with no pups + $this->buildFixtures(); + $packMapper = Holloway::instance()->getMapper(Pack::class); + + // Create a third pack with no pups + \Illuminate\Database\Capsule\Manager::table('packs')->insert([ + ['id' => 3, 'name' => 'Empty Pack'] + ]); + + // when: Load packs with pups count + $packs = $packMapper->withCount('pups')->get(); + + // then: Empty pack should have 0 count + $emptyPack = $packs->firstWhere('name', 'Empty Pack'); + $this->assertEquals(0, $emptyPack->pups_count, 'Empty Pack should have 0 pups'); + } + + /** @test */ + public function it_counts_has_many_relationships_with_constraints() + { + // given: Bennett Pack has 3 black pups and 1 brown pup + $this->buildFixtures(); + $packMapper = Holloway::instance()->getMapper(Pack::class); + + // when: Count only black pups + $packs = $packMapper->withCount([ + 'pups' => function($query) { + $query->where('coat', 'black'); + } + ])->get(); + + // then: Bennett Pack should have 3 black pups + $bennettPack = $packs->firstWhere('name', 'Bennett Pack'); + $this->assertEquals(3, $bennettPack->pups_count, 'Bennett Pack should have 3 black pups'); + } + + /** @test */ + public function it_counts_multiple_relationships_on_same_query() + { + // given: Company has collars and pup foods + $this->buildFixtures(); + $companyMapper = Holloway::instance()->getMapper(Company::class); + + // when: Count both collars and pupFoods + $companies = $companyMapper->withCount(['collars', 'pupFoods'])->get(); + + // then: Both counts should be present + $diamond = $companies->firstWhere('name', 'Diamond Pet Foods and Accessories'); + + $this->assertEquals(6, $diamond->collars_count, 'Diamond should have 6 collars'); + $this->assertEquals(2, $diamond->pup_foods_count, 'Diamond should have 2 pup foods'); + } + + /** @test */ + public function it_counts_has_one_relationships_when_relationship_exists() + { + // given: Pups with collars + $this->buildFixtures(); + $pupMapper = Holloway::instance()->getMapper(Pup::class); + + // when: Load pups with collar count + $pups = $pupMapper->withCount('collar')->get(); + + // then: Each pup should have collar_count of 1 (all pups in fixtures have collars) + foreach ($pups as $pup) { + $this->assertEquals(1, $pup->collar_count, "Pup {$pup->first_name} should have 1 collar"); + } + } + + /** @test */ + public function it_counts_has_one_relationships_when_relationship_does_not_exist() + { + // given: A pup without a collar + $this->buildFixtures(); + $pupMapper = Holloway::instance()->getMapper(Pup::class); + + // Create a pup without a collar + \Illuminate\Database\Capsule\Manager::table('pups')->insert([ + ['id' => 7, 'pack_id' => 1, 'first_name' => 'Naked', 'last_name' => 'Pup', 'coat' => 'white', 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s')] + ]); + + // when: Load pups with collar count + $pups = $pupMapper->withCount('collar')->get(); + + // then: Pup without collar should have count of 0 + $nakedPup = $pups->firstWhere('first_name', 'Naked'); + $this->assertEquals(0, $nakedPup->collar_count, 'Pup without collar should have 0 count'); + } + + /** @test */ + public function it_counts_belongs_to_relationships() + { + // given: Multiple pups belonging to packs + $this->buildFixtures(); + $pupMapper = Holloway::instance()->getMapper(Pup::class); + + // when: Load pups with pack count + $pups = $pupMapper->withCount('pack')->get(); + + // then: Each pup should have pack_count = 1 (every pup belongs to exactly one pack) + foreach ($pups as $pup) { + $this->assertEquals(1, $pup->pack_count, "Pup {$pup->first_name} should have pack_count of 1"); + } + + // Verify we got the expected number of pups + $this->assertCount(6, $pups, 'Should have 6 pups total'); + } + + /** @test */ + public function it_counts_belongs_to_relationships_with_correct_foreign_key() + { + // given: Collars belonging to pups + $this->buildFixtures(); + $collarMapper = Holloway::instance()->getMapper(Collar::class); + + // when: Load collars with pup count + $collars = $collarMapper->withCount('pup')->get(); + + // then: Each collar should have pup_count = 1 + foreach ($collars as $collar) { + $this->assertEquals(1, $collar->pup_count, "Collar {$collar->id} should have pup_count of 1"); + } + } + + /** @test */ + public function it_counts_belongs_to_relationships_verifying_sql_correctness() + { + // given: Pups belonging to packs + $this->buildFixtures(); + $pupMapper = Holloway::instance()->getMapper(Pup::class); + + // when: Build the query with pack count + $query = $pupMapper->withCount('pack'); + $sql = $query->toSql(); + + // then: The SQL should contain the correct whereColumn clause + // For BelongsTo, it should match: related_table.id = parent_table.foreign_key + // In this case: packs.id = pups.pack_id + $this->assertStringContainsString('packs', $sql, 'SQL should reference packs table'); + $this->assertStringContainsString('pups', $sql, 'SQL should reference pups table'); + + // Execute and verify counts are correct + $pups = $query->get(); + foreach ($pups as $pup) { + $this->assertEquals(1, $pup->pack_count, "Pup {$pup->first_name} should have pack_count of 1"); + } + } + + /** @test */ + public function it_counts_belongs_to_many_relationships() + { + // given: Users with multiple pups + $this->buildFixtures(); + $userMapper = Holloway::instance()->getMapper(User::class); + + // when: Load users with pups count + $users = $userMapper->withCount('pups')->get(); + + // then: Travis should have 5 pups, Marilyn should have 5 pups + $travis = $users->firstWhere('first_name', 'Travis'); + $marilyn = $users->firstWhere('first_name', 'Marilyn'); + + $this->assertEquals(5, $travis->pups_count, 'Travis should have 5 pups'); + $this->assertEquals(5, $marilyn->pups_count, 'Marilyn should have 5 pups'); + } + + /** @test */ + public function it_counts_belongs_to_many_relationships_with_constraints() + { + // given: Users with pups of different coats + $this->buildFixtures(); + $userMapper = Holloway::instance()->getMapper(User::class); + + // when: Count only black pups + $users = $userMapper->withCount([ + 'pups' => function($query) { + $query->where('coat', 'black'); + } + ])->get(); + + // then: Travis should have 3 black pups (Tobias, Tyler, Tucker) + $travis = $users->firstWhere('first_name', 'Travis'); + $this->assertEquals(3, $travis->pups_count, 'Travis should have 3 black pups'); + } + + /** @test */ + public function it_counts_belongs_to_many_verifying_pivot_joins() + { + // given: Pups with many foods (already set up in fixtures) + $this->buildFixtures(); + $pupFoodMapper = Holloway::instance()->getMapper(PupFood::class); + + // when: Load pup foods with pups count + $pupFoods = $pupFoodMapper->withCount('pups')->get(); + + // then: Each food should have correct pup count + $fourHealth = $pupFoods->firstWhere('name', '4Health'); + $tasteOfWild = $pupFoods->firstWhere('name', 'Taste of The Wild'); + $blueBuggalo = $pupFoods->firstWhere('name', 'Blue Buffalo'); + + $this->assertEquals(6, $fourHealth->pups_count, '4Health should have 6 pups'); + $this->assertEquals(6, $tasteOfWild->pups_count, 'Taste of The Wild should have 6 pups'); + $this->assertEquals(0, $blueBuggalo->pups_count, 'Blue Buffalo should have 0 pups'); + } + + /** @test */ + public function it_supports_aliasing_with_as_syntax() + { + // given: Packs with pups + $this->buildFixtures(); + $packMapper = Holloway::instance()->getMapper(Pack::class); + + // when: Count pups with custom alias + $packs = $packMapper->withCount('pups as total_pups')->get(); + + // then: Custom alias should appear on entity + $bennettPack = $packs->firstWhere('name', 'Bennett Pack'); + $this->assertEquals(4, $bennettPack->total_pups, 'Should use custom alias "total_pups"'); + $this->assertObjectNotHasProperty('pups_count', $bennettPack, 'Should not have default "pups_count"'); + } + + /** @test */ + public function it_supports_multiple_counts_with_different_aliases() + { + // given: Packs with different types of pups + $this->buildFixtures(); + $packMapper = Holloway::instance()->getMapper(Pack::class); + + // when: Count black pups and brown pups separately + $packs = $packMapper->withCount([ + 'pups as black_pups' => function($query) { + $query->where('coat', 'black'); + }, + 'pups as brown_pups' => function($query) { + $query->where('coat', 'brown'); + } + ])->get(); + + // then: Bennett Pack should have separate counts + $bennettPack = $packs->firstWhere('name', 'Bennett Pack'); + $this->assertEquals(3, $bennettPack->black_pups, 'Should have 3 black pups'); + $this->assertEquals(1, $bennettPack->brown_pups, 'Should have 1 brown pup'); + } + + /** @test */ + public function it_applies_constraints_only_to_count_not_main_query() + { + // given: Packs with black and brown pups + $this->buildFixtures(); + $packMapper = Holloway::instance()->getMapper(Pack::class); + + // when: Count only black pups but fetch all pups + $packs = $packMapper + ->withCount([ + 'pups as black_pups' => function($query) { + $query->where('coat', 'black'); + } + ]) + ->with('pups') + ->get(); + + // then: Bennett Pack should have 3 black pups counted but 4 pups loaded + $bennettPack = $packs->firstWhere('name', 'Bennett Pack'); + $this->assertEquals(3, $bennettPack->black_pups, 'Should count only 3 black pups'); + $this->assertCount(4, $bennettPack->pups, 'Should load all 4 pups'); + } + + /** @test */ + public function it_mixes_different_relationship_types_in_one_query() + { + // given: Company with collars (hasMany) and collar with pup (belongsTo) + $this->buildFixtures(); + $companyMapper = Holloway::instance()->getMapper(Company::class); + + // when: Count both hasMany relationships + $companies = $companyMapper->withCount(['collars', 'pupFoods'])->get(); + + // then: Both counts should be correct + $diamond = $companies->firstWhere('name', 'Diamond Pet Foods and Accessories'); + $this->assertEquals(6, $diamond->collars_count); + $this->assertEquals(2, $diamond->pup_foods_count); + } + + /** @test */ + public function it_applies_global_scopes_to_count_queries() + { + // given: Collars with SoftDeletes scope + $this->buildFixtures(); + $collarMapper = Holloway::instance()->getMapper(Collar::class); + + // Soft delete one collar + \Illuminate\Database\Capsule\Manager::table('collars') + ->where('id', 1) + ->update(['deleted_at' => date('Y-m-d H:i:s')]); + + // when: Count pup collars (should exclude soft deleted) + $pups = Holloway::instance()->getMapper(Pup::class)->withCount('collar')->get(); + + // then: Tobias (pup 1) should have 0 collars since his collar was soft deleted + $tobias = $pups->firstWhere('first_name', 'Tobias'); + $this->assertEquals(0, $tobias->collar_count, 'Should exclude soft deleted collar'); + + // Tyler (pup 2) should still have 1 collar + $tyler = $pups->firstWhere('first_name', 'Tyler'); + $this->assertEquals(1, $tyler->collar_count, 'Should count non-deleted collar'); + } + + /** @test */ + public function it_throws_exception_for_undefined_relationships() + { + // given: A mapper + $this->buildFixtures(); + $packMapper = Holloway::instance()->getMapper(Pack::class); + + // then: Should throw exception for undefined relationship + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Relationship [nonExistentRelation] not defined on mapper'); + + // when: Try to count undefined relationship + $packMapper->withCount('nonExistentRelation')->get(); + } + + /** @test */ + public function it_throws_exception_for_custom_relationships_without_count_support() + { + // given: Pack with custom collars relationship (which doesn't support count) + $this->buildFixtures(); + $packMapper = Holloway::instance()->getMapper(Pack::class); + + // then: Should throw exception + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('does not support withCount()'); + + // when: Try to count custom relationship + $packMapper->withCount('collars')->get(); + } + + /** @test */ + public function it_preserves_relationship_level_scopes_in_count() + { + // given: Collars with scope + $this->buildFixtures(); + $packMapper = Holloway::instance()->getMapper(Pack::class); + + // when: Count pups using a scoped relationship (if we had one) + // For now, just verify constraints work on the count + $packs = $packMapper->withCount([ + 'pups' => function($query) { + $query->ofCoat('black'); // Using the scopeOfCoat defined on PupMapper + } + ])->get(); + + // then: Should only count black pups + $bennettPack = $packs->firstWhere('name', 'Bennett Pack'); + $this->assertEquals(3, $bennettPack->pups_count, 'Should count only black pups'); + } + + /** @test */ + public function it_counts_belongs_to_many_with_different_pivot_table() + { + // given: Users with surrogate pups (different pivot table) + $this->buildFixtures(); + $userMapper = Holloway::instance()->getMapper(User::class); + + // when: Count surrogate pups + $users = $userMapper->withCount('surrogatePups')->get(); + + // then: Travis should have 2 surrogate pups (Lucky and Duchess) + $travis = $users->firstWhere('first_name', 'Travis'); + $this->assertEquals(2, $travis->surrogate_pups_count, 'Travis should have 2 surrogate pups'); + + // Marilyn should have 2 surrogate pups (Lucky and Duchess) + $marilyn = $users->firstWhere('first_name', 'Marilyn'); + $this->assertEquals(2, $marilyn->surrogate_pups_count, 'Marilyn should have 2 surrogate pups'); + } + + /** @test */ + public function it_works_with_query_builder_constraints_before_count() + { + // given: Packs + $this->buildFixtures(); + $packMapper = Holloway::instance()->getMapper(Pack::class); + + // when: Apply where clause before withCount + $packs = $packMapper + ->where('name', 'Bennett Pack') + ->withCount('pups') + ->get(); + + // then: Should only return Bennett Pack with count + $this->assertCount(1, $packs, 'Should only return Bennett Pack'); + $this->assertEquals(4, $packs->first()->pups_count, 'Bennett Pack should have 4 pups'); + } + + /** @test */ + public function it_works_with_query_builder_constraints_after_count() + { + // given: Packs + $this->buildFixtures(); + $packMapper = Holloway::instance()->getMapper(Pack::class); + + // when: Apply withCount then where clause + $packs = $packMapper + ->withCount('pups') + ->where('name', 'Adams Pack') + ->get(); + + // then: Should only return Adams Pack with count + $this->assertCount(1, $packs, 'Should only return Adams Pack'); + $this->assertEquals(2, $packs->first()->pups_count, 'Adams Pack should have 2 pups'); + } +}