adachsoft/collection

A collection library providing flexible and immutable collections.

Installs: 16

Dependents: 6

Suggesters: 0

Security: 0

Stars: 0

Forks: 0

pkg:composer/adachsoft/collection

v2.4.1 2025-10-11 07:37 UTC

This package is not auto-updated.

Last update: 2025-10-11 12:34:55 UTC


README

A simple, focused PHP library for building strongly-typed collections with both mutable and immutable variants. It promotes clear, predictable APIs and removes the pitfalls of loosely-typed arrays.

  • PHP 8.3+
  • No external runtime dependencies
  • PSR-4 autoloading

Key Features

  • Mutable and immutable base classes to extend
  • Strong runtime type validation for items (and for maps: for keys and values)
  • Array-like access []:
    • Mutable collections support read/write via []
    • Immutable collections support read-only via [] (write operations throw)
  • Clean iteration with IteratorAggregate
  • Utility type system for maps (scalar and class types)
  • NEW in this version:
    • Merge API for collections and maps with conflict strategies (OVERWRITE, SKIP, FAIL, APPEND_NEW_KEY)
    • toArray() for all collections (preserves keys)
    • get() / getOrDefault() for base collections
    • getOrDefault() for maps
    • Constructors accept iterable in all collections and maps
    • Advanced operations: filter, map, reduce, unique, reverse, chunk, take, skip

Installation

composer require adachsoft/collection

Quick Start

1) Your domain object

<?php

final class User
{
    public function __construct(
        public readonly int $id,
        public readonly string $name,
    ) {}
}

2) Mutable collection

Extend AdachSoft\Collection\AbstractCollection and set the expected item type. The simplest way is to predefine the $type property in the subclass.

<?php

use AdachSoft\Collection\AbstractCollection;

final class UserCollection extends AbstractCollection
{
    /** @var class-string<User>|null */
    protected ?string $type = User::class;
}

Usage:

$user1 = new User(1, 'Alice');
$user2 = new User(2, 'Bob');

$users = new UserCollection([$user1]);
$users->add($user2);         // OK
$users[] = new User(3, 'C'); // also OK via ArrayAccess (write)

foreach ($users as $user) {
    echo $user->name . "\n"; // Alice, Bob, C
}

// Type safety: adding wrong type will throw InvalidArgumentException
$users->add('not a user'); // throws InvalidArgumentException

3) Immutable collection (read-only, array-like access for reads)

Extend AdachSoft\Collection\AbstractImmutableCollection. No mutator methods exist; read access via [] is allowed, write attempts via [] throw.

<?php

use AdachSoft\Collection\AbstractImmutableCollection;

final class ImmutableUserCollection extends AbstractImmutableCollection
{
    /** @var class-string<User>|null */
    protected ?string $type = User::class;
}

Usage:

$users = new ImmutableUserCollection([
    new User(1, 'Alice'),
    new User(2, 'Bob'),
]);

// Read operations
echo $users[0]->name;       // Alice
var_dump(isset($users[1])); // true

// Write operations are not allowed (immutable)
$users[0] = new User(3, 'Charlie'); // throws BadMethodCallException
unset($users[0]);                   // throws BadMethodCallException

// Functional helpers return the same collection class (static)
$names = $users->map(fn (User $u) => $u->name); // ImmutableUserCollection

4) Advanced Collection Operations

All collections (both mutable and immutable) support functional operations:

<?php

use AdachSoft\Collection\AbstractImmutableCollection;

// A simple immutable collection without item type constraints
final class IntCollection extends AbstractImmutableCollection {}

$numbers = new IntCollection([1, 2, 3, 4, 5, 5, 6]);

// Reduce - aggregate values
$sum = $numbers->reduce(fn ($carry, $item) => $carry + $item, 0); // 21

// Unique - remove duplicates
$unique = $numbers->unique(); // [1, 2, 3, 4, 5, 6]

// Custom unique comparator for objects
$users = new UserCollection([
    new User(1, 'Alice'),
    new User(2, 'Bob'),
    new User(1, 'Alice2'), // Same ID, different name
]);
$uniqueUsers = $users->unique(fn ($a, $b) => $a->id === $b->id);

// Reverse - reverse order
$reversed = $numbers->reverse(); // [6, 5, 5, 4, 3, 2, 1]

// Chunk - split into smaller collections
$chunks = $numbers->chunk(3); // [[1,2,3], [4,5,5], [6]]

// Take - get first N elements
$firstThree = $numbers->take(3); // [1, 2, 3]

// Skip - skip first N elements
$withoutFirst = $numbers->skip(2); // [3, 4, 5, 5, 6]

All operations return new collection instances, preserving the original collection type and maintaining key associations where applicable.

4.1) get() and getOrDefault() for base collections

$users = new UserCollection([
    'owner' => new User(1, 'Alice'),
]);

$owner = $users->get('owner');             // returns User
$guest = $users->getOrDefault('guest');    // returns null by default
$guest2 = $users->getOrDefault('guest', new User(0, 'Guest')); // returns default

// Distinguishes missing key from existing null value
$values = new class (['a' => null]) extends AdachSoft\Collection\AbstractCollection {};
var_dump($values->getOrDefault('a', 'x')); // null
var_dump($values->getOrDefault('b', 'x')); // 'x'

5) Immutable, typed map (keys and values)

Use AdachSoft\Collection\AbstractImmutableMap for key/value collections with strict, explicit types for keys and values.

<?php

use AdachSoft\Collection\AbstractImmutableMap;
use AdachSoft\Collection\Type\TypeDefinitionInterface;
use AdachSoft\Collection\Type\ScalarTypeEnum;
use AdachSoft\Collection\Type\ClassType;

final class UserMap extends AbstractImmutableMap
{
    protected function getKeyType(): TypeDefinitionInterface
    {
        return ScalarTypeEnum::STRING; // keys must be strings
    }

    protected function getValueType(): TypeDefinitionInterface
    {
        return new ClassType(User::class); // values must be User instances
    }
}

Usage:

$map = new UserMap([
    'owner' => new User(1, 'Alice'),
    'guest' => new User(2, 'Bob'),
]);

$owner = $map->get('owner');                  // returns User
$ownerOrDefault = $map->getOrDefault('admin', null); // returns default when missing

foreach ($map as $key => $user) {
    echo $key . ': ' . $user->name . "\n";
}

$all = $map->toArray();           // ['owner' => User(...), 'guest' => User(...)]
$allKeys = iterator_to_array($map->keys());   // ['owner', 'guest']
$allVals = iterator_to_array($map->values()); // [User(...), User(...)]

6) Mutable, typed map

Use AdachSoft\Collection\AbstractMap to allow in-place mutation of key/value pairs while keeping strict types.

<?php

use AdachSoft\Collection\AbstractMap;
use AdachSoft\Collection\Type\TypeDefinitionInterface;
use AdachSoft\Collection\Type\ScalarTypeEnum;
use AdachSoft\Collection\Type\ClassType;

final class MutableUserMap extends AbstractMap
{
    protected function getKeyType(): TypeDefinitionInterface
    {
        return ScalarTypeEnum::STRING; // keys must be strings
    }

    protected function getValueType(): TypeDefinitionInterface
    {
        return new ClassType(User::class); // values must be User instances
    }
}

Usage:

$map = new MutableUserMap([
    'x' => new User(1, 'Alice'),
]);
$map->set('x', new User(2, 'Bob')); // updates existing key
$map->removeKey('x');
$map->clear();

$values = $map->values();
$keys = $map->keys();
$all = $map->toArray();

7) Constructors accept iterable

All collections and maps accept iterable as constructor argument. This means you can pass:

  • plain arrays
  • generators (with or without keys)
  • other collections (any IteratorAggregate or Traversable)

Examples:

// from an array
$users = new UserCollection([$user1, $user2]);

// from a generator
$gen = (function () {
    yield 'a' => new User(1, 'Alice');
    yield 'b' => new User(2, 'Bob');
})();
$map = new MutableUserMap($gen);

// from another collection (preserves keys)
$users2 = new UserCollection($users);

Note: Keys must be of type int|string. In maps, invalid key/value types will throw domain exceptions at construction time.

8) Merge API for collections and maps

The library provides a unified merge method for both collections and maps via MergeableCollectionInterface.

Signature:

public function merge(
    iterable $source,
    MergeConflictModeEnum $mode = MergeConflictModeEnum::OVERWRITE,
    bool $preserveKeys = true,
    ?string $appendSuffix = '_copy'
): static;

General rules:

  • Immutable classes return a new instance with changes; mutable classes modify the current instance and return $this (fluent).
  • For collections:
    • When $preserveKeys === false, items are appended (no conflicts occur, $mode is ignored).
    • When $preserveKeys === true, keys in $source must be int|string; conflicts are resolved per $mode.
    • APPEND_NEW_KEY generates a new unique string key: {key}{suffix}, {key}{suffix}_2, ...
  • For maps:
    • $preserveKeys is ignored (keys are always meaningful and preserved).
    • Key and value types are validated using the map’s getKeyType() and getValueType().
    • APPEND_NEW_KEY: if string keys are allowed, a string key with suffix is generated; otherwise, the next free integer key is used (validated against key type).

Conflict strategies (MergeConflictModeEnum):

  • OVERWRITE – replace existing value with source value
  • SKIP – keep existing value, ignore source value
  • FAIL – throw MergeConflictException on conflict
  • APPEND_NEW_KEY – add the source value under a new unique key

Exceptions:

  • InvalidArgumentException – for collections when $preserveKeys === true and a source key is not int|string
  • MergeConflictException – when $mode === FAIL and a conflict is detected
  • InvalidKeyTypeException – for maps when a key does not match the key type
  • InvalidItemTypeException – for maps when a value does not match the value type

Examples:

use AdachSoft\Collection\Type\MergeConflictModeEnum;

// Collections (mutable)
$col = new UserCollection(['a' => new User(1, 'A')]);
$col->merge(['a' => new User(2, 'B')], MergeConflictModeEnum::OVERWRITE); // overwrites
$col->merge(['a' => new User(3, 'C')], MergeConflictModeEnum::APPEND_NEW_KEY, true, '_cpy'); // adds 'a_cpy'

// Collections (immutable)
$col = new ImmutableUserCollection(['a' => new User(1, 'A')]);
$new = $col->merge(['a' => new User(2, 'B')], MergeConflictModeEnum::SKIP); // original unchanged

// Maps (mutable)
$map = new MutableUserMap(['owner' => new User(1, 'A')]);
$map->merge(['owner' => new User(2, 'B')], MergeConflictModeEnum::OVERWRITE); // overwrites

// Maps (immutable)
$imap = new UserMap(['owner' => new User(1, 'A')]);
$imap2 = $imap->merge(['owner' => new User(2, 'B')], MergeConflictModeEnum::SKIP);

Examples

The repository contains runnable examples in the examples/ directory that demonstrate typical usage patterns of the library:

  • examples/01-mutable-collection.php – Mutable object collection (PostCollection)
  • examples/02-immutable-collection.php – Immutable object collection (TagCollection)
  • examples/03-mutable-map.php – Mutable typed map: int => User (IdToUserMap)
  • examples/04-immutable-map.php – Immutable typed map: string => float (PriceBySymbolMap)

Run any example with PHP (Composer autoload required):

php examples/01-mutable-collection.php
php examples/02-immutable-collection.php
php examples/03-mutable-map.php
php examples/04-immutable-map.php

Contracts

Prefer the contracts from AdachSoft\Collection\Contract:

  • ReadableCollectionInterface for read-only collections
  • MutableCollectionInterface for mutable collections
  • KeyedCollectionInterface for immutable maps (now with getOrDefault)
  • MutableKeyedCollectionInterface for mutable maps
  • MergeableCollectionInterface for a unified merge operation across collections and maps

Tooling

  • PHPUnit tests: composer test
  • Coding style: composer csfix
  • Static analysis: composer phpstan

Changelog

See CHANGELOG.md for release notes.

License

MIT