diff --git a/src/Phaseolies/Database/Temporal/TemporalBuilder.php b/src/Phaseolies/Database/Temporal/TemporalBuilder.php index 24838e3f..0e503543 100644 --- a/src/Phaseolies/Database/Temporal/TemporalBuilder.php +++ b/src/Phaseolies/Database/Temporal/TemporalBuilder.php @@ -176,29 +176,104 @@ private function applyPhpConditions(array $models): array return $models; } - return array_values(array_filter($models, function (Model $model) { - $pass = true; + return array_values(array_filter( + $models, + fn (Model $model) => $this->matchesConditions($model, $this->conditions) + )); + } - foreach ($this->conditions as $condition) { - // Skip typed conditions (NESTED, EXISTS, RAW_WHERE, etc.) - if (isset($condition['type'])) { - continue; - } + /** + * Check a model against a list of conditions + * + * @param Model $model + * @param array $conditions + * @return bool + * @throws \LogicException + */ + private function matchesConditions(Model $model, array $conditions): bool + { + $seen = false; + $anyGroup = false; + $groupResult = true; - [$boolean, $field, $operator, $value] = array_pad($condition, 4, null); - $extra = $condition[4] ?? null; + foreach ($conditions as $condition) { + if (($condition['type'] ?? null) === 'RAW_GROUP_BY') { + continue; + } - // Strip table prefix if present (e.g. "contracts.status" → "status") - $column = str_contains($field, '.') ? explode('.', $field, 2)[1] : $field; - $actual = $model->{$column} ?? null; + $boolean = strtoupper(isset($condition['type']) + ? ($condition['boolean'] ?? 'AND') + : ($condition[0] ?? 'AND')); - $result = $this->evaluateCondition($actual, $operator, $value, $extra); + $result = $this->matchesCondition($model, $condition); - $pass = $boolean === 'OR' ? ($pass || $result) : ($pass && $result); + if (!$seen) { + $groupResult = $result; + $seen = true; + } elseif ($boolean === 'OR') { + $anyGroup = $anyGroup || $groupResult; + $groupResult = $result; + } else { + $groupResult = $groupResult && $result; } + } + + return !$seen || $anyGroup || $groupResult; + } + + /** + * Check a model against a single condition entry. + * + * @param Model $model + * @param array $condition + * @return bool + * @throws \LogicException + */ + private function matchesCondition(Model $model, array $condition): bool + { + if (isset($condition['type'])) { + if ($condition['type'] === 'NESTED') { + return $this->matchesConditions($model, $condition['query']->conditions); + } + + throw new \LogicException( + "Temporal queries filter snapshots in PHP, so {$condition['type']} conditions are not supported. " + . 'Use where(), orWhere(), whereIn(), whereNull(), whereBetween() or a nested where() instead.' + ); + } + + [, $field, $operator, $value] = array_pad($condition, 4, null); + $extra = $condition[4] ?? null; + + // Strip table prefix if present (e.g. "contracts.status" → "status") + $column = str_contains($field, '.') ? explode('.', $field, 2)[1] : $field; + $actual = $model->{$column} ?? null; + + return $this->evaluateCondition($actual, $operator, $value, $extra); + } + + /** + * Add a nested WHERE group. The nested query keeps the same point in time. + * + * @param callable $callback + * @param string $boolean + * @return self + */ + public function whereNested(callable $callback, string $boolean = 'AND'): self + { + $nested = new self($this->pdo, $this->table, $this->modelClass, $this->rowPerPage, $this->datetime); + + $callback($nested); + + if (!empty($nested->conditions)) { + $this->conditions[] = [ + 'type' => 'NESTED', + 'query' => $nested, + 'boolean' => $boolean, + ]; + } - return $pass; - })); + return $this; } /** diff --git a/tests/Model/TemporalModelTest.php b/tests/Model/TemporalModelTest.php index 0ab04909..e6022a37 100644 --- a/tests/Model/TemporalModelTest.php +++ b/tests/Model/TemporalModelTest.php @@ -605,6 +605,112 @@ private function diffReport(array $from, array $to): array return $report; } + public function testTemporalOrWhereAsFirstConditionOnlyReturnsMatches(): void + { + $at = $this->seedForBooleanTests(); + + $this->assertSame(['B', 'D'], $this->titlesOf(MockTemporalRecord::at($at)->orWhere('status', 'closed'))); + $this->assertSame(['C'], $this->titlesOf(MockTemporalRecord::at($at)->orWhereIn('status', ['draft']))); + $this->assertSame([], $this->titlesOf(MockTemporalRecord::at($at)->orWhere('status', 'nothing'))); + } + + public function testTemporalWhereAndOrWhereMatchSqlSemantics(): void + { + $at = $this->seedForBooleanTests(); + + $this->assertSame( + ['A', 'B', 'D'], + $this->titlesOf(MockTemporalRecord::at($at)->where('status', 'open')->orWhere('status', 'closed')) + ); + $this->assertSame( + ['A', 'D'], + $this->titlesOf(MockTemporalRecord::at($at)->where('status', 'closed')->where('body', '1')->orWhere('status', 'open')) + ); + } + + public function testTemporalAndBindsTighterThanOr(): void + { + $at = $this->seedForBooleanTests(); + + // open OR (closed AND body = 2), not (open OR closed) AND body = 2 + $this->assertSame( + ['A', 'B'], + $this->titlesOf( + MockTemporalRecord::at($at)->where('status', 'open')->orWhere('status', 'closed')->where('body', '2') + ) + ); + } + + public function testTemporalIgnoresTheBooleanOfTheFirstCondition(): void + { + $at = $this->seedForBooleanTests(); + + // orWhere(closed) AND body = 1 + $this->assertSame( + ['D'], + $this->titlesOf(MockTemporalRecord::at($at)->orWhere('status', 'closed')->where('body', '1')) + ); + } + + public function testTemporalNestedWhereGroups(): void + { + $at = $this->seedForBooleanTests(); + + $this->assertSame( + ['D'], + $this->titlesOf( + MockTemporalRecord::at($at) + ->where(function ($query) { + $query->where('status', 'closed')->orWhere('status', 'draft'); + }) + ->where('body', '1') + ) + ); + + // body = 3 OR (status = closed AND body = 2) + $this->assertSame( + ['B', 'C'], + $this->titlesOf( + MockTemporalRecord::at($at) + ->where('body', '3') + ->orWhere(function ($query) { + $query->where('status', 'closed')->where('body', '2'); + }) + ) + ); + } + + public function testTemporalRawConditionsAreRejectedInsteadOfIgnored(): void + { + $at = $this->seedForBooleanTests(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('not supported'); + + MockTemporalRecord::at($at)->whereRaw("status = 'open'")->get(); + } + + /** + * A open/1, B closed/2, C draft/3, D closed/1. Returns a time after all of them. + */ + private function seedForBooleanTests(): string + { + $this->travelTo('2026-01-01 10:00:00.100000'); + + foreach ([['A', 'open', '1'], ['B', 'closed', '2'], ['C', 'draft', '3'], ['D', 'closed', '1']] as [$title, $status, $body]) { + MockTemporalRecord::create(['title' => $title, 'status' => $status, 'body' => $body]); + } + + return '2026-01-01 11:00:00'; + } + + private function titlesOf($query): array + { + $titles = $query->orderBy('id')->get()->map->title->all(); + + return array_values($titles); + } + private function travelTo(string $datetime): void { Carbon::setTestNow(Carbon::parse($datetime, 'UTC'));