7.4's arrow function syntax makes creating on-the-fly single-statement functions much nicer to write; without them there's about as much boilerplate as there is code.

So anyway, LINQ is an API and infrastructure available in the .NET platform for querying collections of data. There's a dedicated syntax in C♯ and Visual Basic and a bunch of other things.

Over the weekend I knocked this together. The days since were spent debugging and retesting.

This is nowhere near the full LINQ experience. The dedicated syntax isn't there of course. The use of extension classes to attach the methods directly to existing collection objects (including native arrays) isn't available in PHP, so explicit new QueryEnumerable($iterable) constructions are needed. C♯ being statically typed means several LINQ methods are for type management purposes that don't need distinct handling in PHP.

But here's the code. The documentation is probably rubbish stream-of-consciousness stuff I cobbled together while going through Microsoft's documentation. Most of the tests were ported from the examples there as well.

    Because I can't be bothered figuring out what file types I can attach, or hosting it somewhere else.
    linq.php

    <?php
    
    namespace Linq;
    
    require_once 'Linq_Impl.php';
    
    use Linq\Impl\OrderedQueryEnumerable;
    
    
    // require_once 'Link_Impl.php';
    
    /*
     * Immediate execution methods live on the QueryEnumerable class, as do the static ones (Empty, Repeat, Range)
     *
     * OrDefault is replaced by OrNull because PHP isn't statically typed and doesn't know what a default value would be. In C# it's the closest thing the (known) type has to 0.
     * The name is changed in case a proper 'orDefault' can be created later.
     */
    class QueryEnumerable
    {
    	private iterable $source;
    
    	public function __construct(iterable $source)
    	{
    		$this->source = $source;
    	}
    
    	public function Enumerate()
    	{
    		yield from $this->source;
    	}
    
    	/*
    	 * Immediate execution methods
    	 */
    	
    	/**
    	 *
    	 * Applies an accumulator function over a sequence, in the manner of array_reduce.
    	 *
    	 * There can be one, two, or three arguments:
    	 *
    	 * If one argument is given, it is the aggregation function that is applied to
    	 * the accumulator and successive terms of the sequence. The accumulator starts out
    	 * null.
    	 *
    	 * If two arguments are given, the seecond is the seed value for the accumulator.
    	 *
    	 * If three arguments are given, the first two are as above, and the third is a
    	 * tranformation function that is applied to the final value of the accumulator before
    	 * returning it.
    	 *
    	 * Something to be aware of if you're familiar with the C# implementation: in the two-argument
    	 * case, C# puts the seed value first and the aggregation function second; here, the function is first and
    	 * the seed is second. After copying the C# order, I decided it was too awkward and changed to the current
    	 * order. Keeping the C# order also messed up attempts to declare the parameter types.
    	 *
    	 * @return mixed
    	 */
    	public function Aggregate(callable $func, $acc = null, callable $transform = null)
    	{
    		foreach($this->Enumerate() as $term)
    		{
    			$acc = $func($acc, $term);
    		}
    		if($transform !== null)
    		{
    			$acc = $transform($acc);
    		}
    		return $acc;
    	}
    
    	/**
    	 * Returns true iff all terms in the sequence satisfy the given condition.
    	 *
    	 * Notice that under this definition, an empty sequence will (vacuously) return "true".
    	 *
    	 * @param callable $predicate
    	 * @return bool
    	 */
    	public function All(callable $predicate): bool
    	{
    		foreach($this->Enumerate() as $term)
    		{
    			if(!$predicate($term))
    			{
    				return false;
    			}
    		}
    		return true;
    	}
    
    	/**
    	 * Returns true iff there is at least one term in the sequence that satisfies the given condition.
    	 *
    	 * Notice that under this definition, an empty sequence will (vacuously) return "false".
    	 *
    	 * If no condition is given, will return true iff there are any terms in the sequence.
    	 *
    	 * @param callable $predicate
    	 * @return bool
    	 */
    	public function Any(callable $predicate = null): bool
    	{
    		if($predicate === null)
    		{
    			foreach($this->Enumerate() as $term)
    			{
    				return true;
    			}
    		}
    		else
    		{
    			foreach($this->Enumerate() as $term)
    			{
    				if($predicate($term))
    				{
    					return true;
    				}
    			}
    		}
    		return false;
    	}
    
    	/**
    	 * Computes an average value for all the terms in the sequence.
    	 *
    	 * Apart from skipping (and not counting) null terms, it assumes that all terms are numeric, or can be juggled into numeric types.
    	 * Optionally, a transform function can be applied to (non-null) terms to produce a suitable numeric value.
    	 * If all terms are skipped (or there are no terms to begin with), it returns null.
    	 *
    	 * @param callable $transform
    	 * @return mixed
    	 */
    	public function Average(callable $transform = null)
    	{
    		$sum = $n = 0;
    		foreach($this->Enumerate() as $term)
    		{
    			if($term !== null)
    			{
    				if($transform !== null)
    				{
    					$term = $transform($term);
    				}
    				$sum += $term;
    				$n++;
    			}
    		}
    		if($n == 0)
    			return null;
    		return $sum / $n;
    	}
    
    	/**
    	 *
    	 * Determines whether the sequence contains a certain term.
    	 * If a second argument is supplied, it is a two-argument function that returns a boolean:
    	 * true if its two arguments are to be considered "equal", false if not. If not supplied,
    	 * the method will use plain strict equality "===" as the equality test.
    	 *
    	 * @param mixed $needle
    	 * @param callable $equality
    	 * @return bool
    	 */
    	public function Contains($needle, callable $equality = null): bool
    	{
    		if($equality === null)
    		{
    			foreach($this->Enumerate() as $term)
    			{
    				if($term === $needle)
    					return true;
    			}
    		}
    		else
    		{
    			foreach($this->Enumerate() as $term)
    			{
    				if($equality($term, $needle))
    					return true;
    			}
    		}
    		return false;
    	}
    
    	/**
    	 * Counts the number of terms in the sequence.
    	 * Optionally, counts the number of terms
    	 * in the sequence that satisfy the given condition.
    	 *
    	 * @param callable $condition
    	 * @return int
    	 */
    	public function Count(callable $condition = null): int
    	{
    		if($condition !== null)
    		{
    			return $this->Where($condition)->Count();
    		}
    		else
    		{
    			$n = 0;
    			foreach($this->Enumerate() as $term)
    			{
    				$n++;
    			}
    			return $n;
    		}
    	}
    
    	/**
    	 * Returns the element at the specified position in the sequence.
    	 * Throws an out of bounds exception if the index is less than zero or
    	 * greater or equal to the number of terms in the sequence.
    	 *
    	 * @param int $i
    	 * @return mixed
    	 * @throws \OutOfBoundsException
    	 */
    	public function ElementAt(int $i)
    	{
    		if($i < 0)
    		{
    			throw new \OutOfBoundsException();
    		}
    		$n = 0;
    		foreach($this->Enumerate() as $term)
    		{
    			if($n == $i)
    			{
    				return $term;
    			}
    			++$n;
    		}
    		throw new \OutOfBoundsException();
    	}
    
    	/**
    	 * Same as ElementAt, except that instead of throwing on out of bounds conditions, it
    	 * just returns null instead.
    	 *
    	 * @param int $i
    	 * @return mixed
    	 */
    	public function ElementAtOrNull(int $i)
    	{
    		if($i < 0)
    		{
    			return null;
    		}
    		$n = 0;
    		foreach($this->Enumerate() as $term)
    		{
    			if($n == $i)
    			{
    				return $term;
    			}
    			++$n;
    		}
    		return null;
    	}
    
    	/**
    	 *
    	 * Returns the first term in the sequence; optinally, the first term in the sequence satisfying
    	 * a given condition.
    	 *
    	 * Throws an underflow exception if no such term is found.
    	 *
    	 * @param callable $condition
    	 * @return mixed
    	 * @throws \UnderflowException
    	 */
    	public function First(callable $condition = null)
    	{
    		if($condition === null)
    		{
    			foreach($this->Enumerate() as $term)
    			{
    				return $term;
    			}
    		}
    		else
    		{
    			foreach($this->Enumerate() as $term)
    			{
    				if($condition($term))
    				{
    					return $term;
    				}
    			}
    		}
    		throw new \UnderflowException();
    	}
    
    	/**
    	 *
    	 * As for First, but returns null instead of throwing.
    	 *
    	 * @param callable $condition
    	 * @return mixed
    	 */
    	public function FirstOrNull(callable $condition = null)
    	{
    		if($condition === null)
    		{
    			foreach($this->Enumerate() as $term)
    			{
    				return $term;
    			}
    		}
    		else
    		{
    			foreach($this->Enumerate() as $term)
    			{
    				if($condition($term))
    				{
    					return $term;
    				}
    			}
    		}
    		return null;
    	}
    
    	/**
    	 *
    	 * Returns the last term in the sequence; optinally, the last term in the sequence satisfying
    	 * a given condition.
    	 *
    	 * Throws an underflow exception if no such term is found.
    	 *
    	 * @param callable $condition
    	 * @return mixed
    	 * @throws \UnderflowException
    	 */
    	public function Last(callable $condition = null)
    	{
    		$found = false;
    		$value = null;
    		if($condition === null)
    		{
    			foreach($this->Enumerate() as $term)
    			{
    				$found = true;
    				$value = $term;
    			}
    		}
    		else
    		{
    			foreach($this->Enumerate() as $term)
    			{
    				if($condition($term))
    				{
    					$found = true;
    					$value = $term;
    				}
    			}
    		}
    		if($found)
    		{
    			return $value;
    		}
    		else
    		{
    			throw new \UnderflowException();
    		}
    	}
    
    	/**
    	 *
    	 * As for Last(), but returns null instead of throwing.
    	 *
    	 * @param callable $condition
    	 * @return mixed
    	 */
    	public function LastOrNull(callable $condition = null)
    	{
    		$found = false;
    		$value = null;
    		if($condition === null)
    		{
    			foreach($this->Enumerate() as $term)
    			{
    				$found = true;
    				$value = $term;
    			}
    		}
    		else
    		{
    			foreach($this->Enumerate() as $term)
    			{
    				if($condition($term))
    				{
    					$found = true;
    					$value = $term;
    				}
    			}
    		}
    		return $found ? $term : null;
    	}
    
    	/**
    	 * Returns the maximum term in a sequence; optionally, the maximum term as scored by a given transform function.
    	 *
    	 * Nulls in the original sequence are skipped. If all terms in the sequence are skipped (or
    	 * there are no terms to begin with) then the method returns null.
    	 *
    	 * Note that if the transform is used, then the value returned is the transformed maximum value, not the term itself,
    	 * as the latter might not be well-defined.
    	 *
    	 * The method uses the <=> operator for making the actual comparison between (optionally transformed) terms.
    	 *
    	 * @param callable $transform
    	 * @return mixed
    	 */
    	public function Max(callable $transform = null)
    	{
    		$found = false;
    		$max = null;
    		foreach($this->Enumerate() as $term)
    		{
    			if($term !== null)
    			{
    				if($transform !== null)
    				{
    					$term = $transform($term);
    				}
    				if(!$found)
    				{
    					$max = $term;
    					$found = true;
    				}
    				else
    				{
    					if(($term <=> $max) > 0)
    					{
    						$max = $term;
    					}
    				}
    			}
    		}
    		return $max;
    	}
    
    	/**
    	 * As for Max(), but searching for the minimum rather than the maximum.
    	 *
    	 * @param callable $transform
    	 * @return mixed
    	 */
    	public function Min(callable $transform = null)
    	{
    		$found = false;
    		$min = null;
    		foreach($this->Enumerate() as $term)
    		{
    			if($term !== null)
    			{
    				if($transform !== null)
    				{
    					$term = $transform($term);
    				}
    				if(!$found)
    				{
    					$min = $term;
    					$found = true;
    				}
    				else
    				{
    					if(($term <=> $min) < 0)
    					{
    						$min = $term;
    					}
    				}
    			}
    		}
    		return $min;
    	}
    
    	/**
    	 * Determines if the passed sequence contains the same terms in the same order (and the same
    	 * number of terms) as this sequence.
    	 *
    	 * The default comparison operator used is ===. If another equality test is required,
    	 * the second parameter may be used. Note that the terms will still be compared using ===
    	 * first, and the custom equality test would be applied only if the result of that is false.
    	 *
    	 * @param QueryEnumerable $that
    	 * @param callable $equality
    	 * @return bool
    	 */
    	public function SequenceEqual(QueryEnumerable $that, callable $equality = null): bool
    	{
    		$this_enumerator = $this->Enumerate();
    		$that_enumerator = $that->Enumerate();
    		
    		$equal = true;
    		$this_enumerator->rewind();
    		$that_enumerator->rewind();
    		while($this_enumerator->valid() && $that_enumerator->valid())
    		{
    			$this_term = $this_enumerator->current();
    			$that_term = $that_enumerator->current();
    			if($this_term !== $that_term && ($equality === null || !$equality($this_term, $that_term)))
    			{
    				return false;
    			}
    			$this_enumerator->next();
    			$that_enumerator->next();
    		}
    		return !($this_enumerator->valid() || $that_enumerator->valid());
    	}
    
    	/**
    	 * Returns the only value of a sequence that contains only one term; optionally, the only term in a sequence that
    	 * satisfies a given condition.
    	 * Throws an underflow exception if there are no (suitable) terms, and an overflow
    	 * if there is more than one.
    	 *
    	 * @param callable $condition
    	 * @return mixed
    	 * @throws \UnderflowException if the sequence contains no (suitable) terms
    	 * @throws \OverflowException if the sequence contains more than one (suitable) term.
    	 */
    	public function Single(callable $condition = null)
    	{
    		if($condition !== null)
    		{
    			return $this->Where($condition)->Single();
    		}
    		$found = false;
    		foreach($this->Enumerate() as $term)
    		{
    			if($found)
    			{
    				throw new \OverflowException();
    			}
    			$found = true;
    		}
    		if(!$found)
    		{
    			throw new \UnderflowException();
    		}
    		return $term;
    	}
    
    	/**
    	 * As for Single, but returns null instead of an underflow exception.
    	 * It still throws an overflow
    	 * exception in the event that there is more than one term (meeting the condition).
    	 *
    	 * @param callable $condition
    	 * @return mixed
    	 * @throws \OverflowException if the sequence contains more than one (suitable) term.
    	 */
    	public function SingleOrNull(callable $condition = null)
    	{
    		if($condition !== null)
    		{
    			return $this->Where($condition)->SingleOrNull();
    		}
    		$found = false;
    		foreach($this->Enumerate() as $term)
    		{
    			if($found)
    			{
    				throw new \OverflowException();
    			}
    			$found = true;
    		}
    		return $found ? $term : null;
    	}
    
    	/**
    	 * Computes the sum of all the terms in the sequence.
    	 *
    	 * Apart from skipping null terms, it assumes that all terms are numeric, or can be juggled into numeric types.
    	 * Optionally, a transform function can be applied to (non-null) terms to produce a suitable numeric value.
    	 * If all terms are skipped (or there are no terms to begin with), it returns null.
    	 *
    	 * @param callable $transform
    	 * @return mixed
    	 */
    	public function Sum(callable $transform = null)
    	{
    		$sum = null;
    		foreach($this->Enumerate() as $term)
    		{
    			if($term !== null)
    			{
    				if($transform !== null)
    				{
    					$term = $transform($term);
    				}
    				$sum += $term;
    			}
    		}
    		return $sum;
    	}
    
    	/**
    	 * Convert the sequence to an ordinary numerically-indexed array.
    	 *
    	 * @return array
    	 */
    	public function ToArray(): array
    	{
    		return iterator_to_array($this->Enumerate(), false);
    	}
    
    	/**
    	 * Returns a sequence that doesn't have any terms to enumerate over.
    	 * Can be used as a nil object.
    	 *
    	 * @return QueryEnumerable
    	 */
    	public static function Empty()
    	{
    		return new QueryEnumerable([]);
    	}
    
    	/**
    	 * Returns a new QueryEnumerable that will iterate over a given element a specific number of times.
    	 *
    	 * Throws an out of range exception if $count is <= 0.
    	 *
    	 * @param mixed $element
    	 * @param integer $count
    	 * @return QueryEnumerable
    	 * @throws \OutOfRangeException
    	 */
    	public static function Repeat($element, int $count): QueryEnumerable
    	{
    		if($count <= 0)
    		{
    			throw new \OutOfRangeException();
    		}
    		return new Impl\Repeat($element, $count);
    	}
    
    	/**
    	 * Returns a new QueryEnumerable that will iterate consecutive integers starting from a given value
    	 * and continuing for a given number of terms.
    	 *
    	 * Throws an out of range exception if $count is <= 0, or if $start + $count - 1 exceeds PHP_INT_MAX.
    	 *
    	 * @param mixed $element
    	 * @param integer $count
    	 * @return QueryEnumerable
    	 * @throws \OutOfRangeException
    	 */
    	public static function Range(int $start, int $count): QueryEnumerable
    	{
    		if($count <= 0)
    		{
    			throw new \OutOfRangeException();
    		}
    		if($start - 1 > PHP_INT_MAX - $count)
    		{
    			throw new \OutOfRangeException();
    		}
    		return new Impl\Range($start, $count);
    	}
    
    
    /**
    * Returns a new QueryEnumerator equivalent to this QueryEnumerator with an additional
    * element at the end.
    */
    public function Append($element)
    {
    	return new Impl\Append($this, $element);
    }
    
    /**
     *
     * @param QueryEnumerable $that
     * @return QueryEnumerable
     */
    public function Concat(QueryEnumerable $that, QueryEnumerable ...$subsequent): QueryEnumerable
    {
    	return new Impl\Concat($this, $that, ...$subsequent);
    }
    
    /**
     * Similar to Concat, but can take only one QueryEnumerable and only distinct terms are produced;
     * an optional equality test can be provided
     * if the default === operation is insufficient to determine equality.
     * Note that === will still be
     * tried first.
     *
     * @param QueryEnumerable $that
     * @return QueryEnumerable
     */
    public function Union(QueryEnumerable $that, callable $equality = null): QueryEnumerable
    {
    	return $this->Concat($that)->Distinct($equality);
    }
    
    public function DefaultIfEmpty($default): QueryEnumerable
    {
    	return new Impl\DefaultIfEmpty($this, $default);
    }
    
    public function Distinct(callable $equality = null): QueryEnumerable
    {
    	return new Impl\Distinct($this, $equality);
    }
    
    public function Except(QueryEnumerable $exceptions, callable $equality = null): QueryEnumerable
    {
    	return new Impl\Except($this, $exceptions, $equality);
    }
    
    /**
     * Groups elements of a sequence according to a key.
     * $key is used to extract the key for each item.
     *
     * If supplied, $element is used to extract the value to be retained from each item for adding to
     * the group; otherwise the item itself will be added.
     *
     * Each group is represented by a QueryEnumerable object. If $result is supplied, both the key and
     * that QueryEnumerable for each group will be passed to it in turn and the result will become
     * the item returned as part of the GroupBy sequence. Otherwise, the item will be an array pair
     * [group key, QueryEnumerable(group items)].
     *
     * @param callable $key
     * @param callable $element
     * @param callable $result
     * @return QueryEnumerable
     */
    public function GroupBy(callable $key, callable $element = null, callable $result = null)
    {
    	return new Impl\GroupBy($this, $key, $element, $result);
    }
    
    /**
     * Every term of the sequence passed into this method is paired with a term of this sequence according to
     * keys extracted from them by $innerKey and $outerKey respectively.
     * This produces a pair
     * [outer sequence term, group of inner sequence terms]. These get passed into $result($outer, $innersequence)
     * to produce each output sequence term.
     *
     * An optional $equality test can be provided to test keys for equality, if === is considered too narrow.
     *
     * Note that This is a "left join" in the sense that all keys from the outer sequence are represented even
     * if there are no matching items from the inner sequence.
     *
     * Multiple items in the outer sequence may have the same key; all will be paired with the matching items from the inner sequence.
     *
     * @param QueryEnumerable $inner
     * @param callable $outerKey
     * @param callable $innerKey
     * @param callable $result
     * @param callable $equality
     * @return QueryEnumerable
     */
    public function GroupJoin(QueryEnumerable $inner, callable $outerKey, callable $innerKey, callable $result, callable $equality = null)
    {
    	return new Impl\GroupJoin($this, $inner, $outerKey, $innerKey, $result, $equality);
    }
    
    /**
     * Returns every distinct term that appears in both this sequence and the given sequence.
     * If $equality is given, it is used
     * to determine equality in cases where the === operator is too strict.
     *
     * @param QueryEnumerable $that
     * @param callable $equality
     * @return QueryEnumerable
     */
    public function Intersect(QueryEnumerable $that, callable $equality = null)
    {
    	return new Impl\Intersect($this, $that, $equality);
    }
    
    /**
     * A join operation.
     * For each term $o of this sequence and each term $i of that sequence, their keys
     * (as computed by $outerKey and $innerKey, respectively) are compared and, if equal (as determined by === and,
     * optionally, by $equality), the term $result($o, $i) is generated.
     *
     * @param QueryEnumerable $that
     * @param callable $outerKey
     * @param callable $innerKey
     * @param callable $result
     * @param callable $equality
     * @return QueryEnumerable
     */
    public function Join(QueryEnumerable $that, callable $outerKey, callable $innerKey, callable $result, callable $equality = null)
    {
    	return new Impl\Join($this, $that, $outerKey, $innerKey, $result, $equality);
    }
    
    /**
     * Orders the sequence according to the given key selection function.
     * Keys are compared using <=> by default;
     * optionally, a comparator function can be supplied to be used instead.
     *
     * @param callable $keySelector
     * @param callable $comparator
     * @return OrderedQueryEnumerable
     */
    public function OrderBy(callable $keySelector, callable $comparator = null)
    {
    	return new Impl\OrderBy($this, $keySelector, $comparator);
    }
    
    /**
     * Orders the sequence according to the given key selection function in descending order.
     * Keys are compared using <=> by default;
     * optionally, a comparator function can be supplied to be used instead.
     *
     * Note that this is not simply the reversal of OrderBy, as elements with equal keys will have their original order preserved.
     *
     * @param callable $keySelector
     * @param callable $comparator
     * @return OrderedQueryEnumerable
     */
    public function OrderByDescending(callable $keySelector, callable $comparator = null)
    {
    	if($comparator === null)
    	{
    		$comparator = fn($a, $b) => $b <=> $a;
    	}
    	else
    	{
    		$comparator = fn($a, $b) => $comparator($b, $a);
    	}
    	return new Impl\OrderBy($this, $keySelector, $comparator);
    }
    
    public function Reverse()
    {
    	return new Impl\Reverse($this);
    }
    
    /**
     * Transform each item of a sequence into a new form.
     *
     * The selector function may have one or two parameters. The first parameter
     * is passed the item itself. If a two-parameter function is given, the second
     * parameter will receive the item's position in the sequence, starting with 0.
     *
     * @param callable $selector
     * @return QueryEnumerable
     */
    public function Select(callable $selector)
    {
    	return new Impl\Select($this, $selector);
    }
    
    /**
     * For each term in the source sequence, the selector function should
     * produce an iterable sequence; the sequences produced are flattened into
     * one sequence which is then optionally passed through the transformation.
     *
     * As for Select, the selector function is provided with each term's position
     * in the sequence as well as the term itself.
     *
     * If the value returned from the selector is not iterable, it will be inserted
     * into the sequence as-is. Otherwise, it will be iterated over and its elements
     * will be inserted into the sequence.
     *
     * @param callable $sourceSelector
     * @param callable $transformation
     */
    public function SelectMany(callable $sourceSelector, callable $transformation = null)
    {
    	return (new Impl\SelectMany($this, $sourceSelector, $transformation));
    }
    
    /**
     * Discards the first elements of a sequence and then produces the remainder.
     *
     * If the number to be discarded is nonpositive, all elements are retained.
     * If the number is greater than the length of the sequence, no elements are retained.
     *
     * @param int $n
     * @return QueryEnumerable
     */
    public function Skip(int $n)
    {
    	if($n <= 0)
    	{
    		return $this;
    	}
    	return (new Impl\Skip($this, $n));
    }
    
    /**
     * Skips terms from the sequence until encounting one for which the
     * given condition returns false; produces that term and all
     * subsequent terms in the sequence.
     *
     * As for Select, $condition may take one or two arguments, with the
     * second being the term's position in the original sequence.
     *
     * @param callable $condition
     * @return QueryEnumerable
     */
    public function SkipWhile(callable $condition)
    {
    	return new Impl\SkipWhile($this, $condition);
    }
    
    /**
     * Takes the first elements of a sequence and discards the rest.
     *
     * If the number to be discarded is nonpositive, all elements are discarded.
     * If the number is greater than the length of the sequence, all elements are retained.
     *
     * @param int $n
     * @return QueryEnumerable
     */
    public function Take(int $n)
    {
    	if($n <= 0)
    	{
    		return QueryEnumerable::Empty();
    	}
    	return new Impl\Take($this, $n);
    }
    
    /**
     * Takes terms from the sequence until encounting one for which the
     * given condition returns false; discards that term and all
     * subsequent terms in the sequence.
     *
     * As for Select, $condition may take one or two arguments, with the
     * second being the term's position in the original sequence.
     *
     * @param callable $condition
     * @return QueryEnumerable
     */
    public function TakeWhile(callable $condition)
    {
    	return new Impl\TakeWhile($this, $condition);
    }
    
    /**
     * ThenBy can only be used immediately following a previous OrderBy, OrderByDescending, ThenBy, or
     * ThenByDescending method.
     * It performs a subsequent ordering on the previously ordered sequence.
     * That is, any elements that compared equal with respect to the previous sequence will be further
     * sorted according to the selection function, and optional comparator function, given here.
     *
     * @param callable $keySelector
     * @param callable $comparator
     * @return OrderedQueryEnumerable
     * @throws \BadMethodCallException
     */
    public function ThenBy(callable $keySelector, callable $comparator = null)
    {
    	throw new \BadMethodCallException("ThenBy can only be used immediately following (Order|Then)By(Descending)?");
    }
    
    /**
     * As OrderedByDescending is to OrderedBy, so ThenByDescending is to ThenBy.
     *
     * ThenByDescending can only be used immediately following a previous OrderBy, OrderByDescending, ThenBy, or
     * ThenByDescending method. It performs a subsequent reverseordering on the previously ordered sequence.
     * That is, any elements that compared equal with respect to the previous sequence will be further
     * sorted according to the selection function, and optional comparator function, given here.
     *
     * @param callable $keySelector
     * @param callable $comparator
     * @return OrderedQueryEnumerable
     * @throws \BadMethodCallException
     */
    public function ThenByDescending(callable $keySelector, callable $comparator = null)
    {
    	throw new \BadMethodCallException("ThenByDescending can only be used immediately following (Order|Then)By(Descending?)");
    }
    
    /**
     * Filters a sequence based on a predicate.
     * Only items that satisfy the predicate are retained.
     *
     * The filter function may have one or two parameters. The first parameter
     * is passed the item itself. If a two-parameter function is given, the second
     * parameter will receive the item's position in the sequence, starting with 0.
     *
     * @param callable $filter
     * @return QueryEnumerable
     */
    public function Where(callable $filter)
    {
    	return new Impl\Where($this, $filter);
    }
    
    public function Zip(QueryEnumerable $that, $transform = null)
    {
    	return new Impl\Zip($this, $that, $transform);
    }
    }
    

      linq_impl.php

      <?php
      
      namespace Linq\Impl;
      
      use \Linq\QueryEnumerable;
      
      require_once 'linq.php';
      
      
      class Repeat extends QueryEnumerable
      {
      	private $element;
      	private int $count;
      
      public function __construct($element, int $count)
      {
      	if($count <= 0)
      	{
      		throw new \OutOfRangeException();
      	}
      	$this->element = $element;
      	$this->count = $count;
      }
      
      public function Enumerate()
      {
      	for($i = 0; $i < $this->count; ++$i)
      	{
      		yield $this->element;
      	}
      }
      }
      
      
      class Range extends QueryEnumerable
      {
      	private int $start;
      	private int $count;
      
      public function __construct(int $start, int $count)
      {
      	if($count <= 0)
      	{
      		throw new \OutOfRangeException();
      	}
      	if($start - 1 > PHP_INT_MAX - $count)
      	{
      		throw new \OutOfRangeException();
      	}
      	$this->start = $start;
      	$this->count = $count;
      }
      
      public function Enumerate()
      {
      	$max = $this->start + $this->count;
      	for($i = $this->start; $i < $max; ++$i)
      	{
      		yield $i;
      	}
      }
      }
      
      class Append extends QueryEnumerable
      {
      	private QueryEnumerable $source;
      	private $element;
      	public function __construct(QueryEnumerable $source, $element)
      	{
      		$this->source = $source;
      		$this->element = $element;
      	}
      	public function Enumerate()
      	{
      		yield from $this->source->Enumerate();
      		yield $this->element;
      	}
      }
      
      
      class Concat extends QueryEnumerable
      {
      	private array $enumerables = [];
      
      public function __construct(QueryEnumerable $first, QueryEnumerable $second, QueryEnumerable ...$subsequent)
      {
      	array_unshift($subsequent, $second);
      	array_unshift($subsequent, $first);
      	$this->enumerables = $subsequent;
      }
      
      public function Enumerate()
      {
      	foreach($this->enumerables as $enumerable)
      	{
      		yield from $enumerable->Enumerate();
      	}
      }
      }
      
      
      class DefaultIfEmpty extends QueryEnumerable
      {
      	private QueryEnumerable $source;
      	private $default;
      
      public function __construct(QueryEnumerable $source, $default)
      {
      	$this->source = $source;
      	$this->default = $default;
      }
      
      public function Enumerate()
      {
      	$found = false;
      	foreach($this->source->Enumerate() as $term)
      	{
      		$found = true;
      		yield $term;
      	}
      	if(!$found)
      	{
      		yield $this->default;
      	}
      }
      }
      
      
      class Distinct extends QueryEnumerable
      {
      	private QueryEnumerable $source;
      	private $equality;
      
      public function __construct(QueryEnumerable $source, callable $equality = null)
      {
      	$this->source = $source;
      	$this->equality = $equality;
      }
      
      public function Enumerate()
      {
      	$seen = [];
      	if($this->equality === null)
      	{
      		foreach($this->source->Enumerate() as $term)
      		{
      			if(!in_array($term, $seen, true))
      			{
      				$seen[] = $term;
      				yield $term;
      			}
      		}
      	}
      	else
      	{
      		$equality = $this->equality;
      		foreach($this->source->Enumerate() as $term)
      		{
      			$found = false;
      			foreach($seen as $s)
      			{
      				if($equality($term, $s))
      				{
      					$found = true;
      					break;
      				}
      			}
      			if(!$found)
      			{
      				$seen[] = $term;
      				yield $term;
      			}
      		}
      	}
      }
      }
      
      
      class Except extends QueryEnumerable
      {
      	private QueryEnumerable $source;
      	private QueryEnumerable $exceptions;
      	private $equality;
      
      public function __construct(QueryEnumerable $source, QueryEnumerable $exceptions, callable $equality = null)
      {
      	$this->source = $source;
      	$this->exceptions = $exceptions;
      	$this->equality = $equality;
      }
      
      public function Enumerate()
      {
      	$seen = $this->exceptions->Distinct($this->equality)->ToArray();
      	if($this->equality === null)
      	{
      		foreach($this->source->Enumerate() as $term)
      		{
      			if(!in_array($term, $seen, true))
      			{
      				$seen[] = $term;
      				yield $term;
      			}
      		}
      	}
      	else
      	{
      		$equality = $this->equality;
      		foreach($this->source->Enumerate() as $term)
      		{
      			$found = false;
      			foreach($seen as $s)
      			{
      				if($equality($term, $s))
      				{
      					$found = true;
      					break;
      				}
      			}
      			if(!$found)
      			{
      				$seen[] = $term;
      				yield $term;
      			}
      		}
      	}
      }
      }
      
      
      class GroupBy extends QueryEnumerable
      {
      	private QueryEnumerable $source;
      	private $keySelector;
      	private $elementSelector;
      	private $resultTransform;
      
      public function __construct(QueryEnumerable $source, callable $keySelector = null, callable $elementSelector = null, callable $resultTransform = null)
      {
      	$this->source = $source;
      	$this->keySelector = $keySelector;
      	$this->elementSelector = $elementSelector;
      	$this->resultTransform = $resultTransform;
      }
      
      public function Enumerate()
      {
      	$keys = [];
      	$groups = [];
      	
      	$keySelector = $this->keySelector;
      	$elementSelector = $this->elementSelector;
      	foreach($this->source->Enumerate() as $item)
      	{
      		$key = $keySelector($item);
      		if($elementSelector !== null)
      		{
      			$item = $elementSelector($item);
      		}
      		if(($idx = array_search($key, $keys, true)) !== false)
      		{
      			$groups[$idx][] = $item;
      		}
      		else
      		{
      			$keys[] = $key;
      			$groups[] = [$item];
      		}
      	}
      
      	$groups = array_map(fn($group) => new QueryEnumerable($group), $groups);
      	if($this->resultTransform !== null)
      	{
      		$groups =array_map($this->resultTransform, $keys, $groups);
      	}
      	yield from array_combine($keys, $groups);
      }
      }
      
      
      class GroupJoin extends QueryEnumerable
      {
      	private QueryEnumerable $outer;
      	private QueryEnumerable $inner;
      	private $outerKeySelector;
      	private $innerKeySelector;
      	private $resultTransform;
      	private $equality;
      
      public function __construct(QueryEnumerable $outer, QueryEnumerable $inner, callable $outerKeySelector, callable $innerKeySelector, callable $resultTransform, callable $equality = null)
      {
      	$this->outer = $outer;
      	$this->inner = $inner;
      	$this->outerKeySelector = $outerKeySelector;
      	$this->innerKeySelector = $innerKeySelector;
      	$this->resultTransform = $resultTransform;
      	$this->equality = $equality;
      }
      
      public function Enumerate()
      {
      	$outerSequence = $this->outer->ToArray();
      	$keys = array_map($this->outerKeySelector, $outerSequence);
      	$count = count($keys);
      	$innerGroups = array_fill(0, $count, []);
      	$innerKeySelector = $this->innerKeySelector;
      	$equality = $this->equality;
      	foreach($this->inner->Enumerate() as $innerTerm)
      	{
      		$innerKey = $innerKeySelector($innerTerm);
      		
      		// Repeated linear searches. Yeah, hashing would be a good idea...
      		for($i = 0; $i < $count; ++$i)
      		{
      			$key = $keys[$i];
      			if($innerKey === $key || ($equality !== null && $equality($innerKey, $key)))
      			{
      				$innerGroups[$i][] = $innerTerm;
      			}
      		}
      	}
      	$groups = array_map(fn($group) => new QueryEnumerable($group), $innerGroups);
      	if($this->resultTransform !== null)
      	{
      		$groups = array_map($this->resultTransform, $outerSequence, $groups);
      	}
      	yield from $groups;
      }
      }
      
      
      class Intersect extends QueryEnumerable
      {
      	private QueryEnumerable $left;
      	private QueryEnumerable $right;
      	private $equality;
      
      public function __construct(QueryEnumerable $left, QueryEnumerable $right, callable $equality = null)
      {
      	$this->left = $left;
      	$this->right = $right;
      	$this->equality = $equality;
      }
      
      public function Enumerate()
      {
      	$lefts = $this->left->Distinct($this->equality)->ToArray();
      	foreach($this->right->Distinct($this->equality)->Enumerate() as $right)
      	{
      		if(in_array($right, $lefts, true))
      		{
      			yield $right;
      		}
      		elseif($this->equality !== null)
      		{
      			foreach($lefts as $left)
      			{
      				if(($this->equality)($right, $left))
      				{
      					yield $right;
      					break;
      				}
      			}
      		}
      	}
      }
      }
      
      
      class Join extends QueryEnumerable
      {
      	private QueryEnumerable $outer;
      	private QueryEnumerable $inner;
      	private $outerKey;
      	private $innerKey;
      	private $result;
      	private $equality;
      
      public function __construct(QueryEnumerable $outer, QueryEnumerable $inner, callable $outerKey, callable $innerKey, callable $result, callable $equality = null)
      {
      	$this->outer = $outer;
      	$this->inner = $inner;
      	$this->outerKey = $outerKey;
      	$this->innerKey = $innerKey;
      	$this->result = $result;
      	$this->equality = $equality;
      }
      
      public function Enumerate()
      {
      	$outer = $this->outer;
      	$inner = $this->inner;
      	$outerKey = $this->outerKey;
      	$innerKey = $this->innerKey;
      	$result = $this->result;
      	$equality = $this->equality;
      
      	$i = 0;
      	$ikeys = $inners = [];
      	foreach($inner->Enumerate() as $item)
      	{
      		$ikey = $innerKey($item);
      		if(($idx = array_search($ikey, $ikeys, true)) === false)
      		{
      			$ikeys[$i] = $ikey;
      			$inners[$i] = [];
      			$idx = $i++;
      		}
      		$inners[$idx][] = $item;
      	}
      
      	foreach($outer->Enumerate() as $item)
      	{
      		$okey = $outerKey($item);
      		if(($idx = array_search($okey, $ikeys, true)) !== false)
      		{
      			yield from array_map(fn($inner) => $result($item, $inner), $inners[$idx]);
      		}
      		elseif($equality !== null)
      		{
      			foreach($ikeys as $ikey)
      			{
      				if($equality($ikey, $okey))
      				{
      					yield from array_map(fn($inner) => $result($item, $inner), $inners[$ikey]);
      				}
      			}
      		}
      	}
      }
      }
      
      
      class Reverse extends QueryEnumerable
      {
      	private QueryEnumerable $source;
      
      public function __construct(QueryEnumerable $source)
      {
      	$this->source = $source;
      }
      
      public function Enumerate()
      {
      	yield from array_reverse($this->source->ToArray());
      }
      }
      
      
      class Select extends QueryEnumerable
      {
      	private QueryEnumerable $source;
      	private $transform;
      
      public function __construct(QueryEnumerable $source, callable $transform)
      {
      	$this->source = $source;
      	$this->transform = $transform;
      }
      
      public function Enumerate()
      {
      	$i = 0;
      	foreach($this->source->Enumerate() as $item)
      	{
      		yield ($this->transform)($item, $i);
      		++$i;
      	}
      }
      }
      
      
      class SelectMany extends QueryEnumerable
      {
      	private QueryEnumerable $source;
      	private $transform;
      
      public function __construct($source, $sourceSelector, $transform = null)
      {
      	$this->source = $source;
      	$this->sourceSelector = $sourceSelector;
      	$this->transform = $transform;
      }
      
      public function Enumerate()
      {
      	$i = 0;
      	foreach($this->source->Enumerate() as $item)
      	{
      		$subsequence = ($this->sourceSelector)($item, $i++);
      		if($subsequence instanceof QueryEnumerable)
      		{
      			$subsequence = $subsequence->Enumerate();
      		}
      		if(!is_iterable($subsequence))
      		{
      			$subsequence = [$subsequence];
      		}
      		if($this->transform !== null)
      		{
      			foreach($subsequence as $subitem)
      			{
      				yield ($this->transform)($item, $subitem);
      			}
      		}
      		else
      		{
      			yield from $subsequence;
      		}
      	}
      }
      }
      
      
      class Skip extends QueryEnumerable
      {
      	private QueryEnumerable $source;
      	private int $skip;
      
      public function __construct(QueryEnumerable $source, int $skip)
      {
      	$this->source = $source;
      	$this->skip = $skip;
      }
      
      public function Enumerate()
      {
      	if($this->skip <= 0)
      	{
      		yield from $this->source->Enumerate();
      	}
      	else
      	{
      		$skipped = 0;
      		foreach($this->source->Enumerate() as $term)
      		{
      			if($skipped < $this->skip)
      			{
      				++$skipped;
      			}
      			else
      			{
      				yield $term;
      			}
      		}
      	}
      }
      }
      
      
      class SkipWhile extends QueryEnumerable
      {
      	private QueryEnumerable $source;
      	private $condition;
      
      public function __construct(QueryEnumerable $source, callable $condition)
      {
      	$this->source = $source;
      	$this->condition = $condition;
      }
      
      public function Enumerate()
      {
      	$skipping = true;
      	$i = 0;
      	foreach($this->source->Enumerate() as $term)
      	{
      		if($skipping)
      		{
      			if(!($this->condition)($term, $i++))
      			{
      				$skipping = false;
      				yield $term;
      			}
      		}
      		else
      		{
      			yield $term;
      		}
      	}
      }
      }
      
      
      class Take extends QueryEnumerable
      {
      	private QueryEnumerable $source;
      	private int $take;
      
      public function __construct(QueryEnumerable $source, int $take)
      {
      	$this->source = $source;
      	$this->take = $take;
      }
      
      public function Enumerate()
      {
      	if($this->take > 0)
      	{
      		$i = 0;
      		foreach($this->source->Enumerate() as $term)
      		{
      			yield $term;
      			if(++$i >= $this->take)
      			{
      				break;
      			}
      		}
      	}
      }
      }
      
      
      class TakeWhile extends QueryEnumerable
      {
      	private QueryEnumerable $source;
      	private $condition;
      
      public function __construct(QueryEnumerable $source, callable $condition)
      {
      	$this->source = $source;
      	$this->condition = $condition;
      }
      
      public function Enumerate()
      {
      	$i = 0;
      	foreach($this->source->Enumerate() as $term)
      	{
      		if(!($this->condition)($term, $i++))
      		{
      			break;
      		}
      		yield $term;
      	}
      }
      }
      
      
      class OrderedQueryEnumerable extends QueryEnumerable
      {
      	protected $sortingFunction;
      	protected $sortingFunctionWithTiebreak;
      
      public function ThenBy(callable $criterion, callable $comparator = null)
      {
      	return new ThenBy($this, $criterion, $comparator);
      }
      
      public function ThenByDescending(callable $criterion, callable $comparator = null)
      {
      	if($comparator === null)
      	{
      		$comparator = fn($a, $b) => $b <=> $a;
      	}
      	else
      	{
      		$comparator = fn($a, $b) => $comparator($b, $a);
      	}
      	return new ThenBy($this, $criterion, $comparator);
      }
      }
      
      
      class ThenBy extends OrderedQueryEnumerable
      {
      	private QueryEnumerable $source;
      
      private function createSortingFunction(callable $presort, callable $criterion, callable $comparator = null)
      {
      	if($comparator === null)
      	{
      		$this->sortingFunction = fn($a, $b) => $presort($a, $b) ?: ($criterion($a[1]) <=> $criterion($b[1]));
      	}
      	else
      	{
      		$this->sortingFunction = fn($a, $b) => $presort($a, $b) ?: $comparator($criterion($a[1]), $criterion($b[1]));
      	}
      	$this->sortingFunctionWithTiebreak = fn($a, $b) => ($this->sortingFunction)($a, $b) ?: ($a[0] - $b[0]);
      }
      
      public function __construct(OrderedQueryEnumerable $source, callable $criterion, callable $comparator = null)
      {
      	$this->source = $source;
      
      	$this->createSortingFunction($source->sortingFunction, $criterion, $comparator);
      }
      
      public function Enumerate()
      {
      	$source = $this->source->ToArray();
      	$source = array_map(null, array_keys($source), array_values($source));
      	usort($source, $this->sortingFunctionWithTiebreak);
      	$source = array_column($source, 1);
      	yield from $source;
      }
      }
      
      
      class OrderBy extends OrderedQueryEnumerable
      {
      	private QueryEnumerable $source;
      
      private function createSortingFunction(callable $criterion, callable $comparator = null)
      {
      	if($comparator === null)
      	{
      		$this->sortingFunction = fn($a, $b) => ($criterion($a[1]) <=> $criterion($b[1]));
      	}
      	else
      	{
      		$this->sortingFunction = fn($a, $b) => $comparator($criterion($a[1]), $criterion($b[1]));
      	}
      	$this->sortingFunctionWithTiebreak = fn($a, $b) => ($this->sortingFunction)($a, $b) ?: ($a[0] - $b[0]);
      }
      
      public function __construct(QueryEnumerable $source, callable $criterion, callable $comparator = null)
      {
      	$this->source = $source;
      	$this->createSortingFunction($criterion, $comparator);
      }
      
      public function Enumerate()
      {
      	$source = $this->source->ToArray();
      	$source = array_map(null, array_keys($source), array_values($source));
      	usort($source, $this->sortingFunctionWithTiebreak);
      	$source = array_column($source, 1);
      
      	yield from $source;
      }
      }
      
      
      class Where extends QueryEnumerable
      {
      	private QueryEnumerable $source;
      	private $predicate;
      
      public function __construct(QueryEnumerable $source, callable $predicate)
      {
      	$this->source = $source;
      	$this->predicate = $predicate;
      }
      
      public function Enumerate()
      {
      	$i = 0;
      	$predicate = $this->predicate;
      	foreach($this->source->Enumerate() as $item)
      	{
      		if($predicate($item, $i++))
      		{
      			yield $item;
      		}
      	}
      }
      }
      
      class Zip extends QueryEnumerable
      {
      	private QueryEnumerable $left, $right;
      	private $transform;
      
      public function __construct(QueryEnumerable $left, QueryEnumerable $right, callable $transform = null)
      {
      	$this->left = $left;
      	$this->right = $right;
      	$this->transform = $transform;
      }
      
      public function Enumerate()
      {
      	$this_enumerator = $this->left->Enumerate();
      	$that_enumerator = $this->right->Enumerate();
      	
      	$equal = true;
      	$this_enumerator->rewind();
      	$that_enumerator->rewind();
      	while($this_enumerator->valid() && $that_enumerator->valid())
      	{
      		$this_term = $this_enumerator->current();
      		$that_term = $that_enumerator->current();
      		if($this->transform !== null)
      		{
      			yield ($this->transform)($this_term, $that_term);
      		}
      		else
      		{
      			yield [$this_term, $that_term];
      		}
      		$this_enumerator->next();
      		$that_enumerator->next();
      	}
      }
      }
      

        linq_tests.php

        <?php
        
        require_once('linq.php');
        
        use \Linq\QueryEnumerable as QE;
        
        echo "\nMinimal creation/iteration\n";
        (function(){
        	$qe = new QE([3,1,4,1,5]);
        	$target = '3 1 4 1 5 ';
        	$result = '';
        	foreach($qe->Enumerate() as $q)
        	{
        		$result .= $q . ' ';
        	}
        	echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        echo "\nAggregate\n";
        (function(){
        	$words = explode(' ', "the quick brown fox jumps over the lazy dog");
        	$target = '[dog lazy the over jumps fox brown quick the ]';
        	$reversed = (new QE($words))
        		->Aggregate(
        			fn($workingSentence, $next) => "$next $workingSentence"
        			);
        	$result = "[$reversed]";
        	echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$ints = [4, 8, 8, 3, 9, 0, 7, 8, 2];
        	$target = '';
        	$target = 'The number of even integers is: 6';
        	$numEven = (new QE($ints))->Aggregate(
        		fn($total, $next) => $total + 1 - ($next % 2),
        		0
        		);
        	$result = "The number of even integers is: $numEven";
        	echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$fruits = ["apple", "mango", "orange", "passionfruit", "grape"];
        	$target = 'The fruit with the longest name is PASSIONFRUIT';
        	$longestName = (new QE($fruits))
        		->Aggregate(fn($longest, $next) => strlen($next) > strlen($longest) ? $next : $longest,
        			'banana',
        			fn($fruit) => strtoupper($fruit));
        	$result = "The fruit with the longest name is $longestName";
        	echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        echo "\nAll\n";
        (function(){
        	$pets = [
        		(object)['Name' => 'Barley', 'Age' => 10],
        		(object)['Name' => 'Boots', 'Age' => 4],
        		(object)['Name' => 'Whiskers', 'Age' => 6],
        	];
        	$target = "Not all pet names start with 'B'.";
        	$allStartWithB = (new QE($pets))
        		->All(fn($pet) => substr($pet->Name, 0, 1) == 'B');
        	$result = ($allStartWithB ? 'All' : 'Not all') . " pet names start with 'B'.";
        	echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$people = [
        	(object)['LastName' => "Haas",
        		'Pets' => [
        			(object)['Name' => "Barley", 'Age' => 10],
        			(object)['Name' => "Boots", 'Age' => 14],
        			(object)['Name' => "Whiskers", 'Age' => 6]]],
        	(object)['LastName' => "Fakhouri",
        		'Pets' => [
        			(object)['Name' => "Snowball", 'Age' => 1]]],
        	(object)['LastName' => "Antebi",
        		'Pets' => [
        			(object)['Name' => "Belle", 'Age' => 8]]],
        	(object)['LastName' => "Philips",
        		'Pets' => [
        			(object)['Name' => "Sweetie", 'Age' => 2],
        			(object)['Name' => "Rover", 'Age' => 13]]]];
        
        $target = 'Haas Antebi';
        
        $names = (new QE($people))
        	->Where(fn($person) => (new QE($person->Pets))->All(fn($pet) => $pet->Age > 5))
        	->Select(fn($person) => $person->LastName)->ToArray();
        $result = join(' ', $names);
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nAny\n";
        (function(){
        
        $numbers = [1, 2];
        $target = "The list is not empty";
        $hasElements = (new QE($numbers))->Any();
        $result = sprintf("The list %s empty", $hasElements ? "is not" : "is");
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        (function(){
        	$people = [
        	(object)['LastName' => "Haas",
        		'Pets' => [
        			(object)['Name' => "Barley", 'Age' => 10],
        			(object)['Name' => "Boots", 'Age' => 14],
        			(object)['Name' => "Whiskers", 'Age' => 6]]],
        	(object)['LastName' => "Fakhouri",
        		'Pets' => [
        			(object)['Name' => "Snowball", 'Age' => 1]]],
        	(object)['LastName' => "Antebi",
        		'Pets' => []],
        	(object)['LastName' => "Philips",
        		'Pets' => [
        			(object)['Name' => "Sweetie", 'Age' => 2],
        			(object)['Name' => "Rover", 'Age' => 13]]]];
        
        $target = 'Haas Fakhouri Philips';
        
        $names = (new QE($people))
        	->Where(fn($person) => (new QE($person->Pets))->Any())
        	->Select(fn($person) => $person->LastName)->ToArray();
        $result = join(' ', $names);
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        (function(){
        	$pets = [
        		(object)['Name' => "Barley", 'Age' => 8, 'Vaccinated' => true],
        		(object)['Name' => "Boots", 'Age' => 4, 'Vaccinated' => false],
        		(object)['Name' => "Whiskers", 'Age' => 1, 'Vaccinated' => false],
        	];
        
        $target = 'There are unvaccinated animals over age one.';
        
        $unvaccinated = (new QE($pets))
        	->Any(fn($p) => $p->Age > 1 && !$p->Vaccinated);
        
        $result = sprintf("There %s unvaccinated animals over age one.", $unvaccinated ? "are" : "are not any");
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nAverage\n";
        (function(){
        	$numbers = [10007, null, 37, 399846234235];
        	$target = round((10007 + 37 + 399846234235) / 3, 3);
        	$average = (new QE($numbers))->Average();
        	$result = round($average, 3);
        	echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$fruits = ['apple', 'banana', 'mango', 'orange', 'passionfruit', 'grape'];
        	$target = 6.5;
        	$average = (new QE($fruits))->Average('strlen');
        	$result = $average;
        	echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nContains\n";
        (function(){
        	$fruits = ['apple', 'banana', 'mango', 'orange', 'passionfruit', 'grape'];
        	$mango = 'mango';
        	$target = "The array does contain '$mango'.";
        
        $contains = (new QE($fruits))->Contains($mango);
        $result = sprintf("The array %s contain '$mango'.", $contains ? "does" : "does not");
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$fruits = ['apple', 'banana', 'mango', 'passionfruit', 'grape'];
        	$orange = 'orange';
        	$target = "Another fruit's name is the same length as '$orange'.";
        
        $contains = (new QE($fruits))->Contains($orange, fn($a, $b) => strlen($a) == strlen($b));
        $result = sprintf("Another fruit's name %s the same length as '$orange'.", $contains ? "is" : "is not");
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nCount\n";
        (function(){
        	$fruits = ['apple', 'banana', 'mango', 'orange', 'passionfruit', 'grape'];
        	$target = "There are 6 items in the array.";
        
        $numberOfFruits = (new QE($fruits))->Count();
        $result = "There are $numberOfFruits items in the array.";
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$pets = [
        		(object)['Name' => "Barley", 'Age' => 8, 'Vaccinated' => true],
        		(object)['Name' => "Boots", 'Age' => 4, 'Vaccinated' => false],
        		(object)['Name' => "Whiskers", 'Age' => 1, 'Vaccinated' => false],
        	];
        	$target = "There are 2 unvaccinated animals.";
        
        $numberUnvaccinated = (new QE($pets))->Count(fn($pet) => !$pet->Vaccinated);
        $result = "There are $numberUnvaccinated unvaccinated animals.";
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nElementAt\n";
        (function(){
        	$names = [
        		'Hartono, Tommy',
        		'Adams, Terry',
        		'Andersen, Henriette Thaulow',
        		'Hedlund, Magnus',
        		'Ito, Shu'];
        	$pickOne = 3; // Hedlund, Magnus
        	$target = "Hedlund, Magnus";
        
        $result = (new QE($names))->ElementAt($pickOne);
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$names = [
        		'Hartono, Tommy',
        		'Adams, Terry',
        		'Andersen, Henriette Thaulow',
        		'Hedlund, Magnus',
        		'Ito, Shu'];
        	$pickOne = 9; // OutOfBounds
        
        echo "Invalid Access\n";
        try
        {
        	$result = (new QE($names))->ElementAt($pickOne);
        	echo "FAIL\x07\n\n";
        }
        catch(\OutOfBoundsException $e)
        {
        	echo "PASS\n\n";
        }
        })();
        
        
        
        echo "\nElementAtOrNull\n";
        (function(){
        	$names = [
        		'Hartono, Tommy',
        		'Adams, Terry',
        		'Andersen, Henriette Thaulow',
        		'Hedlund, Magnus',
        		'Ito, Shu'];
        	$pickOne = 3; // Hedlund, Magnus
        	$target = "Hedlund, Magnus";
        
        $result = (new QE($names))->ElementAtOrNull($pickOne);
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$names = [
        		'Hartono, Tommy',
        		'Adams, Terry',
        		'Andersen, Henriette Thaulow',
        		'Hedlund, Magnus',
        		'Ito, Shu'];
        	$pickOne = 9;
        	$target = null;
        
        echo "Invalid Access\n";
        $result = (new QE($names))->ElementAtOrNull($pickOne);
        echo ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nFirst\n";
        (function(){
        	$numbers = [9, 34, 65, 92, 87, 435, 3, 54, 83, 23, 87, 435, 67, 12, 19];
        	$target = 9;
        
        $result = (new QE($numbers))->First();
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$numbers = [9, 34, 65, 92, 87, 435, 3, 54, 83, 23, 87, 435, 67, 12, 19];
        	$target = 92;
        
        $result = (new QE($numbers))->First(fn($n) => $n > 80);
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$numbers = [9, 34, 65, 92, 87, 435, 3, 54, 83, 23, 87, 435, 67, 12, 19];
        
        echo "Invalid Access\n";
        try
        {
        	$result = (new QE($numbers))->First(fn($n) => $n > 800);
        	echo "FAIL\x07\n\n";
        }
        catch(\UnderflowException $e)
        {
        	echo "PASS\n\n";
        }
        })();
        
        
        
        echo "\nFirstOrNull\n";
        (function(){
        	$numbers = [9, 34, 65, 92, 87, 435, 3, 54, 83, 23, 87, 435, 67, 12, 19];
        	$target = 9;
        
        $result = (new QE($numbers))->FirstOrNull();
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$numbers = [];
        	$target = null;
        
        $result = (new QE($numbers))->FirstOrNull();
        echo "Invalid Access\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$numbers = [9, 34, 65, 92, 87, 435, 3, 54, 83, 23, 87, 435, 67, 12, 19];
        	$target = null;
        
        $result = (new QE($numbers))->FirstOrNull(fn($n) => $n > 800);
        echo "Invalid Access\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nLast\n";
        (function(){
        	$numbers = [9, 34, 65, 92, 87, 435, 3, 54, 83, 23, 87, 435, 67, 12, 19];
        	$target = 19;
        
        $result = (new QE($numbers))->Last();
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$numbers = [9, 34, 65, 92, 87, 435, 3, 54, 83, 23, 87, 435, 67, 12, 19];
        	$target = 435;
        
        $result = (new QE($numbers))->Last(fn($n) => $n > 80);
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nLastOrNull\n";
        (function(){
        	$numbers = [9, 34, 65, 92, 87, 435, 3, 54, 83, 23, 87, 435, 67, 12, 19];
        	$target = 19;
        
        $result = (new QE($numbers))->LastOrNull();
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$numbers = [];
        	$target = null;
        
        $result = (new QE($numbers))->LastOrNull(fn($n) => $n > 800);
        echo "Invalid Access\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$numbers = [9, 34, 65, 92, 87, 435, 3, 54, 83, 23, 87, 435, 67, 12, 19];
        	$target = null;
        
        $result = (new QE($numbers))->LastOrNull(fn($n) => $n > 800);
        echo "Invalid Access\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nMax\n";
        (function(){
        	$numbers = [4_294_967_296, 466_855_135, 81_125];
        	$target = "The largest number is 4294967296.";
        
        $max = (new QE($numbers))->Max();
        $result = "The largest number is $max.";
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$pets = [
        		(object)['Name' => "Barley", 'Age' => 8],
        		(object)['Name' => "Boots", 'Age' => 4],
        		(object)['Name' => "Whiskers", 'Age' => 1],
        	];
        	$target = "The maximum pet age plus name length is 14."; // Barley
        
        $max = (new QE($pets))->Max(fn($pet) => $pet->Age + strlen($pet->Name));
        $result = "The maximum pet age plus name length is $max.";
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        
        echo "\nMin\n";
        (function(){
        	$numbers = [4_294_967_296, 466_855_135, 81_125];
        	$target = "The smallest number is 81125.";
        
        $min = (new QE($numbers))->Min();
        $result = "The smallest number is $min.";
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$pets = [
        		(object)['Name' => "Barley", 'Age' => 8],
        		(object)['Name' => "Boots", 'Age' => 4],
        		(object)['Name' => "Whiskers", 'Age' => 1],
        	];
        	$target = "The youngest animal is age 1.";
        
        $min = (new QE($pets))->Min(fn($pet) => $pet->Age);
        $result = "The youngest animal is age $min.";
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        
        echo "\nSequenceEqual\n";
        (function(){
        	$pet1 = (object)['Name' => "Turbo", 'Age' => 2];
        	$pet2 = (object)['Name' => "Peanut", 'Age' => 8];
        	$pets1 = new QE([$pet1, $pet2]);
        	$pets2 = new QE([$pet1, $pet2]);
        	$target = "The lists are equal.";
        
        $equal = $pets1->SequenceEqual($pets2);
        $result = sprintf("The lists %s equal.", $equal ? "are" : "are NOT");
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$pet1 = (object)['Name' => "Turbo", 'Age' => 2];
        	$pet2 = (object)['Name' => "Peanut", 'Age' => 8];
        	$pets1 = new QE([$pet1, $pet2]);
        	$pets2 = new QE([(object)['Name' => "Turbo", 'Age' => 2], (object)['Name' => "Peanut", 'Age' => 8]]);
        	$target = "The lists are NOT equal.";
        
        $equal = $pets1->SequenceEqual($pets2);
        $result = sprintf("The lists %s equal.", $equal ? "are" : "are NOT");
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        (function(){
        	$pet1 = (object)['Name' => "Turbo", 'Age' => 2];
        	$pet2 = (object)['Name' => "Peanut", 'Age' => 8];
        	$pets1 = new QE([$pet1, $pet2]);
        	$pets2 = new QE([(object)['Name' => "Turbo", 'Age' => 2], (object)['Name' => "Peanut", 'Age' => 8]]);
        	$target = "The lists are equal.";
        
        $equal = $pets1->SequenceEqual($pets2, fn($p1, $p2) => $p1->Name == $p2->Name && $p1->Age == $p2->Age);
        $result = sprintf("The lists %s equal.", $equal ? "are" : "are NOT");
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        
        echo "\nSingle\n";
        (function(){
        	$fruits1 = ['orange'];
        
        $target = "First query: orange";
        $fruit1 = (new QE($fruits1))->Single();
        $result = "First query: $fruit1";
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$fruits2 = ['orange', 'apple'];
        
        echo "Invalid Access\n";
        try
        {
        	$fruit2 = (new QE($fruits2))->Single();
        	echo "Second query: $fruit2\nFAIL\x07\n\n";
        }
        catch(\OverflowException $e)
        {
        	echo "PASS\n\n";
        }
        })();
        
        (function(){
        	$fruits0 = [];
        
        echo "Invalid Access\n";
        try
        {
        	$fruit0 = (new QE($fruits0))->Single();
        	echo "Third query: $fruit0\nFAIL\x07\n\n";
        }
        catch(\UnderflowException $e)
        {
        	echo "PASS\n\n";
        }
        })();
        
        (function(){
        	$fruits = ['apple', 'banana', 'mango', 'orange', 'passionfruit', 'grape'];
        	$target = "First query: passionfruit";
        
        $fruit = (new QE($fruits))->Single(fn($fruit) => strlen($fruit) > 10); // Passionfruit
        $result = "First query: $fruit";
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$fruits = ['apple', 'banana', 'mango', 'orange', 'passionfruit', 'grape'];
        	echo "Invalid Access\n";
        	try
        	{
        		$fruit = (new QE($fruits))->Single(fn($fruit) => strlen($fruit) == 5);
        		echo "Second query: $fruit\nFAIL\x07\n\n";
        	}
        	catch(\OverflowException $e)
        	{
        		echo "PASS\n\n";
        	}
        })();
        
        (function(){
        	$fruits = ['apple', 'banana', 'mango', 'orange', 'passionfruit', 'grape'];
        
        echo "Invalid Access\n";
        try
        {
        	$fruit = (new QE($fruits))->Single(fn($fruit) => strlen($fruit) > 15);
        	echo "Third query: $fruit\nFAIL\x07\n\n";
        }
        catch(\UnderflowException $e)
        {
        	echo "PASS\n\n";
        }
        })();
        
        
        
        echo "\nSingleOrNull\n";
        (function(){
        	$fruits1 = ['orange'];
        
        $target = "First query: orange";
        $fruit1 = (new QE($fruits1))->SingleOrNull();
        $result = "First query: $fruit1";
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$fruits2 = ['orange', 'apple'];
        	$target = null;
        
        echo "Invalid Access\n";
        try
        {
        	$fruit2 = (new QE($fruits2))->SingleOrNull();
        	echo "Second query: $fruit2\nFAIL\x07\n\n";
        }
        catch(\OverflowException $e)
        {
        	echo "PASS\n\n";
        }
        })();
        
        (function(){
        	$fruits0 = [];
        	$target = null;
        
        $result = (new QE($fruits0))->SingleOrNull();
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$fruits = ['apple', 'banana', 'mango', 'orange', 'passionfruit', 'grape'];
        	$target = "First query: passionfruit";
        
        $fruit = (new QE($fruits))->SingleOrNull(fn($fruit) => strlen($fruit) > 10); // Passionfruit
        $result = "First query: $fruit";
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$fruits = ['apple', 'banana', 'mango', 'orange', 'passionfruit', 'grape'];
        	echo "Invalid Access\n";
        	try
        	{
        		$fruit = (new QE($fruits))->SingleOrNull(fn($fruit) => strlen($fruit) == 5);
        		echo "Second query: $fruit\nFAIL\x07\n\n";
        	}
        	catch(\OverflowException $e)
        	{
        		echo "PASS\n\n";
        	}
        })();
        
        (function(){
        	$fruits = ['apple', 'banana', 'mango', 'orange', 'passionfruit', 'grape'];
        	$target = null;
        
        $result = (new QE($fruits))->SingleOrNull(fn($fruit) => strlen($fruit) > 15);
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nSum\n";
        (function(){
        	$numbers = [43.68, 1.25, 583.7, 6.5];
        	$target = round(array_sum($numbers), 2);
        	$target = "The sum of the numbers is $target.";
        
        $sum = (new QE($numbers))->Sum();
        $result = round($sum, 2);
        $result = "The sum of the numbers is $result.";
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$packages = [
        		(object)['Company' => 'Coho Vineyard', 'Weight' => 252],
        		(object)['Company' => 'Lucerne Publishing', 'Weight' => 187],
        		(object)['Company' => 'Wingtip Toys', 'Weight' =>  60],
        		(object)['Company' => 'Adventure Works', 'Weight' => 338],
        	];
        	$target = "The total weight of the packages is 837.";
        
        $totalWeight = (new QE($packages))->Sum(fn($pkg) => $pkg->Weight);
        $result = "The total weight of the packages is $totalWeight.";
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nToArray\n";
        (function(){
        	$target = ['foo', 17, false];
        
        $result = (new QE($target))->ToArray();
        echo ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nEmpty\n";
        (function(){
        	$target = [];
        
        $result = QE::Empty()->ToArray();
        echo ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$target = [];
        
        try
        {
        	$result = QE::Empty()->Single();
        	echo "FAIL\x07\n\n";
        }
        catch(\UnderflowException $e)
        {
        	echo "PASS\n\n";
        }
        })();
        
        
        
        echo "\nRepeat\n";
        (function(){
        	$target = [17,17,17,17];
        
        $result = QE::Repeat(17, 4)->ToArray();
        echo ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$target = [];
        
        try
        {
        	$result = QE::Repeat('fnord', 0)->ToArray();
        	echo "FAIL\x07\n\n";
        }
        catch(\OutOfRangeException $e)
        {
        	echo "PASS\n\n";
        }
        })();
        
        
        
        echo "\nRange\n";
        (function(){
        	$target = [8,9,10,11];
        
        $result = QE::Range(8, 4)->ToArray();
        echo ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	try
        	{
        		$result = QE::Range(PHP_INT_MAX - 8, 97)->ToArray();
        		echo "FAIL\x07\n\n";
        	}
        	catch(\OutOfRangeException $e)
        	{
        		echo "PASS\n\n";
        	}
        })();
        
        (function(){
        	try
        	{
        		$result = QE::Range(97, -8)->ToArray();
        		echo "FAIL\x07\n\n";
        	}
        	catch(\OutOfRangeException $e)
        	{
        		echo "PASS\n\n";
        	}
        })();
        
        
        
        echo "\nAppend\n";
        (function(){
        	$numbers = [8,9,10,11];
        
        $old_number_qe = (new QE($numbers));
        $new_number_qe = $old_number_qe->Append(5);
        
        $before_target = "8, 9, 10, 11";
        $after_target = "8, 9, 10, 11, 5";
        
        $before_result = join(', ', $old_number_qe->ToArray());
        $after_result = join(', ', $new_number_qe->ToArray());
        
        
        echo ($before_result === $before_target && $after_target == $after_result ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nConcat\n";
        (function(){
        	$getCats = function()
        	{
        		return (new QE([
        			(object)['Name' => "Barley", 'Age' => 8],
        			(object)['Name' => "Boots", 'Age' => 4],
        			(object)['Name' => "Whiskers", 'Age' => 1],
        		]));
        	};
        	$getDogs = function()
        	{
        		return (new QE([
        			(object)['Name' => "Bounder", 'Age' => 3],
        			(object)['Name' => "Snoopy", 'Age' => 14],
        			(object)['Name' => "Fido", 'Age' => 9],
        		]));
        	};
        
        $target = "Barley Boots Whiskers Bounder Snoopy Fido";
        
        $cats = $getCats();
        $dogs = $getDogs();
        $query = $cats->Select(fn($cat) => $cat->Name)->Concat($dogs->Select(fn($dog) => $dog->Name));
        $result = join(' ', $query->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        
        })();
        
        (function(){
        	$getCats = function()
        	{
        		return (new QE([
        			(object)['Name' => "Barley", 'Age' => 8],
        			(object)['Name' => "Boots", 'Age' => 4],
        			(object)['Name' => "Whiskers", 'Age' => 1],
        		]));
        	};
        	$getDogs = function()
        	{
        		return (new QE([
        			(object)['Name' => "Bounder", 'Age' => 3],
        			(object)['Name' => "Snoopy", 'Age' => 14],
        			(object)['Name' => "Fido", 'Age' => 9],
        		]));
        	};
        
        $target = "Barley Boots Whiskers Bounder Snoopy Fido";
        
        $cats = $getCats();
        $dogs = $getDogs();
        $query = (new QE([
        	$cats->Select(fn($cat) => $cat->Name),
        	$dogs->Select(fn($dog) => $dog->Name)]))->SelectMany(fn($name) => $name);
        $result = join(' ', $query->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        
        })();
        
        
        
        echo "\nUnion\n";
        (function(){
        	$ints1 = [5,3,9,7,5,9,3,7];
        	$ints2 = [8,3,6,4,4,9,1,0];
        	$target = "539786410";
        	$union = (new QE($ints1))->Union(new QE($ints2));
        	$result = join($union->ToArray());
        	echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$equal = function($left, $right)
        	{
        		if($left === $right)
        		{
        			return true;
        		}
        		if($left === null || $right === null)
        		{
        			return false;
        		}
        		else
        		{
        			return $left->Name == $right->Name && $left->Code == $right->Code;
        		}
        	};
        
        $store1 = [
        	(object)['Name' => 'apple', 'Code' => 9],
        	(object)['Name' => 'orange', 'Code' => 4],
        ];
        $store2 = [
        	(object)['Name' => 'apple', 'Code' => 9],
        	(object)['Name' => 'lemon', 'Code' => 12],
        ];
        
        $target = "apple 9, orange 4, lemon 12";
        
        $union = (new QE($store1))->Union(new QE($store2), $equal);
        $result = [];
        foreach($union->Enumerate() as $fruit)
        {
        	$result[] = "{$fruit->Name} {$fruit->Code}";
        }
        $result = join(", ", $result);
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nDefaultIfEmpty\n";
        (function(){
        
        $defaultPet = (object)['Name' => "Default Pet", 'Age' => 0];
        
        $pets = [
        	(object)['Name' => "Barley", 'Age' => 8],
        	(object)['Name' => "Boots", 'Age' => 4],
        	(object)['Name' => "Whiskers", 'Age' => 1],
        ];
        $target = "Barley, Boots, Whiskers";
        
        $list = (new QE($pets))->DefaultIfEmpty($defaultPet);
        $result = join(', ', $list->Select(fn($pet) => $pet->Name)->ToArray());
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        
        $defaultPet = (object)['Name' => "Default Pet", 'Age' => 0];
        
        $pets = [
        ];
        $target = "Default Pet";
        
        $list = (new QE($pets))->DefaultIfEmpty($defaultPet);
        $result = join(', ', $list->Select(fn($pet) => $pet->Name)->ToArray());
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nDistinct\n";
        (function(){
        
        $ages = [21, 46, 46, 55, 17, 55, 55];
        
        $target = "21 46 55 17";
        
        $distinctAges = (new QE($ages))->Distinct();
        $result = join(' ', $distinctAges->ToArray());
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        
        $equal = function($left, $right)
        {
        	if($left === $right)
        	{
        		return true;
        	}
        	if($left === null || $right === null)
        	{
        		return false;
        	}
        	else
        	{
        		return $left->Name == $right->Name && $left->Code == $right->Code;
        	}
        };
        
        $products = [
        	(object)['Name' => 'apple', 'Code' => 9],
        	(object)['Name' => 'orange', 'Code' => 4],
        	(object)['Name' => 'apple', 'Code' => 9],
        	(object)['Name' => 'lemon', 'Code' => 12],
        ];
        $target = "apple 9, orange 4, lemon 12";
        
        $noDuplicates = (new QE($products))->Distinct($equal);
        $result = join(', ', $noDuplicates->Select(fn($fruit) => "{$fruit->Name} {$fruit->Code}")->ToArray());
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nExcept\n";
        (function(){
        
        $numbers1 = [20, 20, 21, 22, 23, 23, 24, 25];
        $numbers2 = [22];
        
        $target = "20, 21, 23, 24, 25";
        
        $onlyInFirst = (new QE($numbers1))->Except(new QE($numbers2));
        
        $result = join(', ', $onlyInFirst->ToArray());
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        (function(){
        
        $equal = function($left, $right)
        {
        	if($left === $right)
        	{
        		return true;
        	}
        	if($left === null || $right === null)
        	{
        		return false;
        	}
        	else
        	{
        		return $left->Name == $right->Name && $left->Code == $right->Code;
        	}
        };
        
        $products = [
        	(object)['Name' => 'apple', 'Code' => 9],
        	(object)['Name' => 'orange', 'Code' => 4],
        	(object)['Name' => 'apple', 'Code' => 9],
        	(object)['Name' => 'lemon', 'Code' => 12],
        ];
        $removed = [(object)['Name' => 'apple', 'Code' => 9]];
        $target = "orange 4, lemon 12";
        
        $except = (new QE($products))->Except(new QE($removed), $equal);
        $result = join(', ', $except->Select(fn($fruit) => "{$fruit->Name} {$fruit->Code}")->ToArray());
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nGroupBy\n";
        (function(){
        	$pets = [
        		(object)['Name' => "Barley", 'Age' => 8],
        		(object)['Name' => "Boots", 'Age' => 4],
        		(object)['Name' => "Whiskers", 'Age' => 1],
        		(object)['Name' => "Daisy", 'Age' => 4],
        	];
        	$target = "8\n\tBarley\n4\n\tBoots\n\tDaisy\n1\n\tWhiskers\n";
        
        $query = (new QE($pets))->GroupBy(fn($pet) => $pet->Age, fn($pet) => $pet->Name);
        
        $result = '';
        foreach($query->Enumerate() as $key => $petGroup)
        {
        	$result .= "$key\n";
        	foreach($petGroup->Enumerate() as $name)
        	{
        		$result .= "\t$name\n";
        	}
        }
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$pets = [
        		(object)['Name' => "Barley", 'Age' => 8.3],
        		(object)['Name' => "Boots", 'Age' => 4.9],
        		(object)['Name' => "Whiskers", 'Age' => 1.5],
        		(object)['Name' => "Daisy", 'Age' => 4.3],
        	];
        	$target = "\nAge Group: 8
        Number of pets in this age group: 1
        Minimum age: " . 8.3 . "
        Maximum age: " . 8.3 . "
        
        Age Group: 4
        Number of pets in this age group: 2
        Minimum age: " . min(4.9, 4.3) . "
        Maximum age: " . max(4.9, 4.3) . "
        
        Age Group: 1
        Number of pets in this age group: 1
        Minimum age: " . 1.5 . "
        Maximum age: " . 1.5 . "\n";
        
        $query = (new QE($pets))->GroupBy(
        	fn($pet) => floor($pet->Age),
        	null,
        	fn($age, $pets) => (object)[
        		'Key' => $age,
        		'Count' => $pets->Count(),
        		'Min' => $pets->Min(fn($pet) => $pet->Age),
        		'Max' => $pets->Max(fn($pet) => $pet->Age)]
        	);
        
        $result = '';
        foreach($query->Enumerate() as $key => $petGroup)
        {
        	$result .= "\nAge Group: $key\n";
        	$result .= "Number of pets in this age group: {$petGroup->Count}\n";
        	$result .= "Minimum age: {$petGroup->Min}\n";
        	$result .= "Maximum age: {$petGroup->Max}\n";
        }
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nGroupJoin\n";
        (function(){
        	$people = [
        		$magnus = (object)['Name' => "Hedlund, Magnus"],
        		$terry = (object)['Name' => "Adams, Terry"],
        		$charlotte = (object)['Name' => "Weiss, Charlotte"],
        	];
        	$pets = [
        		(object)['Name' => "Barley", 'Owner' => $terry],
        		(object)['Name' => "Boots", 'Owner' => $terry],
        		(object)['Name' => "Whiskers", 'Owner' => $charlotte],
        		(object)['Name' => "Daisy", 'Owner' => $magnus],
        	];
        
        $target = 
        "Hedlund, Magnus: Daisy\n" .
        "Adams, Terry: Barley, Boots\n" .
        "Weiss, Charlotte: Whiskers\n";
        	$query = (new QE($people))->GroupJoin(new QE($pets),
        		fn($person) => $person,
        		fn($pet) => $pet->Owner,
        		fn($person, $petCollection) => (object)[
        			'OwnerName' => $person->Name,
        			'Pets' => $petCollection->Select(fn($pet) => $pet->Name)
        		]);
        
        $result = '';
        foreach($query->Enumerate() as $obj)
        {
        	$result .= $obj->OwnerName . ": " . join(', ', $obj->Pets->ToArray()) . "\n";
        }
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nIntersect\n";
        (function(){
        
        $id1 = [44, 26, 92, 30, 71, 38];
        $id2 = [39, 59, 83, 47, 26, 4, 30];
        
        $target = "26 30";
        
        $query = (new QE($id1))->Intersect(new QE($id2));
        
        $result = join(" ", $query->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        
        $store1 = [
        	(object)['Name' => "apple", 'Code' => 9],
        	(object)['Name' => "orange", 'Code' => 4],
        ];
        
        $store2 = [
        	(object)['Name' => "apple", 'Code' => 9],
        	(object)['Name' => "lemon", 'Code' => 12],
        ];
        
        $target = "apple 9";
        
        $query = (new QE($store1))->Intersect(new QE($store2), function($a, $b)
        {
        	if($a === null || $b === null)
        	{
        		return $a === null && $b === null;
        	}
        	else
        	{
        		return $a->Name == $b->Name && $a->Code == $b->Code;
        	}
        });
        
        $result = join(", ", $query->Select(fn($fruit) => "{$fruit->Name} {$fruit->Code}")->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nJoin\n";
        (function(){
        
        $people = [
        	$magnus = (object)['Name' => "Hedlund, Magnus"],
        	$terry = (object)['Name' => "Adams, Terry"],
        	$charlotte = (object)['Name' => "Weiss, Charlotte"],
        ];
        $pets = [
        	(object)['Name' => "Barley", 'Owner' => $terry],
        	(object)['Name' => "Boots", 'Owner' => $terry],
        	(object)['Name' => "Whiskers", 'Owner' => $charlotte],
        	(object)['Name' => "Daisy", 'Owner' => $magnus],
        ];
        
        $target = "Hedlund, Magnus - Daisy\n" . 
        	"Adams, Terry - Barley\n" . 
        	"Adams, Terry - Boots\n" . 
        	"Weiss, Charlotte - Whiskers\n";
        
        $query = (new QE($people))->Join(new QE($pets),
        	fn($person) => $person,
        	fn($pet) => $pet->Owner,
        	fn($person, $pet) => (object)['OwnerName' => $person->Name, 'Pet' => $pet->Name],
        	);
        
        $result = '';
        foreach($query->Enumerate() as $obj)
        {
        	$result .= "{$obj->OwnerName} - {$obj->Pet}\n";
        }
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nOrderBy\n";
        (function(){
        
        $pets = [
        	(object)['Name' => 'Barley', 'Age' => 8],
        	(object)['Name' => 'Boots', 'Age' => 4],
        	(object)['Name' => 'Whiskers', 'Age' => 1],
        	(object)['Name' => 'Mog', 'Age' => 4],
        ];
        $target = "Whiskers - 1, Boots - 4, Mog - 4, Barley - 8";
        
        $query = (new QE($pets))->OrderBy(fn($pet) => $pet->Age);
        $result = join(', ', $query->Select(fn($pet) => "{$pet->Name} - {$pet->Age}")->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        
        $pets = [
        	(object)['Name' => 'Barley', 'Age' => 8],
        	(object)['Name' => 'Boots', 'Age' => 4],
        	(object)['Name' => 'Whiskers', 'Age' => 1],
        	(object)['Name' => 'Mog', 'Age' => 4],
        ];
        $target = "Mog - 4, Boots - 4, Barley - 8, Whiskers - 1";
        
        $query = (new QE($pets))->OrderBy(fn($pet) => $pet->Name, fn($a, $b) => strlen($a) - strlen($b));
        $result = join(', ', $query->Select(fn($pet) => "{$pet->Name} - {$pet->Age}")->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nOrderByDescending\n";
        (function(){
        
        $pets = [
        	(object)['Name' => 'Barley', 'Age' => 8],
        	(object)['Name' => 'Boots', 'Age' => 4],
        	(object)['Name' => 'Whiskers', 'Age' => 1],
        	(object)['Name' => 'Mog', 'Age' => 4],
        ];
        $target = "Barley - 8, Boots - 4, Mog - 4, Whiskers - 1";
        
        $query = (new QE($pets))->OrderByDescending(fn($pet) => $pet->Age);
        $result = join(', ', $query->Select(fn($pet) => "{$pet->Name} - {$pet->Age}")->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        
        $pets = [
        	(object)['Name' => 'Barley', 'Age' => 8],
        	(object)['Name' => 'Boots', 'Age' => 4],
        	(object)['Name' => 'Whiskers', 'Age' => 1],
        	(object)['Name' => 'Mog', 'Age' => 4],
        ];
        $target = "Whiskers - 1, Barley - 8, Boots - 4, Mog - 4";
        
        $query = (new QE($pets))->OrderByDescending(fn($pet) => $pet->Name, fn($a, $b) => strlen($a) - strlen($b));
        $result = join(', ', $query->Select(fn($pet) => "{$pet->Name} - {$pet->Age}")->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nSelect\n";
        (function(){
        	$fruits = ['apple', 'banana', 'mango', 'orange', 'passionfruit', 'grape'];
        	$target = "5, 6, 5, 6, 12, 5";
        
        $query = (new QE($fruits))->Select(fn($fruit) => strlen($fruit));
        $result = join(', ', $query->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$fruits = ['apple', 'banana', 'mango', 'orange', 'passionfruit', 'grape'];
        	$target = "[0 => ], [1 => b], [2 => ma], [3 => ora], [4 => pass], [5 => grape]";
        
        $query = (new QE($fruits))->Select(fn($fruit, $idx) => [$idx, substr($fruit, 0, $idx)]);
        $result = join(', ', $query->Select(fn($res) => "[{$res[0]} => {$res[1]}]")->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nSelectMany\n";
        (function(){
        	$petOwners = [
        		(object)['Name' => "Higa, Sidney", 'Pets' => ['Scruffy', 'Sam']],
        		(object)['Name' => "Ashkenazi, Romen", 'Pets' => ['Walker', 'Sugar']],
        		(object)['Name' => "Price, Vernette", 'Pets' => ['Scratches', 'Diesel']],
        		(object)['Name' => "Hines, Patrick", 'Pets' => ['Dusty']],
        	];
        	$target = "0Scruffy, 0Sam, 1Walker, 1Sugar, 2Scratches, 2Diesel, 3Dusty";
        
        $query = (new QE($petOwners))->SelectMany(
        	fn($petOwner, $index) => (new QE($petOwner->Pets))->Select(fn($pet) => "$index$pet"));
        
        $result = join(', ', $query->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$petOwners = [
        		(object)['Name' => "Higa", 'Pets' => ['Scruffy', 'Sam']],
        		(object)['Name' => "Ashkenazi", 'Pets' => ['Walker', 'Sugar']],
        		(object)['Name' => "Price", 'Pets' => ['Scratches', 'Diesel']],
        		(object)['Name' => "Hines", 'Pets' => ['Dusty']],
        	];
        	$target = "{Owner=Higa, Pet=Scruffy}\n"
        	. "{Owner=Higa, Pet=Sam}\n"
        	. "{Owner=Ashkenazi, Pet=Sugar}\n"
        	. "{Owner=Price, Pet=Scratches}\n";
        
        $query = (new QE($petOwners))->SelectMany(
        	fn($petOwner) => $petOwner->Pets,
        	fn($petOwner, $petName) => ['PetOwner' => $petOwner, 'PetName' => $petName])
        ->Where(fn($ownerAndPet) => substr($ownerAndPet['PetName'], 0, 1) == 'S')
        ->Select(fn($ownerAndPet) => (object)['Owner' => $ownerAndPet['PetOwner']->Name, 'Pet' => $ownerAndPet['PetName']]);
        
        $result = '';
        foreach($query->Enumerate() as $ownerAndPet)
        {
        	$result .= "{Owner={$ownerAndPet->Owner}, Pet={$ownerAndPet->Pet}}\n";
        }
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nSkip\n";
        (function(){
        	$grades = [59, 82, 70, 56, 92, 98, 85];
        	$target = "All grades except the top three are: 82, 70, 59, 56";
        
        $query = (new QE($grades))->OrderByDescending(fn($x) => $x)->Skip(3);
        
        $result = "All grades except the top three are: " . join(', ', $query->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nSkipWhile\n";
        (function(){
        	$grades = [59, 82, 70, 56, 92, 98, 85];
        	$target = "All grades below 80: 70, 59, 56";
        
        $query = (new QE($grades))->OrderByDescending(fn($x) => $x)->SkipWhile(fn($grade) => $grade >= 80);
        
        $result = "All grades below 80: " . join(', ', $query->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nTake\n";
        (function(){
        	$grades = [59, 82, 70, 56, 92, 98, 85];
        	$target = "The top three grades are: 98, 92, 85";
        
        $query = (new QE($grades))->OrderByDescending(fn($x) => $x)->Take(3);
        
        $result = "The top three grades are: " . join(', ', $query->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nTakeWhile\n";
        (function(){
        	$fruits = ['apple', 'banana', 'mango', 'orange', 'passionfruit', 'grape'];
        	$target = "apple, banana, mango";
        
        $query = (new QE($fruits))->TakeWhile(fn($fruit) => $fruit != "orange");
        
        $result = join(', ', $query->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        (function(){
        	$fruits = ['apple', 'passionfruit', 'banana', 'mango', 'orange', 'blueberry', 'grape', 'strawberry'];
        	$target = "apple, passionfruit, banana, mango, orange, blueberry";
        
        $query = (new QE($fruits))->TakeWhile(fn($fruit, $index) => strlen($fruit) >= $index);
        
        $result = join(', ', $query->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nThenBy\n";
        
        (function(){
        	$fruits = ['grape', 'passionfruit', 'banana', 'mango', 'orange', 'raspberry', 'apple', 'blueberry'];
        	$target = "apple, grape, mango, banana, orange, blueberry, raspberry, passionfruit";
        
        $query = (new QE($fruits))
        	->OrderBy(fn($fruit) => strlen($fruit))
        	->ThenBy(fn($fruit) => $fruit);
        
        $result = join(', ', $query->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nThenByDescending\n";
        
        (function(){
        	$fruits = ["apPLe", "baNanA", "apple", "APple", "orange", "BAnana", "ORANGE", "apPLE"];
        	$target = "apPLe, apple, APple, apPLE, orange, ORANGE, baNanA, BAnana";
        
        $query = (new QE($fruits))
        	->OrderBy(fn($fruit) => strlen($fruit))
        	->ThenByDescending(fn($fruit) => $fruit, 'strcasecmp');
        
        $result = join(', ', $query->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nWhere\n";
        
        (function(){
        	$fruits = ['apple', 'passionfruit', 'banana', 'mango', 'orange', 'blueberry', 'grape', 'strawberry'];
        	$target = 'apple, mango, grape';
        
        $query = (new QE($fruits))
        	->Where(fn($fruit) => strlen($fruit) < 6);
        
        $result = join(', ', $query->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        })();
        
        
        
        echo "\nZip\n";
        
        (function(){
        	$numbers = [1, 2, 3, 4];
        	$words = ['one', 'two', 'three'];
        	$target = "1=one, 2=two, 3=three";
        
        $query = (new QE($numbers))->Zip(new QE($words), fn($a, $b) => "$a=$b");
        
        $result = join(', ', $query->ToArray());
        
        echo "$result\n$target\n", ($result === $target ? 'PASS' : "FAIL\x07"), "\n\n";
        
        })();
        
          3 months later
          /*
           * Each group is represented by a QueryEnumerable object. If $result is supplied, both the key and
           * that QueryEnumerable for each group will be passed to it in turn and the result will become
           * the item returned as part of the GroupBy sequence. Otherwise, the item will be an array pair
           * [group key, QueryEnumerable(group items)].
          */
          

          I lied.

          class GroupBy extends QueryEnumerable
          {
          	private QueryEnumerable $source;
          	private $keySelector;
          	private $elementSelector;
          	private $resultTransform;
          
          public function __construct(QueryEnumerable $source, callable $keySelector = null, callable $elementSelector = null, callable $resultTransform = null)
          {
          	$this->source = $source;
          	$this->keySelector = $keySelector;
          	$this->elementSelector = $elementSelector;
          	$this->resultTransform = $resultTransform;
          }
          
          public function Enumerate()
          {
          	$keys = [];
          	$groups = [];
          
          	$keySelector = $this->keySelector;
          	$elementSelector = $this->elementSelector;
          	foreach($this->source->Enumerate() as $item)
          	{
          		$key = $keySelector($item);
          		if($elementSelector !== null)
          		{
          			$item = $elementSelector($item);
          		}
          		if(($idx = array_search($key, $keys, true)) !== false)
          		{
          			$groups[$idx][] = $item;
          		}
          		else
          		{
          			$keys[] = $key;
          			$groups[] = [$item];
          		}
          	}
          
          	yield from array_map($this->resultTransform, $keys, array_map(fn($group) => new QueryEnumerable($group), $groups));
          }
          }
          

          That fixes it. I didn't notice the problem because my tests made the same mistake. They should have been things like foreach($query->Enumerate() as [$key, $petGroup]) and foreach($query->Enumerate() as $petGroup).

            4 days later

            Dunno what you mean by "plan", but I'm trying it out to see how useful it actually is.

              Write a Reply...