41 changed files with 11174 additions and 557 deletions
@ -0,0 +1,39 @@
|
||||
FROM node:24.0.1-alpine |
||||
|
||||
WORKDIR /usr/src/app |
||||
|
||||
# Install system dependencies |
||||
RUN apk add --no-cache \ |
||||
git \ |
||||
bash \ |
||||
curl \ |
||||
sudo |
||||
|
||||
# Give sudo permissions to the developer user |
||||
RUN echo '%developer ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers.d/developer && \ |
||||
chmod 0440 /etc/sudoers.d/developer |
||||
|
||||
# Configure user permissions |
||||
ARG USER_ID=1000 |
||||
ARG GROUP_ID=1000 |
||||
|
||||
# Create a user with matching UID/GID |
||||
RUN if ! getent group $GROUP_ID > /dev/null 2>&1; then \ |
||||
addgroup -g $GROUP_ID developer; \ |
||||
else \ |
||||
addgroup developer; \ |
||||
fi && \ |
||||
if ! getent passwd $USER_ID > /dev/null 2>&1; then \ |
||||
adduser -D -u $USER_ID -G developer -s /bin/sh developer; \ |
||||
else \ |
||||
adduser -D -G developer -s /bin/sh developer; \ |
||||
fi |
||||
|
||||
RUN chown -R $USER_ID:$GROUP_ID /usr/src/app |
||||
|
||||
USER $USER_ID:$GROUP_ID |
||||
|
||||
# Expose port 3000 for NestJS |
||||
EXPOSE 3000 |
||||
|
||||
CMD ["npm", "run", "start:dev"] |
||||
@ -0,0 +1,26 @@
|
||||
{ |
||||
"name": "NestJS dev container", |
||||
"dockerComposeFile": "./docker-compose.yml", |
||||
"service": "app", |
||||
"workspaceFolder": "/usr/src/app", |
||||
"customizations": { |
||||
"vscode": { |
||||
"settings": { |
||||
"terminal.integrated.defaultProfile.linux": "bash", |
||||
"node.js.version": "24.0.1" |
||||
}, |
||||
"extensions": [ |
||||
"ms-vscode.vscode-typescript-next", |
||||
"ms-nodejs.vscode-node-debug2", |
||||
"ms-vscode.vscode-json", |
||||
"esbenp.prettier-vscode", |
||||
"dbaeumer.vscode-eslint", |
||||
"christian-kohler.npm-intellisense", |
||||
"christian-kohler.path-intellisense" |
||||
] |
||||
} |
||||
}, |
||||
"forwardPorts": [3000], |
||||
"remoteUser": "developer", |
||||
"postCreateCommand": "sudo chown -R developer:developer /usr/src/app && npm install" |
||||
} |
||||
@ -0,0 +1,29 @@
|
||||
version: "3.9" |
||||
services: |
||||
app: |
||||
build: |
||||
context: .. |
||||
dockerfile: .devcontainer/Dockerfile |
||||
args: |
||||
USER_ID: ${USER_ID:-1000} |
||||
GROUP_ID: ${GROUP_ID:-1000} |
||||
image: dev-nestjs-img |
||||
container_name: dev-nestjs |
||||
user: "developer" |
||||
volumes: |
||||
- ../:/usr/src/app:cached |
||||
- node_modules:/usr/src/app/node_modules |
||||
environment: |
||||
NODE_ENV: development |
||||
ports: |
||||
- "50080:3000" |
||||
networks: |
||||
- dev-network |
||||
command: sleep infinity |
||||
|
||||
volumes: |
||||
node_modules: |
||||
|
||||
networks: |
||||
dev-network: |
||||
driver: bridge |
||||
@ -0,0 +1,28 @@
|
||||
FROM node:24.0.1-alpine as builder |
||||
|
||||
WORKDIR /app |
||||
|
||||
COPY package*.json ./ |
||||
COPY nest-cli.json ./ |
||||
COPY tsconfig.json ./ |
||||
|
||||
RUN npm install |
||||
|
||||
COPY src ./src |
||||
|
||||
RUN npm run build |
||||
|
||||
|
||||
FROM node:24.0.1-alpine |
||||
|
||||
WORKDIR /app |
||||
|
||||
COPY package*.json ./ |
||||
|
||||
RUN npm install --only=production |
||||
|
||||
COPY --from=builder /app/dist ./dist |
||||
|
||||
EXPOSE 3000 |
||||
|
||||
CMD [ "node", "dist/main" ] |
||||
@ -0,0 +1,17 @@
|
||||
version: "3.9" |
||||
services: |
||||
app: |
||||
build: |
||||
context: .. |
||||
dockerfile: docker/Dockerfile |
||||
image: nestjs-app-img |
||||
container_name: nestjs-app |
||||
ports: |
||||
- "50080:3000" |
||||
networks: |
||||
- app-network |
||||
restart: unless-stopped |
||||
|
||||
networks: |
||||
app-network: |
||||
driver: bridge |
||||
@ -0,0 +1,8 @@
|
||||
{ |
||||
"$schema": "https://json.schemastore.org/nest-cli", |
||||
"collection": "@nestjs/schematics", |
||||
"sourceRoot": "src", |
||||
"compilerOptions": { |
||||
"deleteOutDir": true |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,70 @@
|
||||
{ |
||||
"name": "autostore-nestjs", |
||||
"version": "0.0.1", |
||||
"description": "AutoStore implementation with NestJS", |
||||
"author": "", |
||||
"license": "MIT", |
||||
"scripts": { |
||||
"prebuild": "rimraf dist", |
||||
"build": "nest build", |
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", |
||||
"start": "nest start", |
||||
"start:dev": "nest start --watch", |
||||
"start:debug": "nest start --debug --watch", |
||||
"start:prod": "node dist/main", |
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", |
||||
"test": "jest", |
||||
"test:watch": "jest --watch", |
||||
"test:cov": "jest --coverage", |
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", |
||||
"test:e2e": "jest --config ./test/jest-e2e.json" |
||||
}, |
||||
"dependencies": { |
||||
"@nestjs/common": "^10.0.0", |
||||
"@nestjs/core": "^10.0.0", |
||||
"@nestjs/platform-express": "^10.0.0", |
||||
"reflect-metadata": "^0.1.13", |
||||
"rxjs": "^7.8.1" |
||||
}, |
||||
"devDependencies": { |
||||
"@nestjs/cli": "^10.0.0", |
||||
"@nestjs/schematics": "^10.0.0", |
||||
"@nestjs/testing": "^10.0.0", |
||||
"@types/express": "^4.17.17", |
||||
"@types/jest": "^29.5.2", |
||||
"@types/node": "^20.3.1", |
||||
"@types/supertest": "^6.0.0", |
||||
"@typescript-eslint/eslint-plugin": "^6.0.0", |
||||
"@typescript-eslint/parser": "^6.0.0", |
||||
"eslint": "^8.42.0", |
||||
"eslint-config-prettier": "^9.0.0", |
||||
"eslint-plugin-prettier": "^5.0.0", |
||||
"jest": "^29.5.0", |
||||
"prettier": "^3.0.0", |
||||
"rimraf": "^5.0.1", |
||||
"source-map-support": "^0.5.21", |
||||
"supertest": "^6.3.3", |
||||
"ts-jest": "^29.1.0", |
||||
"ts-loader": "^9.4.3", |
||||
"ts-node": "^10.9.1", |
||||
"tsconfig-paths": "^4.2.0", |
||||
"typescript": "^5.1.3" |
||||
}, |
||||
"jest": { |
||||
"moduleFileExtensions": [ |
||||
"js", |
||||
"json", |
||||
"ts" |
||||
], |
||||
"rootDir": "src", |
||||
"testRegex": ".*\\.spec\\.ts$", |
||||
"transform": { |
||||
"^.+\\.(t|j)s$": "ts-jest" |
||||
}, |
||||
"collectCoverageFrom": [ |
||||
"**/*.(t|j)s" |
||||
], |
||||
"coverageDirectory": "../coverage", |
||||
"testEnvironment": "node" |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common'; |
||||
import { AppService } from './app.service'; |
||||
|
||||
@Controller() |
||||
export class AppController { |
||||
constructor(private readonly appService: AppService) {} |
||||
|
||||
@Get() |
||||
getHello(): string { |
||||
return this.appService.getHello(); |
||||
} |
||||
} |
||||
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common'; |
||||
import { AppController } from './app.controller'; |
||||
import { AppService } from './app.service'; |
||||
|
||||
@Module({ |
||||
imports: [], |
||||
controllers: [AppController], |
||||
providers: [AppService], |
||||
}) |
||||
export class AppModule {} |
||||
@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common'; |
||||
|
||||
@Injectable() |
||||
export class AppService { |
||||
getHello(): string { |
||||
return 'Hello World!'; |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
|
||||
import { NestFactory } from '@nestjs/core'; |
||||
import { AppModule } from './app.module'; |
||||
|
||||
async function bootstrap() { |
||||
const app = await NestFactory.create(AppModule); |
||||
await app.listen(3000); |
||||
} |
||||
bootstrap(); |
||||
@ -0,0 +1,24 @@
|
||||
{ |
||||
"compilerOptions": { |
||||
"module": "commonjs", |
||||
"declaration": true, |
||||
"removeComments": true, |
||||
"emitDecoratorMetadata": true, |
||||
"experimentalDecorators": true, |
||||
"allowSyntheticDefaultImports": true, |
||||
"target": "ES2021", |
||||
"sourceMap": true, |
||||
"outDir": "./dist", |
||||
"baseUrl": "./", |
||||
"incremental": true, |
||||
"skipLibCheck": true, |
||||
"strictNullChecks": false, |
||||
"noImplicitAny": false, |
||||
"strictBindCallApply": false, |
||||
"forceConsistentCasingInFileNames": false, |
||||
"noFallthroughCasesInSwitch": false, |
||||
"paths": { |
||||
"@/*": ["src/*"] |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,40 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace AutoStore\Application\Services; |
||||
|
||||
use AutoStore\Application\Interfaces\IUserRepository; |
||||
use AutoStore\Domain\Entities\User; |
||||
use Psr\Log\LoggerInterface; |
||||
|
||||
class UserInitializationService |
||||
{ |
||||
private IUserRepository $userRepository; |
||||
private LoggerInterface $logger; |
||||
|
||||
public function __construct(IUserRepository $userRepository, LoggerInterface $logger) |
||||
{ |
||||
$this->userRepository = $userRepository; |
||||
$this->logger = $logger; |
||||
} |
||||
|
||||
public function createDefaultUsers(): void |
||||
{ |
||||
$defaultUsers = [ |
||||
['username' => 'admin', 'password' => 'admin'], |
||||
['username' => 'user', 'password' => 'user'] |
||||
]; |
||||
|
||||
foreach ($defaultUsers as $userData) { |
||||
$user = new User( |
||||
uniqid('user_', true), |
||||
$userData['username'], |
||||
password_hash($userData['password'], PASSWORD_DEFAULT) |
||||
); |
||||
$this->userRepository->save($user); |
||||
} |
||||
|
||||
$this->logger->info("Created default users"); |
||||
} |
||||
} |
||||
@ -1,19 +0,0 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace AutoStore\Domain\Filters; |
||||
|
||||
class FilterCondition extends FilterExpr |
||||
{ |
||||
public string $field; |
||||
public string $operator; |
||||
public $value; |
||||
|
||||
public function __construct(string $field, string $operator, $value = null) |
||||
{ |
||||
$this->field = $field; |
||||
$this->operator = $operator; |
||||
$this->value = $value; |
||||
} |
||||
} |
||||
@ -1,9 +0,0 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace AutoStore\Domain\Filters; |
||||
|
||||
abstract class FilterExpr |
||||
{ |
||||
} |
||||
@ -1,22 +0,0 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace AutoStore\Domain\Filters; |
||||
|
||||
class FilterGroup extends FilterExpr |
||||
{ |
||||
public string $type; |
||||
public array $children = []; |
||||
|
||||
public function __construct(string $type = 'AND') |
||||
{ |
||||
$this->type = strtoupper($type); |
||||
} |
||||
|
||||
public function add(FilterExpr $expr): self |
||||
{ |
||||
$this->children[] = $expr; |
||||
return $this; |
||||
} |
||||
} |
||||
@ -1,20 +0,0 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace AutoStore\Domain\Filters; |
||||
|
||||
class FilterOperator |
||||
{ |
||||
public const EQUALS = 'equals'; |
||||
public const NOT_EQUALS = 'not_equals'; |
||||
public const GREATER_THAN = 'greater_than'; |
||||
public const LESS_THAN = 'less_than'; |
||||
public const GREATER_EQ = 'greater_eq'; |
||||
public const LESS_EQ = 'less_eq'; |
||||
public const IN = 'in'; |
||||
public const NOT_IN = 'not_in'; |
||||
public const LIKE = 'like'; |
||||
public const IS_NULL = 'is_null'; |
||||
public const IS_NOT_NULL = 'is_not_null'; |
||||
} |
||||
@ -1,236 +0,0 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace AutoStore\Domain\Filters; |
||||
|
||||
class FilterSpecification |
||||
{ |
||||
private FilterGroup $rootGroup; |
||||
private FilterGroup $currentGroup; |
||||
private array $groupStack = []; |
||||
private ?string $lastField = null; |
||||
|
||||
public function __construct(?string $field = null) |
||||
{ |
||||
$this->rootGroup = new FilterGroup('AND'); |
||||
$this->currentGroup = $this->rootGroup; |
||||
if ($field !== null) { |
||||
$this->field($field); |
||||
} |
||||
} |
||||
|
||||
public function field(string $field): self |
||||
{ |
||||
$this->lastField = $field; |
||||
return $this; |
||||
} |
||||
|
||||
public function equals($value): self |
||||
{ |
||||
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::EQUALS, $value)); |
||||
return $this; |
||||
} |
||||
|
||||
public function notEquals($value): self |
||||
{ |
||||
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::NOT_EQUALS, $value)); |
||||
return $this; |
||||
} |
||||
|
||||
public function greaterThan($value): self |
||||
{ |
||||
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::GREATER_THAN, $value)); |
||||
return $this; |
||||
} |
||||
|
||||
public function lessThan($value): self |
||||
{ |
||||
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LESS_THAN, $value)); |
||||
return $this; |
||||
} |
||||
|
||||
public function greaterEq($value): self |
||||
{ |
||||
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::GREATER_EQ, $value)); |
||||
return $this; |
||||
} |
||||
|
||||
public function lessEq($value): self |
||||
{ |
||||
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LESS_EQ, $value)); |
||||
return $this; |
||||
} |
||||
|
||||
public function in(array $values): self |
||||
{ |
||||
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::IN, $values)); |
||||
return $this; |
||||
} |
||||
|
||||
public function notIn(array $values): self |
||||
{ |
||||
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::NOT_IN, $values)); |
||||
return $this; |
||||
} |
||||
|
||||
public function like(string $pattern): self |
||||
{ |
||||
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LIKE, $pattern)); |
||||
return $this; |
||||
} |
||||
|
||||
public function contains(string $needle): self |
||||
{ |
||||
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LIKE, "%$needle%")); |
||||
return $this; |
||||
} |
||||
|
||||
public function startsWith(string $prefix): self |
||||
{ |
||||
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LIKE, $prefix . '%')); |
||||
return $this; |
||||
} |
||||
|
||||
public function endsWith(string $suffix): self |
||||
{ |
||||
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LIKE, '%' . $suffix)); |
||||
return $this; |
||||
} |
||||
|
||||
public function isNull(): self |
||||
{ |
||||
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::IS_NULL)); |
||||
return $this; |
||||
} |
||||
|
||||
public function isNotNull(): self |
||||
{ |
||||
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::IS_NOT_NULL)); |
||||
return $this; |
||||
} |
||||
|
||||
public function or(?string $field = null): self |
||||
{ |
||||
$newGroup = new FilterGroup('OR'); |
||||
if (!empty($this->currentGroup->children)) { |
||||
$lastExpr = array_pop($this->currentGroup->children); |
||||
$newGroup->add($lastExpr); |
||||
} |
||||
$this->currentGroup->add($newGroup); |
||||
$this->groupStack[] = $this->currentGroup; |
||||
$this->currentGroup = $newGroup; |
||||
if ($field !== null) { |
||||
$this->field($field); |
||||
} |
||||
return $this; |
||||
} |
||||
|
||||
public function and(?string $field = null): self |
||||
{ |
||||
$newGroup = new FilterGroup('AND'); |
||||
if (!empty($this->currentGroup->children)) { |
||||
$lastExpr = array_pop($this->currentGroup->children); |
||||
$newGroup->add($lastExpr); |
||||
} |
||||
$this->currentGroup->add($newGroup); |
||||
$this->groupStack[] = $this->currentGroup; |
||||
$this->currentGroup = $newGroup; |
||||
if ($field !== null) { |
||||
$this->field($field); |
||||
} |
||||
return $this; |
||||
} |
||||
|
||||
public function close(): self |
||||
{ |
||||
if (empty($this->groupStack)) { |
||||
throw new \LogicException('No group to close.'); |
||||
} |
||||
$this->currentGroup = array_pop($this->groupStack); |
||||
return $this; |
||||
} |
||||
|
||||
public function getExpression(): FilterExpr |
||||
{ |
||||
return $this->rootGroup; |
||||
} |
||||
|
||||
public function matches(array $data): bool |
||||
{ |
||||
return $this->evaluateExpression($this->rootGroup, $data); |
||||
} |
||||
|
||||
private function evaluateExpression(FilterExpr $expr, array $data): bool |
||||
{ |
||||
if ($expr instanceof FilterCondition) { |
||||
return $this->evaluateCondition($expr, $data); |
||||
} |
||||
|
||||
if ($expr instanceof FilterGroup) { |
||||
return $this->evaluateGroup($expr, $data); |
||||
} |
||||
|
||||
throw new \LogicException('Unknown filter expression type.'); |
||||
} |
||||
|
||||
private function evaluateCondition(FilterCondition $condition, array $data): bool |
||||
{ |
||||
if (!array_key_exists($condition->field, $data)) { |
||||
return false; |
||||
} |
||||
|
||||
$value = $data[$condition->field]; |
||||
|
||||
switch ($condition->operator) { |
||||
case FilterOperator::EQUALS: |
||||
return $value == $condition->value; |
||||
case FilterOperator::NOT_EQUALS: |
||||
return $value != $condition->value; |
||||
case FilterOperator::GREATER_THAN: |
||||
return $value > $condition->value; |
||||
case FilterOperator::LESS_THAN: |
||||
return $value < $condition->value; |
||||
case FilterOperator::GREATER_EQ: |
||||
return $value >= $condition->value; |
||||
case FilterOperator::LESS_EQ: |
||||
return $value <= $condition->value; |
||||
case FilterOperator::IN: |
||||
return in_array($value, $condition->value); |
||||
case FilterOperator::NOT_IN: |
||||
return !in_array($value, $condition->value); |
||||
case FilterOperator::LIKE: |
||||
return str_contains((string)$value, str_replace('%', '', $condition->value)); |
||||
case FilterOperator::IS_NULL: |
||||
return $value === null; |
||||
case FilterOperator::IS_NOT_NULL: |
||||
return $value !== null; |
||||
default: |
||||
throw new \LogicException("Unknown operator: {$condition->operator}"); |
||||
} |
||||
} |
||||
|
||||
private function evaluateGroup(FilterGroup $group, array $data): bool |
||||
{ |
||||
if (empty($group->children)) { |
||||
return true; |
||||
} |
||||
|
||||
$results = []; |
||||
foreach ($group->children as $child) { |
||||
$results[] = $this->evaluateExpression($child, $data); |
||||
} |
||||
|
||||
return $group->type === 'AND' ? !in_array(false, $results) : in_array(true, $results); |
||||
} |
||||
|
||||
private function consumeField(): string |
||||
{ |
||||
if ($this->lastField === null) { |
||||
throw new \LogicException("No field specified."); |
||||
} |
||||
$field = $this->lastField; |
||||
$this->lastField = null; |
||||
return $field; |
||||
} |
||||
} |
||||
@ -1,25 +0,0 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace AutoStore\Domain\Policies; |
||||
|
||||
use AutoStore\Domain\Entities\Item; |
||||
use AutoStore\Domain\Filters\FilterSpecification; |
||||
use DateTimeImmutable; |
||||
|
||||
class ItemExpirationPolicy |
||||
{ |
||||
public function isExpired(Item $item, DateTimeImmutable $currentTime): bool |
||||
{ |
||||
return $item->getExpirationDate() <= $currentTime; |
||||
} |
||||
|
||||
public function getExpirationSpec(DateTimeImmutable $currentTime): FilterSpecification |
||||
{ |
||||
$specification = new FilterSpecification('expirationDate'); |
||||
$specification->lessEq($currentTime->format('Y-m-d H:i:s')); |
||||
|
||||
return $specification; |
||||
} |
||||
} |
||||
@ -0,0 +1,24 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace AutoStore\Domain\Specifications; |
||||
|
||||
use AutoStore\Domain\Entities\Item; |
||||
use DateTimeImmutable; |
||||
|
||||
class ItemExpirationSpec |
||||
{ |
||||
public function isExpired(Item $item, DateTimeImmutable $currentTime): bool |
||||
{ |
||||
// return $item->getExpirationDate() <= $currentTime; |
||||
return $this->getSpec($currentTime)->match($item); |
||||
} |
||||
|
||||
public function getSpec(DateTimeImmutable $currentTime): Specification |
||||
{ |
||||
return new Specification( |
||||
Spec::lte('expirationDate', $currentTime->format('Y-m-d H:i:s')) |
||||
); |
||||
} |
||||
} |
||||
@ -0,0 +1,266 @@
|
||||
<?php |
||||
|
||||
namespace AutoStore\Domain\Specifications; |
||||
|
||||
class Specification |
||||
{ |
||||
private array $spec; |
||||
|
||||
public function __construct(array $spec) |
||||
{ |
||||
$this->spec = $spec; |
||||
} |
||||
|
||||
public function getSpec(): array |
||||
{ |
||||
return $this->spec; |
||||
} |
||||
|
||||
public function match($object): bool |
||||
{ |
||||
return $this->evaluateSpec($this->spec, $object); |
||||
} |
||||
|
||||
private function evaluateSpec(array $spec, $object): bool |
||||
{ |
||||
// Handle logical groups |
||||
if (isset($spec[Spec::GROUP_AND])) { |
||||
foreach ($spec[Spec::GROUP_AND] as $subSpec) { |
||||
if (!$this->evaluateSpec($subSpec, $object)) { |
||||
return false; |
||||
} |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
if (isset($spec[Spec::GROUP_OR])) { |
||||
foreach ($spec[Spec::GROUP_OR] as $subSpec) { |
||||
if ($this->evaluateSpec($subSpec, $object)) { |
||||
return true; |
||||
} |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
if (isset($spec[Spec::GROUP_NOT])) { |
||||
return !$this->evaluateSpec($spec[Spec::GROUP_NOT], $object); |
||||
} |
||||
|
||||
// Handle simple conditions [field, op, value] |
||||
[$field, $op, $value] = $spec; |
||||
|
||||
// Check if field exists in the object |
||||
$getterMethod = 'get' . ucfirst($field); |
||||
if (method_exists($object, $getterMethod)) { |
||||
$fieldValue = $object->$getterMethod(); |
||||
} elseif (property_exists($object, $field)) { |
||||
$fieldValue = $object->$field; |
||||
} elseif (isset($object->$field)) { |
||||
$fieldValue = $object->$field; |
||||
} else { |
||||
return false; |
||||
} |
||||
|
||||
// Handle DateTimeImmutable comparison |
||||
if ($fieldValue instanceof \DateTimeInterface && is_string($value)) { |
||||
try { |
||||
$valueDate = new \DateTimeImmutable($value); |
||||
return $this->compareDateTime($fieldValue, $op, $valueDate); |
||||
} catch (\Exception $e) { |
||||
// If value is not a valid date string, fall back to regular comparison |
||||
} |
||||
} elseif (is_string($fieldValue) && $value instanceof \DateTimeInterface) { |
||||
try { |
||||
$fieldValueDate = new \DateTimeImmutable($fieldValue); |
||||
return $this->compareDateTime($fieldValueDate, $op, $value); |
||||
} catch (\Exception $e) { |
||||
// If fieldValue is not a valid date string, fall back to regular comparison |
||||
} |
||||
} elseif ($fieldValue instanceof \DateTimeInterface && $value instanceof \DateTimeInterface) { |
||||
return $this->compareDateTime($fieldValue, $op, $value); |
||||
} |
||||
|
||||
// Evaluate based on operator |
||||
switch ($op) { |
||||
case Spec::OP_EQ: |
||||
return $fieldValue == $value; |
||||
case Spec::OP_NEQ: |
||||
return $fieldValue != $value; |
||||
case Spec::OP_GT: |
||||
return $fieldValue > $value; |
||||
case Spec::OP_GTE: |
||||
return $fieldValue >= $value; |
||||
case Spec::OP_LT: |
||||
return $fieldValue < $value; |
||||
case Spec::OP_LTE: |
||||
return $fieldValue <= $value; |
||||
case Spec::OP_IN: |
||||
return is_array($value) && in_array($fieldValue, $value); |
||||
case Spec::OP_NIN: |
||||
return is_array($value) && !in_array($fieldValue, $value); |
||||
default: |
||||
return false; // Unknown operator |
||||
} |
||||
} |
||||
|
||||
private function compareDateTime(\DateTimeInterface $fieldValue, string $op, \DateTimeInterface $value): bool |
||||
{ |
||||
switch ($op) { |
||||
case Spec::OP_EQ: |
||||
return $fieldValue->format('Y-m-d H:i:s') === $value->format('Y-m-d H:i:s'); |
||||
case Spec::OP_NEQ: |
||||
return $fieldValue->format('Y-m-d H:i:s') !== $value->format('Y-m-d H:i:s'); |
||||
case Spec::OP_GT: |
||||
return $fieldValue > $value; |
||||
case Spec::OP_GTE: |
||||
return $fieldValue >= $value; |
||||
case Spec::OP_LT: |
||||
return $fieldValue < $value; |
||||
case Spec::OP_LTE: |
||||
return $fieldValue <= $value; |
||||
default: |
||||
return false; |
||||
} |
||||
} |
||||
} |
||||
|
||||
class Spec |
||||
{ |
||||
// Logical group operators |
||||
public const GROUP_AND = 'AND'; |
||||
public const GROUP_OR = 'OR'; |
||||
public const GROUP_NOT = 'NOT'; |
||||
|
||||
// Comparison operators |
||||
public const OP_EQ = '='; |
||||
public const OP_NEQ = '!='; |
||||
public const OP_GT = '>'; |
||||
public const OP_GTE = '>='; |
||||
public const OP_LT = '<'; |
||||
public const OP_LTE = '<='; |
||||
public const OP_IN = 'IN'; |
||||
public const OP_NIN = 'NOT IN'; |
||||
|
||||
// Logical group helpers |
||||
public static function and(array $conditions): array { |
||||
return [self::GROUP_AND => $conditions]; |
||||
} |
||||
|
||||
public static function or(array $conditions): array { |
||||
return [self::GROUP_OR => $conditions]; |
||||
} |
||||
|
||||
public static function not(array $condition): array { |
||||
return [self::GROUP_NOT => $condition]; |
||||
} |
||||
|
||||
// Condition helpers |
||||
public static function eq(string $field, $value): array { |
||||
return [$field, self::OP_EQ, $value]; |
||||
} |
||||
|
||||
public static function neq(string $field, $value): array { |
||||
return [$field, self::OP_NEQ, $value]; |
||||
} |
||||
|
||||
public static function gt(string $field, $value): array { |
||||
return [$field, self::OP_GT, $value]; |
||||
} |
||||
|
||||
public static function gte(string $field, $value): array { |
||||
return [$field, self::OP_GTE, $value]; |
||||
} |
||||
|
||||
public static function lt(string $field, $value): array { |
||||
return [$field, self::OP_LT, $value]; |
||||
} |
||||
|
||||
public static function lte(string $field, $value): array { |
||||
return [$field, self::OP_LTE, $value]; |
||||
} |
||||
|
||||
public static function in(string $field, array $values): array { |
||||
return [$field, self::OP_IN, $values]; |
||||
} |
||||
|
||||
public static function nin(string $field, array $values): array { |
||||
return [$field, self::OP_NIN, $values]; |
||||
} |
||||
} |
||||
|
||||
|
||||
/* |
||||
|
||||
// ----------------- |
||||
// Example usage |
||||
|
||||
$spec = Spec::and([ |
||||
Spec::eq('status', 'active'), |
||||
Spec::or([ |
||||
Spec::gt('score', 80), |
||||
Spec::in('role', ['admin', 'moderator']) |
||||
]), |
||||
Spec::not(Spec::eq('deleted', true)) |
||||
]); |
||||
|
||||
$params = []; |
||||
$sqlRenderer = new SqlRenderer(); |
||||
$whereClause = $sqlRenderer->render($spec, $params); |
||||
|
||||
echo "SQL: " . $whereClause . "\n"; |
||||
echo "Params: " . json_encode($params, JSON_PRETTY_PRINT) . "\n"; |
||||
|
||||
// ----------------- |
||||
// Example SQL renderer implementation in infrastructure layer |
||||
|
||||
class SqlRenderer |
||||
{ |
||||
private $operatorMap = [ |
||||
Spec::OP_EQ => '=', |
||||
Spec::OP_NEQ => '<>', |
||||
Spec::OP_GT => '>', |
||||
Spec::OP_GTE => '>=', |
||||
Spec::OP_LT => '<', |
||||
Spec::OP_LTE => '<=', |
||||
Spec::OP_IN => 'IN', |
||||
Spec::OP_NIN => 'NOT IN', |
||||
]; |
||||
|
||||
public function render($spec, array &$params = []): string |
||||
{ |
||||
// Logical groups |
||||
if (isset($spec[Spec::GROUP_AND])) { |
||||
$parts = array_map(fn($s) => $this->render($s, $params), $spec[Spec::GROUP_AND]); |
||||
return '(' . implode(' AND ', $parts) . ')'; |
||||
} |
||||
if (isset($spec[Spec::GROUP_OR])) { |
||||
$parts = array_map(fn($s) => $this->render($s, $params), $spec[Spec::GROUP_OR]); |
||||
return '(' . implode(' OR ', $parts) . ')'; |
||||
} |
||||
if (isset($spec[Spec::GROUP_NOT])) { |
||||
return 'NOT (' . $this->render($spec[Spec::GROUP_NOT], $params) . ')'; |
||||
} |
||||
|
||||
// It's a simple [field, op, value] |
||||
[$field, $op, $value] = $spec; |
||||
$renderOp = $this->operatorMap[$op] ?? $op; |
||||
|
||||
if ($op === Spec::OP_IN || $op === Spec::OP_NIN) { |
||||
if (!$value) { |
||||
// Empty IN list: render a false condition |
||||
return ($op === Spec::OP_IN) ? '0=1' : '1=1'; |
||||
} |
||||
$placeholders = array_fill(0, count($value), '?'); |
||||
foreach ($value as $v) { |
||||
$params[] = $v; |
||||
} |
||||
return sprintf("%s %s (%s)", $field, $renderOp, implode(',', $placeholders)); |
||||
} |
||||
|
||||
$params[] = $value; |
||||
return sprintf("%s %s ?", $field, $renderOp); |
||||
} |
||||
} |
||||
|
||||
*/ |
||||
|
||||
@ -0,0 +1,39 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace AutoStore\Infrastructure\Mappers; |
||||
|
||||
use AutoStore\Domain\Entities\Item; |
||||
use AutoStore\Domain\Exceptions\DomainException; |
||||
use DateTimeImmutable; |
||||
|
||||
class ItemMapper |
||||
{ |
||||
public static function toArray(Item $item): array |
||||
{ |
||||
return [ |
||||
'id' => $item->getId(), |
||||
'name' => $item->getName(), |
||||
'expirationDate' => $item->getExpirationDate()->format('Y-m-d H:i:s'), |
||||
'orderUrl' => $item->getOrderUrl(), |
||||
'userId' => $item->getUserId(), |
||||
'createdAt' => $item->getCreatedAt()->format('Y-m-d H:i:s') |
||||
]; |
||||
} |
||||
|
||||
public static function fromArray(array $data): Item |
||||
{ |
||||
if (!isset($data['id'], $data['name'], $data['expirationDate'], $data['orderUrl'], $data['userId'])) { |
||||
throw new DomainException('Invalid item data'); |
||||
} |
||||
|
||||
return new Item( |
||||
$data['id'], |
||||
$data['name'], |
||||
new DateTimeImmutable($data['expirationDate']), |
||||
$data['orderUrl'], |
||||
$data['userId'] |
||||
); |
||||
} |
||||
} |
||||
@ -0,0 +1,36 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace AutoStore\Infrastructure\Mappers; |
||||
|
||||
use AutoStore\Domain\Entities\User; |
||||
use AutoStore\Domain\Exceptions\DomainException; |
||||
use DateTimeImmutable; |
||||
|
||||
class UserMapper |
||||
{ |
||||
public static function toArray(User $user): array |
||||
{ |
||||
return [ |
||||
'id' => $user->getId(), |
||||
'username' => $user->getUsername(), |
||||
'passwordHash' => $user->getPasswordHash(), |
||||
'createdAt' => $user->getCreatedAt()->format('Y-m-d\TH:i:s.uP'), |
||||
'updatedAt' => $user->getUpdatedAt()?->format('Y-m-d\TH:i:s.uP'), |
||||
]; |
||||
} |
||||
|
||||
public static function fromArray(array $data): User |
||||
{ |
||||
if (!isset($data['id'], $data['username'], $data['passwordHash'])) { |
||||
throw new DomainException('Invalid user data'); |
||||
} |
||||
|
||||
return new User( |
||||
$data['id'], |
||||
$data['username'], |
||||
$data['passwordHash'] |
||||
); |
||||
} |
||||
} |
||||
@ -0,0 +1,688 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace AutoStore\Tests\Unit\Domain\Specifications; |
||||
|
||||
use AutoStore\Domain\Specifications\Specification; |
||||
use AutoStore\Domain\Specifications\Spec; |
||||
use DateTimeImmutable; |
||||
use PHPUnit\Framework\TestCase; |
||||
use stdClass; |
||||
|
||||
class SpecificationTest extends TestCase |
||||
{ |
||||
// Test data constants |
||||
private const TEST_STRING = 'test-value'; |
||||
private const TEST_INT = 42; |
||||
private const TEST_FLOAT = 3.14; |
||||
private const TEST_DATE = '2023-01-15 10:30:00'; |
||||
private const TEST_DATE_2 = '2023-02-15 10:30:00'; |
||||
|
||||
private function createSimpleObject(array $data): stdClass |
||||
{ |
||||
$object = new stdClass(); |
||||
foreach ($data as $key => $value) { |
||||
$object->$key = $value; |
||||
} |
||||
return $object; |
||||
} |
||||
|
||||
private function createComplexObject(array $data): TestObject |
||||
{ |
||||
return new TestObject($data); |
||||
} |
||||
|
||||
// EQ Operator Tests |
||||
|
||||
public function testWhenUsingEqWithStringThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::eq('name', self::TEST_STRING)); |
||||
$matchingObject = $this->createSimpleObject(['name' => self::TEST_STRING]); |
||||
$nonMatchingObject = $this->createSimpleObject(['name' => 'different']); |
||||
|
||||
// When |
||||
$matchResult = $spec->match($matchingObject); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
public function testWhenUsingEqWithIntegerThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::eq('age', self::TEST_INT)); |
||||
$matchingObject = $this->createSimpleObject(['age' => self::TEST_INT]); |
||||
$nonMatchingObject = $this->createSimpleObject(['age' => 100]); |
||||
|
||||
// When |
||||
$matchResult = $spec->match($matchingObject); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
public function testWhenUsingEqWithFloatThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::eq('price', self::TEST_FLOAT)); |
||||
$matchingObject = $this->createSimpleObject(['price' => self::TEST_FLOAT]); |
||||
$nonMatchingObject = $this->createSimpleObject(['price' => 1.0]); |
||||
|
||||
// When |
||||
$matchResult = $spec->match($matchingObject); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
public function testWhenUsingEqWithComplexObjectGetterThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::eq('name', self::TEST_STRING)); |
||||
$matchingObject = $this->createComplexObject(['name' => self::TEST_STRING]); |
||||
$nonMatchingObject = $this->createComplexObject(['name' => 'different']); |
||||
|
||||
// When |
||||
$matchResult = $spec->match($matchingObject); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
// NEQ Operator Tests |
||||
|
||||
public function testWhenUsingNeqThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::neq('status', 'inactive')); |
||||
$matchingObject = $this->createSimpleObject(['status' => 'active']); |
||||
$nonMatchingObject = $this->createSimpleObject(['status' => 'inactive']); |
||||
|
||||
// When |
||||
$matchResult = $spec->match($matchingObject); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
// Comparison Operators Tests |
||||
|
||||
public function testWhenUsingGtThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::gt('score', 80)); |
||||
$matchingObject = $this->createSimpleObject(['score' => 90]); |
||||
$nonMatchingObject = $this->createSimpleObject(['score' => 70]); |
||||
|
||||
// When |
||||
$matchResult = $spec->match($matchingObject); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
public function testWhenUsingGteThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::gte('score', 80)); |
||||
$matchingObject1 = $this->createSimpleObject(['score' => 80]); |
||||
$matchingObject2 = $this->createSimpleObject(['score' => 90]); |
||||
$nonMatchingObject = $this->createSimpleObject(['score' => 70]); |
||||
|
||||
// When |
||||
$matchResult1 = $spec->match($matchingObject1); |
||||
$matchResult2 = $spec->match($matchingObject2); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult1); |
||||
$this->assertTrue($matchResult2); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
public function testWhenUsingLtThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::lt('score', 80)); |
||||
$matchingObject = $this->createSimpleObject(['score' => 70]); |
||||
$nonMatchingObject = $this->createSimpleObject(['score' => 90]); |
||||
|
||||
// When |
||||
$matchResult = $spec->match($matchingObject); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
public function testWhenUsingLteThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::lte('score', 80)); |
||||
$matchingObject1 = $this->createSimpleObject(['score' => 80]); |
||||
$matchingObject2 = $this->createSimpleObject(['score' => 70]); |
||||
$nonMatchingObject = $this->createSimpleObject(['score' => 90]); |
||||
|
||||
// When |
||||
$matchResult1 = $spec->match($matchingObject1); |
||||
$matchResult2 = $spec->match($matchingObject2); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult1); |
||||
$this->assertTrue($matchResult2); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
// IN Operator Tests |
||||
|
||||
public function testWhenUsingInThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$validValues = ['admin', 'moderator', 'editor']; |
||||
$spec = new Specification(Spec::in('role', $validValues)); |
||||
$matchingObject = $this->createSimpleObject(['role' => 'admin']); |
||||
$nonMatchingObject = $this->createSimpleObject(['role' => 'user']); |
||||
|
||||
// When |
||||
$matchResult = $spec->match($matchingObject); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
public function testWhenUsingInWithEmptyArrayThenNeverMatches(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::in('role', [])); |
||||
$testObject = $this->createSimpleObject(['role' => 'admin']); |
||||
|
||||
// When |
||||
$result = $spec->match($testObject); |
||||
|
||||
// Then |
||||
$this->assertFalse($result); |
||||
} |
||||
|
||||
// NOT IN Operator Tests |
||||
|
||||
public function testWhenUsingNotInThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$invalidValues = ['banned', 'suspended']; |
||||
$spec = new Specification(Spec::nin('status', $invalidValues)); |
||||
$matchingObject = $this->createSimpleObject(['status' => 'active']); |
||||
$nonMatchingObject = $this->createSimpleObject(['status' => 'banned']); |
||||
|
||||
// When |
||||
$matchResult = $spec->match($matchingObject); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
// DateTime Tests |
||||
|
||||
public function testWhenUsingDateTimeComparisonWithStringsThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::lt('createdAt', self::TEST_DATE_2)); |
||||
$matchingObject = $this->createSimpleObject(['createdAt' => self::TEST_DATE]); |
||||
$nonMatchingObject = $this->createSimpleObject(['createdAt' => self::TEST_DATE_2]); |
||||
|
||||
// When |
||||
$matchResult = $spec->match($matchingObject); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
public function testWhenUsingDateTimeComparisonWithDateTimeObjectsThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$testDate = new DateTimeImmutable(self::TEST_DATE); |
||||
$spec = new Specification(Spec::lte('expirationDate', $testDate)); |
||||
$matchingObject = $this->createComplexObject(['expirationDate' => new DateTimeImmutable(self::TEST_DATE)]); |
||||
$nonMatchingObject = $this->createComplexObject(['expirationDate' => new DateTimeImmutable(self::TEST_DATE_2)]); |
||||
|
||||
// When |
||||
$matchResult = $spec->match($matchingObject); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
public function testWhenUsingDateTimeComparisonWithMixedTypesThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$testDate = new DateTimeImmutable(self::TEST_DATE); |
||||
$spec = new Specification(Spec::eq('createdAt', $testDate)); |
||||
$matchingObject = $this->createSimpleObject(['createdAt' => self::TEST_DATE]); |
||||
|
||||
// When |
||||
$result = $spec->match($matchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($result); |
||||
} |
||||
|
||||
// AND Group Tests |
||||
|
||||
public function testWhenUsingAndGroupThenMatchesOnlyWhenAllConditionsMet(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::and([ |
||||
Spec::eq('status', 'active'), |
||||
Spec::gte('score', 80), |
||||
Spec::in('role', ['admin', 'moderator']) |
||||
])); |
||||
$matchingObject = $this->createSimpleObject([ |
||||
'status' => 'active', |
||||
'score' => 85, |
||||
'role' => 'admin' |
||||
]); |
||||
$nonMatchingObject1 = $this->createSimpleObject([ |
||||
'status' => 'inactive', |
||||
'score' => 85, |
||||
'role' => 'admin' |
||||
]); |
||||
$nonMatchingObject2 = $this->createSimpleObject([ |
||||
'status' => 'active', |
||||
'score' => 70, |
||||
'role' => 'admin' |
||||
]); |
||||
$nonMatchingObject3 = $this->createSimpleObject([ |
||||
'status' => 'active', |
||||
'score' => 85, |
||||
'role' => 'user' |
||||
]); |
||||
|
||||
// When |
||||
$matchResult = $spec->match($matchingObject); |
||||
$noMatchResult1 = $spec->match($nonMatchingObject1); |
||||
$noMatchResult2 = $spec->match($nonMatchingObject2); |
||||
$noMatchResult3 = $spec->match($nonMatchingObject3); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult); |
||||
$this->assertFalse($noMatchResult1); |
||||
$this->assertFalse($noMatchResult2); |
||||
$this->assertFalse($noMatchResult3); |
||||
} |
||||
|
||||
public function testWhenUsingEmptyAndGroupThenAlwaysMatches(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::and([])); |
||||
$testObject = $this->createSimpleObject(['any' => 'value']); |
||||
|
||||
// When |
||||
$result = $spec->match($testObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($result); |
||||
} |
||||
|
||||
// OR Group Tests |
||||
|
||||
public function testWhenUsingOrGroupThenMatchesWhenAnyConditionMet(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::or([ |
||||
Spec::eq('role', 'admin'), |
||||
Spec::gte('score', 90), |
||||
Spec::in('department', ['IT', 'HR']) |
||||
])); |
||||
$matchingObject1 = $this->createSimpleObject([ |
||||
'role' => 'admin', |
||||
'score' => 70, |
||||
'department' => 'Finance' |
||||
]); |
||||
$matchingObject2 = $this->createSimpleObject([ |
||||
'role' => 'user', |
||||
'score' => 95, |
||||
'department' => 'Finance' |
||||
]); |
||||
$matchingObject3 = $this->createSimpleObject([ |
||||
'role' => 'user', |
||||
'score' => 70, |
||||
'department' => 'IT' |
||||
]); |
||||
$nonMatchingObject = $this->createSimpleObject([ |
||||
'role' => 'user', |
||||
'score' => 70, |
||||
'department' => 'Finance' |
||||
]); |
||||
|
||||
// When |
||||
$matchResult1 = $spec->match($matchingObject1); |
||||
$matchResult2 = $spec->match($matchingObject2); |
||||
$matchResult3 = $spec->match($matchingObject3); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult1); |
||||
$this->assertTrue($matchResult2); |
||||
$this->assertTrue($matchResult3); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
public function testWhenUsingEmptyOrGroupThenNeverMatches(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::or([])); |
||||
$testObject = $this->createSimpleObject(['any' => 'value']); |
||||
|
||||
// When |
||||
$result = $spec->match($testObject); |
||||
|
||||
// Then |
||||
$this->assertFalse($result); |
||||
} |
||||
|
||||
// NOT Group Tests |
||||
|
||||
public function testWhenUsingNotGroupThenInvertsCondition(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::not(Spec::eq('status', 'banned'))); |
||||
$matchingObject = $this->createSimpleObject(['status' => 'active']); |
||||
$nonMatchingObject = $this->createSimpleObject(['status' => 'banned']); |
||||
|
||||
// When |
||||
$matchResult = $spec->match($matchingObject); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
// Complex Nested Groups Tests |
||||
|
||||
public function testWhenUsingNestedAndOrGroupsThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::and([ |
||||
Spec::eq('status', 'active'), |
||||
Spec::or([ |
||||
Spec::gte('score', 80), |
||||
Spec::in('role', ['admin', 'moderator']) |
||||
]) |
||||
])); |
||||
$matchingObject1 = $this->createSimpleObject([ |
||||
'status' => 'active', |
||||
'score' => 85, |
||||
'role' => 'user' |
||||
]); |
||||
$matchingObject2 = $this->createSimpleObject([ |
||||
'status' => 'active', |
||||
'score' => 70, |
||||
'role' => 'admin' |
||||
]); |
||||
$nonMatchingObject = $this->createSimpleObject([ |
||||
'status' => 'inactive', |
||||
'score' => 85, |
||||
'role' => 'user' |
||||
]); |
||||
|
||||
// When |
||||
$matchResult1 = $spec->match($matchingObject1); |
||||
$matchResult2 = $spec->match($matchingObject2); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult1); |
||||
$this->assertTrue($matchResult2); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
public function testWhenUsingTripleNestedGroupsThenMatchesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::and([ |
||||
Spec::eq('active', true), |
||||
Spec::not(Spec::or([ |
||||
Spec::eq('role', 'banned'), |
||||
Spec::eq('status', 'suspended') |
||||
])) |
||||
])); |
||||
$matchingObject = $this->createSimpleObject([ |
||||
'active' => true, |
||||
'role' => 'user', |
||||
'status' => 'active' |
||||
]); |
||||
$nonMatchingObject1 = $this->createSimpleObject([ |
||||
'active' => false, |
||||
'role' => 'user', |
||||
'status' => 'active' |
||||
]); |
||||
$nonMatchingObject2 = $this->createSimpleObject([ |
||||
'active' => true, |
||||
'role' => 'banned', |
||||
'status' => 'active' |
||||
]); |
||||
|
||||
// When |
||||
$matchResult = $spec->match($matchingObject); |
||||
$noMatchResult1 = $spec->match($nonMatchingObject1); |
||||
$noMatchResult2 = $spec->match($nonMatchingObject2); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult); |
||||
$this->assertFalse($noMatchResult1); |
||||
$this->assertFalse($noMatchResult2); |
||||
} |
||||
|
||||
// Edge Case Tests |
||||
|
||||
public function testWhenFieldDoesNotExistThenReturnsFalse(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::eq('nonExistentField', 'value')); |
||||
$testObject = $this->createSimpleObject(['existingField' => 'value']); |
||||
|
||||
// When |
||||
$result = $spec->match($testObject); |
||||
|
||||
// Then |
||||
$this->assertFalse($result); |
||||
} |
||||
|
||||
public function testWhenFieldIsNullThenComparesCorrectly(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::eq('optionalField', null)); |
||||
$matchingObject = $this->createSimpleObject(['optionalField' => null]); |
||||
$nonMatchingObject = $this->createSimpleObject(['optionalField' => 'value']); |
||||
|
||||
// When |
||||
$matchResult = $spec->match($matchingObject); |
||||
$noMatchResult = $spec->match($nonMatchingObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($matchResult); |
||||
$this->assertFalse($noMatchResult); |
||||
} |
||||
|
||||
public function testWhenUsingInvalidOperatorThenReturnsFalse(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(['field', 'INVALID_OP', 'value']); |
||||
$testObject = $this->createSimpleObject(['field' => 'value']); |
||||
|
||||
// When |
||||
$result = $spec->match($testObject); |
||||
|
||||
// Then |
||||
$this->assertFalse($result); |
||||
} |
||||
|
||||
public function testWhenUsingInvalidDateStringThenFallsBackToRegularComparison(): void |
||||
{ |
||||
// Given |
||||
$spec = new Specification(Spec::eq('dateField', 'invalid-date')); |
||||
$testObject = $this->createSimpleObject(['dateField' => 'invalid-date']); |
||||
|
||||
// When |
||||
$result = $spec->match($testObject); |
||||
|
||||
// Then |
||||
$this->assertTrue($result); |
||||
} |
||||
|
||||
// Spec Helper Method Tests |
||||
|
||||
public function testWhenUsingSpecHelpersThenCreatesCorrectSpecificationArray(): void |
||||
{ |
||||
// Given |
||||
$eqSpec = Spec::eq('field', 'value'); |
||||
$neqSpec = Spec::neq('field', 'value'); |
||||
$gtSpec = Spec::gt('field', 10); |
||||
$gteSpec = Spec::gte('field', 10); |
||||
$ltSpec = Spec::lt('field', 10); |
||||
$lteSpec = Spec::lte('field', 10); |
||||
$inSpec = Spec::in('field', ['a', 'b']); |
||||
$ninSpec = Spec::nin('field', ['a', 'b']); |
||||
|
||||
// When & Then |
||||
$this->assertEquals(['field', '=', 'value'], $eqSpec); |
||||
$this->assertEquals(['field', '!=', 'value'], $neqSpec); |
||||
$this->assertEquals(['field', '>', 10], $gtSpec); |
||||
$this->assertEquals(['field', '>=', 10], $gteSpec); |
||||
$this->assertEquals(['field', '<', 10], $ltSpec); |
||||
$this->assertEquals(['field', '<=', 10], $lteSpec); |
||||
$this->assertEquals(['field', 'IN', ['a', 'b']], $inSpec); |
||||
$this->assertEquals(['field', 'NOT IN', ['a', 'b']], $ninSpec); |
||||
} |
||||
|
||||
public function testWhenUsingLogicalGroupHelpersThenCreatesCorrectSpecificationArray(): void |
||||
{ |
||||
// Given |
||||
$andSpec = Spec::and([Spec::eq('a', 1), Spec::eq('b', 2)]); |
||||
$orSpec = Spec::or([Spec::eq('a', 1), Spec::eq('b', 2)]); |
||||
$notSpec = Spec::not(Spec::eq('a', 1)); |
||||
|
||||
// When & Then |
||||
$this->assertEquals(['AND' => [['a', '=', 1], ['b', '=', 2]]], $andSpec); |
||||
$this->assertEquals(['OR' => [['a', '=', 1], ['b', '=', 2]]], $orSpec); |
||||
$this->assertEquals(['NOT' => ['a', '=', 1]], $notSpec); |
||||
} |
||||
|
||||
public function testGetSpecReturnsOriginalSpecification(): void |
||||
{ |
||||
// Given |
||||
$originalSpec = Spec::eq('field', 'value'); |
||||
$specification = new Specification($originalSpec); |
||||
|
||||
// When |
||||
$retrievedSpec = $specification->getSpec(); |
||||
|
||||
// Then |
||||
$this->assertEquals($originalSpec, $retrievedSpec); |
||||
} |
||||
} |
||||
|
||||
// Test helper class with getters for complex object testing |
||||
class TestObject |
||||
{ |
||||
private array $data; |
||||
|
||||
public function __construct(array $data) |
||||
{ |
||||
$this->data = $data; |
||||
} |
||||
|
||||
public function __get($name) |
||||
{ |
||||
return $this->data[$name] ?? null; |
||||
} |
||||
|
||||
public function __isset($name) |
||||
{ |
||||
return isset($this->data[$name]); |
||||
} |
||||
|
||||
public function getName() |
||||
{ |
||||
return $this->data['name'] ?? null; |
||||
} |
||||
|
||||
public function getExpirationDate() |
||||
{ |
||||
return $this->data['expirationDate'] ?? null; |
||||
} |
||||
|
||||
public function getCreatedAt() |
||||
{ |
||||
return $this->data['createdAt'] ?? null; |
||||
} |
||||
|
||||
public function getScore() |
||||
{ |
||||
return $this->data['score'] ?? null; |
||||
} |
||||
|
||||
public function getStatus() |
||||
{ |
||||
return $this->data['status'] ?? null; |
||||
} |
||||
|
||||
public function getRole() |
||||
{ |
||||
return $this->data['role'] ?? null; |
||||
} |
||||
|
||||
public function getAge() |
||||
{ |
||||
return $this->data['age'] ?? null; |
||||
} |
||||
|
||||
public function getPrice() |
||||
{ |
||||
return $this->data['price'] ?? null; |
||||
} |
||||
|
||||
public function getDepartment() |
||||
{ |
||||
return $this->data['department'] ?? null; |
||||
} |
||||
|
||||
public function getActive() |
||||
{ |
||||
return $this->data['active'] ?? null; |
||||
} |
||||
|
||||
public function getOptionalField() |
||||
{ |
||||
return $this->data['optionalField'] ?? null; |
||||
} |
||||
|
||||
public function getDateField() |
||||
{ |
||||
return $this->data['dateField'] ?? null; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue