diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff1217f..90c1d35d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [v0.4.0 (2024-09-27)](https://github.com/phenixphp/framework/compare/0.3.8...0.4.0) ### Added +- Integrate CORS middleware. ([#30](https://github.com/phenixphp/framework/pull/30)) +- Add basic form request. ([#31](https://github.com/phenixphp/framework/pull/31)) - Basic validation layer using form request. ([#32](https://github.com/phenixphp/framework/pull/32)) - Stream form parser. ([#33](https://github.com/phenixphp/framework/pull/33)) - Move validation layer to framework. ([#34](https://github.com/phenixphp/framework/pull/34)) diff --git a/src/Configurations/Cors.php b/src/Configurations/Cors.php index 26cf4b0a..0c4c75d0 100644 --- a/src/Configurations/Cors.php +++ b/src/Configurations/Cors.php @@ -8,12 +8,12 @@ class Cors extends Configuration { - public readonly array|string $origins; - public readonly array $allowedMethods; - public readonly int $maxAge; - public readonly array $allowedHeaders; - public readonly array $exposableHeaders; - public readonly bool $allowCredentials; + protected array|string $origins; + protected array $allowedMethods; + protected int $maxAge; + protected array $allowedHeaders; + protected array $exposableHeaders; + protected bool $allowCredentials; public function __construct(array $config) { diff --git a/src/Contracts/Database/ModelAttribute.php b/src/Contracts/Database/ModelAttribute.php new file mode 100644 index 00000000..ef8b59a0 --- /dev/null +++ b/src/Contracts/Database/ModelAttribute.php @@ -0,0 +1,10 @@ +data); + + if ($firstIndex === null) { + return null; + } + + return $this->data[$firstIndex]; + } } diff --git a/src/Database/Concerns/Query/HasSentences.php b/src/Database/Concerns/Query/HasSentences.php new file mode 100644 index 00000000..d1bb6a89 --- /dev/null +++ b/src/Database/Concerns/Query/HasSentences.php @@ -0,0 +1,114 @@ +action = Actions::SELECT; + + $query = Query::fromUri($uri); + + $currentPage = filter_var($query->get('page') ?? $defaultPage, FILTER_SANITIZE_NUMBER_INT); + $currentPage = $currentPage === false ? $defaultPage : $currentPage; + + $perPage = filter_var($query->get('per_page') ?? $defaultPerPage, FILTER_SANITIZE_NUMBER_INT); + $perPage = $perPage === false ? $defaultPerPage : $perPage; + + $countQuery = clone $this; + + $total = $countQuery->count(); + + $data = $this->page((int) $currentPage, (int) $perPage)->get(); + + return new Paginator($uri, $data, (int) $total, (int) $currentPage, (int) $perPage); + } + + public function count(string $column = '*'): int + { + $this->action = Actions::SELECT; + + $this->countRows($column); + + [$dml, $params] = $this->toSql(); + + /** @var array $count */ + $count = $this->connection + ->prepare($dml) + ->execute($params) + ->fetchRow(); + + return array_values($count)[0]; + } + + public function insert(array $data): bool + { + [$dml, $params] = $this->insertRows($data)->toSql(); + + try { + $this->connection->prepare($dml)->execute($params); + + return true; + } catch (SqlQueryError|SqlTransactionError) { + return false; + } + } + + public function exists(): bool + { + $this->action = Actions::EXISTS; + + $this->existsRows(); + + [$dml, $params] = $this->toSql(); + + $results = $this->connection->prepare($dml)->execute($params)->fetchRow(); + + return (bool) array_values($results)[0]; + } + + public function doesntExist(): bool + { + return ! $this->exists(); + } + + public function update(array $values): bool + { + $this->updateRow($values); + + [$dml, $params] = $this->toSql(); + + try { + $this->connection->prepare($dml)->execute($params); + + return true; + } catch (SqlQueryError|SqlTransactionError) { + return false; + } + } + + public function delete(): bool + { + $this->deleteRows(); + + [$dml, $params] = $this->toSql(); + + try { + $this->connection->prepare($dml)->execute($params); + + return true; + } catch (SqlQueryError|SqlTransactionError) { + return false; + } + } +} diff --git a/src/Database/Concerns/Query/PrepareColumns.php b/src/Database/Concerns/Query/PrepareColumns.php index 7c986fb8..37cf4934 100644 --- a/src/Database/Concerns/Query/PrepareColumns.php +++ b/src/Database/Concerns/Query/PrepareColumns.php @@ -4,24 +4,28 @@ namespace Phenix\Database\Concerns\Query; +use Phenix\Database\Alias; use Phenix\Database\Functions; use Phenix\Database\SelectCase; use Phenix\Database\Subquery; use Phenix\Exceptions\QueryError; use Phenix\Util\Arr; +use function is_string; + trait PrepareColumns { protected function prepareColumns(array $columns): string { - $columns = array_map(function ($column) { + $columns = Arr::map($columns, function (string|Functions|SelectCase|Subquery $value, int|string $key): string { return match (true) { - $column instanceof Functions => (string) $column, - $column instanceof SelectCase => (string) $column, - $column instanceof Subquery => $this->resolveSubquery($column), - default => $column, + is_string($key) => (string) Alias::of($key)->as($value), + $value instanceof Functions => (string) $value, + $value instanceof SelectCase => (string) $value, + $value instanceof Subquery => $this->resolveSubquery($value), + default => $value, }; - }, $columns); + }); return Arr::implodeDeeply($columns, ', '); } diff --git a/src/Database/Constants/IdType.php b/src/Database/Constants/IdType.php new file mode 100644 index 00000000..d9fec3d8 --- /dev/null +++ b/src/Database/Constants/IdType.php @@ -0,0 +1,11 @@ +name; + } +} diff --git a/src/Database/Models/Attributes/ForeignKey.php b/src/Database/Models/Attributes/ForeignKey.php new file mode 100644 index 00000000..dd883a9c --- /dev/null +++ b/src/Database/Models/Attributes/ForeignKey.php @@ -0,0 +1,12 @@ +reduce(function (array $carry, DatabaseModel $model): array { + $carry[] = $model->getKey(); + + return $carry; + }, []); + } + + public function map(callable $callback): self + { + return new self(array_map($callback, $this->data)); + } + + public function toArray(): array + { + return $this->reduce(function (array $carry, DatabaseModel $model): array { + $carry[] = $model->toArray(); + + return $carry; + }, []); + } +} diff --git a/src/Database/Models/DatabaseModel.php b/src/Database/Models/DatabaseModel.php new file mode 100644 index 00000000..ddc20a95 --- /dev/null +++ b/src/Database/Models/DatabaseModel.php @@ -0,0 +1,223 @@ +|null + */ + protected array|null $propertyBindings = null; + protected array|null $relationshipBindings = null; + protected DatabaseQueryBuilder|null $queryBuilder; + + public function __construct() + { + $this->table = static::table(); + $this->modelKey = null; + $this->queryBuilder = null; + $this->propertyBindings = null; + $this->relationshipBindings = null; + $this->pivot = new stdClass(); + } + + abstract protected static function table(): string; + + public static function query(): DatabaseQueryBuilder + { + $queryBuilder = static::newQueryBuilder(); + $queryBuilder->setModel(new static()); + + return $queryBuilder; + } + + /** + * @return array + */ + public function getPropertyBindings(): array + { + return $this->propertyBindings ??= $this->buildPropertyBindings(); + } + + /** + * @return array> + */ + public function getRelationshipBindings() + { + return $this->relationshipBindings ??= $this->buildRelationshipBindings(); + } + + public function newCollection(): Collection + { + return new Collection(); + } + + public function getTable(): string + { + return $this->table; + } + + public function getKey(): string|int + { + return $this->{$this->getModelKeyName()}; + } + + public function getModelKeyName(): string + { + $this->modelKey ??= $this->findModelKey(); + + return $this->modelKey->getName(); + } + + public function toArray(): array + { + $data = []; + + foreach ($this->getPropertyBindings() as $property) { + $propertyName = $property->getName(); + + $value = isset($this->{$propertyName}) ? $this->{$propertyName} : null; + + if ($value || $property->isNullable()) { + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } elseif ($value instanceof Date) { + $value = $value->toIso8601String(); + } + + $data[$propertyName] = $value; + } + } + + return $data; + } + + public function toJson(): string + { + return json_encode($this->toArray()); + } + + protected static function newQueryBuilder(): DatabaseQueryBuilder + { + return new DatabaseQueryBuilder(); + } + + protected function buildPropertyBindings(): array + { + $reflection = new ReflectionObject($this); + + $bindings = []; + + foreach ($reflection->getProperties() as $property) { + $attributes = array_map(function (ReflectionAttribute $attr): object { + return $attr->newInstance(); + }, $property->getAttributes()); + + /** @var array $attributes */ + $attributes = array_filter($attributes, fn (object $attr) => $attr instanceof ModelAttribute); + + if (empty($attributes)) { + continue; + } + + $attribute = array_shift($attributes); + $columnName = $attribute->getColumnName() ?? $property->getName(); + + $bindings[$columnName] = $this->buildModelProperty($attribute, $property); + } + + return $bindings; + } + + protected function buildRelationshipBindings(): array + { + $relationships = []; + + foreach ($this->getPropertyBindings() as $property) { + if ($property instanceof BelongsToProperty) { + $relationships[$property->getName()] = $this->buildBelongsToRelationship($property); + } elseif ($property instanceof HasManyProperty) { + $relationships[$property->getName()] = new HasMany($property); + } elseif ($property instanceof BelongsToManyProperty) { + $relationships[$property->getName()] = new BelongsToMany($property); + } + } + + return $relationships; + } + + protected function buildModelProperty(ModelAttribute&Column $attribute, ReflectionProperty $property): ModelProperty + { + $arguments = [ + $property->getName(), + (string) $property->getType(), + class_exists((string) $property->getType()), + $attribute, + $property->isInitialized($this) ? $property->getValue($this) : null, + ]; + + return match($attribute::class) { + BelongsToAttribute::class => new BelongsToProperty(...$arguments), + HasManyAttribute::class => new HasManyProperty(...$arguments), + BelongsToManyAttribute::class => new BelongsToManyProperty(...$arguments), + default => new ModelProperty(...$arguments), + }; + } + + protected function buildBelongsToRelationship(BelongsToProperty $property): BelongsTo + { + $foreignKey = Arr::first($this->getPropertyBindings(), function (ModelProperty $modelProperty) use ($property): bool { + return $property->getAttribute()->foreignProperty === $modelProperty->getName(); + }); + + if (! $foreignKey) { + throw new ModelException("Foreign key not found for {$property->getName()} relationship."); + } + + return new BelongsTo($property, $foreignKey); + } + + protected function findModelKey(): ModelProperty + { + return Arr::first($this->getPropertyBindings(), function (ModelProperty $property): bool { + return $property->getAttribute() instanceof Id; + }); + } +} diff --git a/src/Database/Models/Properties/BelongsToManyProperty.php b/src/Database/Models/Properties/BelongsToManyProperty.php new file mode 100644 index 00000000..c9c02ca0 --- /dev/null +++ b/src/Database/Models/Properties/BelongsToManyProperty.php @@ -0,0 +1,24 @@ +attribute; + } + + public function query(): DatabaseQueryBuilder + { + return $this->getAttribute()->relatedModel::query(); + } +} diff --git a/src/Database/Models/Properties/BelongsToProperty.php b/src/Database/Models/Properties/BelongsToProperty.php new file mode 100644 index 00000000..66997957 --- /dev/null +++ b/src/Database/Models/Properties/BelongsToProperty.php @@ -0,0 +1,24 @@ +attribute; + } + + public function query(): DatabaseQueryBuilder + { + return $this->type::query(); + } +} diff --git a/src/Database/Models/Properties/HasManyProperty.php b/src/Database/Models/Properties/HasManyProperty.php new file mode 100644 index 00000000..570844f7 --- /dev/null +++ b/src/Database/Models/Properties/HasManyProperty.php @@ -0,0 +1,24 @@ +attribute; + } + + public function query(): DatabaseQueryBuilder + { + return $this->getAttribute()->model::query(); + } +} diff --git a/src/Database/Models/Properties/ModelProperty.php b/src/Database/Models/Properties/ModelProperty.php new file mode 100644 index 00000000..53b8ed45 --- /dev/null +++ b/src/Database/Models/Properties/ModelProperty.php @@ -0,0 +1,96 @@ +value; + + return match ($this->normalizedType()) { + Date::class => $this->resolveDate($value), + default => $this->resolveType($value), + }; + } + + public function isNullable(): bool + { + return str_starts_with($this->type, '?'); + } + + public function getName(): string + { + return $this->name; + } + + public function getColumnName(): string + { + return $this->attribute->getColumnName() ?? $this->name; + } + + public function getType(): string + { + return $this->type; + } + + public function isInstantiable(): bool + { + return $this->isInstantiable; + } + + public function getAttribute(): Column + { + return $this->attribute; + } + + public function getValue(): mixed + { + return $this->value; + } + + protected function resolveDate(mixed $value): object|null + { + if (is_null($value) && $this->isNullable()) { + return null; + } + + return Date::parse($value); + } + + protected function resolveType(mixed $value): object|null + { + if (is_null($value) && $this->isNullable()) { + return null; + } + + $type = $this->normalizedType(); + + return new $type($value); + } + + private function normalizedType(): string + { + if (str_starts_with($this->type, '?')) { + return substr($this->type, 1); + } + + return $this->type; + } +} diff --git a/src/Database/Models/Properties/ModelPropertyInterface.php b/src/Database/Models/Properties/ModelPropertyInterface.php new file mode 100644 index 00000000..0138f0fb --- /dev/null +++ b/src/Database/Models/Properties/ModelPropertyInterface.php @@ -0,0 +1,24 @@ + + */ + protected array $relationships; + + protected SqlCommonConnectionPool $connection; + + public function __construct() + { + parent::__construct(); + + $this->relationships = []; + $this->connection = App::make(Connections::default()); + } + + public function connection(SqlCommonConnectionPool|string $connection): self + { + if (is_string($connection)) { + $connection = App::make(Connections::name($connection)); + } + + $this->connection = $connection; + + return $this; + } + + public function addSelect(array $columns): static + { + $this->action = Actions::SELECT; + + $this->columns = array_merge($this->columns, $columns); + + return $this; + } + + public function setModel(DatabaseModel $model): self + { + if (! isset($this->model)) { + $this->model = $model; + } + + $this->table = $this->model->getTable(); + + return $this; + } + + public function with(array|string $relationships): self + { + $modelRelationships = $this->model->getRelationshipBindings(); + + $relationshipParser = new RelationshipParser((array) $relationships); + $relationshipParser->parse(); + + foreach ($relationshipParser->toArray() as $relationshipName => $relationshipData) { + ['columns' => $columns, 'relationships' => $relations] = $relationshipData; + + $closure = is_array($columns) + ? fn ($builder) => $builder->query()->select($columns)->with($relations) + : $columns; + + $relationship = $modelRelationships[$relationshipName] ?? null; + + if (! $relationship) { + throw new ModelException("Undefined relationship {$relationshipName} for " . $this->model::class); + } + + $this->relationships[] = [$relationship, $closure]; + } + + return $this; + } + + /** + * @return Collection + */ + public function get(): Collection + { + $this->action = Actions::SELECT; + $this->columns = empty($this->columns) ? ['*'] : $this->columns; + + [$dml, $params] = $this->toSql(); + + $result = $this->connection->prepare($dml) + ->execute($params); + + $collection = $this->model->newCollection(); + + foreach ($result as $row) { + $collection->add($this->mapToModel($row)); + } + + if (! $collection->isEmpty()) { + $this->resolveRelationships($collection); + } + + return $collection; + } + + public function first(): DatabaseModel + { + $this->action = Actions::SELECT; + + $this->limit(1); + + return $this->get()->first(); + } + + /** + * @param array $row + * @return DatabaseModel + */ + protected function mapToModel(array $row): DatabaseModel + { + /** @var array $propertyBindings */ + $propertyBindings = $this->model->getPropertyBindings(); + + $model = clone $this->model; + + foreach ($row as $columnName => $value) { + if (array_key_exists($columnName, $propertyBindings)) { + $property = $propertyBindings[$columnName]; + + $model->{$property->getName()} = $property->isInstantiable() ? $property->resolveInstance($value) : $value; + } elseif (str_starts_with($columnName, 'pivot_')) { + $columnName = str_replace('pivot_', '', $columnName); + + $model->pivot->{$columnName} = $value; + } else { + throw new ModelException("Unknown column '{$columnName}' for model " . $model::class); + } + } + + return $model; + } + + protected function resolveRelationships(Collection $collection): void + { + foreach ($this->relationships as [$relationship, $closure]) { + if ($relationship instanceof BelongsTo) { + $this->resolveBelongsToRelationship($collection, $relationship, $closure); + } elseif ($relationship instanceof HasMany) { + $this->resolveHasManyRelationship($collection, $relationship, $closure); + } elseif ($relationship instanceof BelongsToMany) { + $this->resolveBelongsToManyRelationship($collection, $relationship, $closure); + } + } + } + + /** + * @param Collection $models + * @param BelongsTo $relationship + * @param Closure $closure + */ + protected function resolveBelongsToRelationship( + Collection $models, + BelongsTo $relationship, + Closure $closure + ): void { + $closure($relationship); + + /** @var Collection $records */ + $records = $relationship->query() + ->whereIn($relationship->getForeignKey()->getColumnName(), $models->modelKeys()) + ->get(); + + $models->map(function (DatabaseModel $model) use ($records, $relationship): DatabaseModel { + foreach ($records as $record) { + if ($record->getKey() === $model->getKey()) { + $model->{$relationship->getProperty()->getName()} = $record; + } + } + + return $model; + }); + } + + /** + * @param Collection $models + * @param HasMany $relationship + * @param Closure $closure + */ + protected function resolveHasManyRelationship( + Collection $models, + HasMany $relationship, + Closure $closure + ): void { + $closure($relationship); + + /** @var Collection $children */ + $children = $relationship->query() + ->whereIn($relationship->getProperty()->getAttribute()->foreignKey, $models->modelKeys()) + ->get(); + + if (! $children->isEmpty()) { + /** @var ModelProperty $chaperoneProperty */ + $chaperoneProperty = Arr::first($children->first()->getPropertyBindings(), function (ModelProperty $property): bool { + return $this->model::class === $property->getType(); + }); + + $models->map(function (DatabaseModel $model) use ($children, $relationship, $chaperoneProperty): DatabaseModel { + $records = $children->filter(fn (DatabaseModel $record) => $model->getKey() === $record->getKey()); + + if ($relationship->getProperty()->getAttribute()->chaperone || $relationship->assignChaperone()) { + $model->{$relationship->getProperty()->getName()} = $records->map(function (DatabaseModel $childModel) use ($model, $chaperoneProperty): DatabaseModel { + $childModel->{$chaperoneProperty->getName()} = clone $model; + + return $childModel; + }); + } else { + $model->{$relationship->getProperty()->getName()} = $records; + } + + return $model; + }); + } + } + + /** + * @param Collection $models + * @param BelongsToMany $relationship + * @param Closure $closure + */ + protected function resolveBelongsToManyRelationship( + Collection $models, + BelongsToMany $relationship, + Closure $closure + ): void { + $closure($relationship); + + $attr = $relationship->getProperty()->getAttribute(); + + /** @var Collection $related */ + $related = $relationship->query() + ->addSelect($relationship->getColumns()) + ->innerJoin($attr->table, function (Join $join) use ($attr): void { + $join->onEqual( + "{$this->model->getTable()}.{$this->model->getModelKeyName()}", + "{$attr->table}.{$attr->relatedForeignKey}" + ); + }) + ->whereIn("{$attr->table}.{$attr->foreignKey}", $models->modelKeys()) + ->get(); + + $models->map(function (DatabaseModel $model) use ($relationship, $attr, $related): DatabaseModel { + $records = $related->filter(fn (DatabaseModel $record): bool => $model->getKey() === $record->pivot->{$attr->foreignKey}); + + $model->{$relationship->getProperty()->getName()} = $records; + + return $model; + }); + } +} diff --git a/src/Database/Models/Relationships/BelongsTo.php b/src/Database/Models/Relationships/BelongsTo.php new file mode 100644 index 00000000..5d24f3f2 --- /dev/null +++ b/src/Database/Models/Relationships/BelongsTo.php @@ -0,0 +1,33 @@ +property; + } + + public function getForeignKey(): ModelProperty + { + return $this->foreignKey; + } + + protected function initQueryBuilder(): DatabaseQueryBuilder + { + return $this->property->query(); + } +} diff --git a/src/Database/Models/Relationships/BelongsToMany.php b/src/Database/Models/Relationships/BelongsToMany.php new file mode 100644 index 00000000..65485fef --- /dev/null +++ b/src/Database/Models/Relationships/BelongsToMany.php @@ -0,0 +1,56 @@ +queryBuilder = null; + $this->pivotColumns = []; + } + + public function getProperty(): BelongsToManyProperty + { + return $this->property; + } + + public function withPivot(array $columns): self + { + $this->pivotColumns = $columns; + + return $this; + } + + public function getColumns(): array + { + $attr = $this->getProperty()->getAttribute(); + + $columns = [ + $attr->foreignKey, + $attr->relatedForeignKey, + ...$this->pivotColumns, + ]; + + $keys = Arr::map($columns, fn (string $column) => "{$attr->table}.{$column}"); + $values = Arr::map($columns, fn (string $column) => "pivot_{$column}"); + + return array_combine($keys, $values); + } + + protected function initQueryBuilder(): DatabaseQueryBuilder + { + return $this->property->query(); + } +} diff --git a/src/Database/Models/Relationships/BelongsToRelationship.php b/src/Database/Models/Relationships/BelongsToRelationship.php new file mode 100644 index 00000000..8b8c6830 --- /dev/null +++ b/src/Database/Models/Relationships/BelongsToRelationship.php @@ -0,0 +1,22 @@ +chaperone = true; + + return $this; + } + + public function assignChaperone(): bool + { + return $this->chaperone; + } +} diff --git a/src/Database/Models/Relationships/HasMany.php b/src/Database/Models/Relationships/HasMany.php new file mode 100644 index 00000000..a526caaf --- /dev/null +++ b/src/Database/Models/Relationships/HasMany.php @@ -0,0 +1,27 @@ +queryBuilder = null; + } + + public function getProperty(): HasManyProperty + { + return $this->property; + } + + protected function initQueryBuilder(): DatabaseQueryBuilder + { + return $this->property->query(); + } +} diff --git a/src/Database/Models/Relationships/Relationship.php b/src/Database/Models/Relationships/Relationship.php new file mode 100644 index 00000000..87d0e126 --- /dev/null +++ b/src/Database/Models/Relationships/Relationship.php @@ -0,0 +1,19 @@ +queryBuilder ??= $this->initQueryBuilder(); + } +} diff --git a/src/Database/Models/Relationships/RelationshipParser.php b/src/Database/Models/Relationships/RelationshipParser.php new file mode 100644 index 00000000..9206b184 --- /dev/null +++ b/src/Database/Models/Relationships/RelationshipParser.php @@ -0,0 +1,70 @@ +mappedRelationships = []; + } + + public function parse(): self + { + $this->mappedRelationships = $this->parseRelations(); + + return $this; + } + + public function toArray(): array + { + return $this->mappedRelationships; + } + + protected function parseRelations(): array + { + $relations = []; + + foreach ($this->relationships as $key => $value) { + $columns = ['*']; + + if ($value instanceof Closure) { + $relationKey = $key; + $columns = $value; + } else { + $relationKey = $value; + } + + $keys = explode('.', $relationKey); + $relationName = array_shift($keys); + + if (str_contains($relationName, ':')) { + [$relationName, $columns] = explode(':', $relationName); + + $columns = explode(',', $columns); + } + + $keys = empty($keys) ? [] : Arr::wrap(implode('.', $keys)); + + if (isset($relations[$relationName])) { + $relations[$relationName]['relationships'] = array_merge($relations[$relationName]['relationships'], $keys); + } else { + $relations[$relationName] = [ + 'columns' => $columns, + 'relationships' => $keys, + ]; + } + } + + return $relations; + } +} diff --git a/src/Database/ORM/Ashes.php b/src/Database/ORM/Ashes.php deleted file mode 100644 index d4af13d8..00000000 --- a/src/Database/ORM/Ashes.php +++ /dev/null @@ -1,15 +0,0 @@ -table; - } -} diff --git a/src/Database/ORM/Model.php b/src/Database/ORM/Model.php deleted file mode 100644 index d6e56663..00000000 --- a/src/Database/ORM/Model.php +++ /dev/null @@ -1,15 +0,0 @@ -get()->first(); } - - public function paginate(Http $uri, int $defaultPage = 1, int $defaultPerPage = 15): Paginator - { - $this->action = Actions::SELECT; - - $query = Query::fromUri($uri); - - $currentPage = filter_var($query->get('page') ?? $defaultPage, FILTER_SANITIZE_NUMBER_INT); - $currentPage = $currentPage === false ? $defaultPage : $currentPage; - - $perPage = filter_var($query->get('per_page') ?? $defaultPerPage, FILTER_SANITIZE_NUMBER_INT); - $perPage = $perPage === false ? $defaultPerPage : $perPage; - - $total = (new self())->connection($this->connection) - ->from($this->table) - ->count(); - - $data = $this->page((int) $currentPage, (int) $perPage)->get(); - - return new Paginator($uri, $data, (int) $total, (int) $currentPage, (int) $perPage); - } - - public function count(string $column = '*'): int - { - $this->action = Actions::SELECT; - - $this->countRows($column); - - [$dml, $params] = $this->toSql(); - - /** @var array $count */ - $count = $this->connection - ->prepare($dml) - ->execute($params) - ->fetchRow(); - - return array_values($count)[0]; - } - - public function insert(array $data): bool - { - [$dml, $params] = $this->insertRows($data)->toSql(); - - try { - $this->connection->prepare($dml)->execute($params); - - return true; - } catch (SqlQueryError|SqlTransactionError) { - return false; - } - } - - public function exists(): bool - { - $this->action = Actions::EXISTS; - - $this->existsRows(); - - [$dml, $params] = $this->toSql(); - - $results = $this->connection->prepare($dml)->execute($params)->fetchRow(); - - return (bool) array_values($results)[0]; - } - - public function doesntExist(): bool - { - return ! $this->exists(); - } - - public function update(array $values): bool - { - $this->updateRow($values); - - [$dml, $params] = $this->toSql(); - - try { - $this->connection->prepare($dml)->execute($params); - - return true; - } catch (SqlQueryError|SqlTransactionError) { - return false; - } - } - - public function delete(): bool - { - $this->deleteRows(); - - [$dml, $params] = $this->toSql(); - - try { - $this->connection->prepare($dml)->execute($params); - - return true; - } catch (SqlQueryError|SqlTransactionError) { - return false; - } - } } diff --git a/src/Exceptions/Database/ModelException.php b/src/Exceptions/Database/ModelException.php new file mode 100644 index 00000000..0aaa4f3a --- /dev/null +++ b/src/Exceptions/Database/ModelException.php @@ -0,0 +1,12 @@ + $value) { + if ($closure($value, $key)) { + $result = $value; + + break; + } + } + } elseif (array_is_list($data)) { + $result = $data[0] ?? $default; + } else { + $result = array_values($data)[0] ?? $default; + } + + return $result; + } + + public static function wrap(mixed $value): array + { + if (is_null($value)) { + return []; + } + + return is_array($value) ? $value : [$value]; + } + + public static function set(array &$array, string|int $key, mixed $value): array + { + $keys = explode('.', $key); + + foreach ($keys as $i => $key) { + if (count($keys) === 1) { + break; + } + + unset($keys[$i]); + + if (! isset($array[$key]) || ! is_array($array[$key])) { + $array[$key] = []; + } + + $array = &$array[$key]; + } + + $array[array_shift($keys)] = $value; + + return $array; + } + + public static function undot(array $array): array + { + $results = []; + + foreach ($array as $key => $value) { + static::set($results, $key, $value); + } + + return $results; + } + + public static function has(ArrayAccess|array $array, string|array $keys): bool + { + $keys = (array) $keys; + + if (! $array || $keys === []) { + return false; + } + + foreach ($keys as $key) { + $subKeyArray = $array; + + if (static::exists($array, $key)) { + continue; + } + + foreach (explode('.', $key) as $segment) { + if (static::accessible($subKeyArray) && static::exists($subKeyArray, $segment)) { + $subKeyArray = $subKeyArray[$segment]; + } else { + return false; + } + } + } + + return true; + } + + public static function exists(ArrayAccess|array $array, string|int $key): bool + { + if ($array instanceof ArrayAccess) { + return $array->offsetExists($key); + } + + return array_key_exists($key, $array); + } + + public static function accessible(mixed $value): bool + { + return is_array($value) || $value instanceof ArrayAccess; + } + + public static function get(ArrayAccess|array $array, string|int|null $key, mixed $default = null): mixed + { + $result = $default; + + if (static::accessible($array)) { + if ($key === null) { + $result = $array; + } elseif (static::exists($array, $key)) { + $result = $array[$key]; + } elseif (! str_contains($key, '.')) { + $result = $array[$key] ?? value($default); + } else { + foreach (explode('.', $key) as $segment) { + if (static::accessible($array) && static::exists($array, $segment)) { + $array = $array[$segment]; + $result = $array; + } else { + $result = value($default); + + break; + } + } + } + } + + return $result; + } } diff --git a/src/functions.php b/src/functions.php index 39673c56..0ffdb04d 100644 --- a/src/functions.php +++ b/src/functions.php @@ -37,3 +37,10 @@ function env(string $key, Closure|null $default = null): array|string|int|bool|n return $default instanceof Closure ? $default() : $default; } } + +if (! function_exists('value')) { + function value($value, ...$args) + { + return $value instanceof Closure ? $value(...$args) : $value; + } +} diff --git a/tests/Feature/Database/DatabaseModelTest.php b/tests/Feature/Database/DatabaseModelTest.php new file mode 100644 index 00000000..4259c38f --- /dev/null +++ b/tests/Feature/Database/DatabaseModelTest.php @@ -0,0 +1,713 @@ + 1, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($data)), + ); + + $this->app->swap(Connections::default(), $connection); + + $users = User::query()->selectAllColumns()->get(); + + expect($users)->toBeInstanceOf(Collection::class); + expect($users->first())->toBeInstanceOf(User::class); + + /** @var User $user */ + $user = $users->first(); + + expect($user->id)->toBe($data[0]['id']); + expect($user->name)->toBe($data[0]['name']); + expect($user->email)->toBe($data[0]['email']); + expect($user->createdAt)->toBeInstanceOf(Date::class); + expect($user->updatedAt)->toBeNull(); + + $data[0]['createdAt'] = Date::parse($data[0]['created_at'])->toIso8601String(); + unset($data[0]['created_at']); + $data[0]['updatedAt'] = null; + + expect($user->toJson())->toBe(json_encode($data[0])); +}); + +it('loads relationship when the model belongs to a parent model', function () { + $userData = [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $userCollection[] = $userData; + + $postData = [ + 'id' => 1, + 'title' => 'PHP is great', + 'content' => faker()->sentence(), + 'user_id' => $userData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $postCollection[] = $postData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($postCollection)), + new Statement(new Result($userCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var Post $post */ + $post = Post::query()->selectAllColumns() + ->with('user') + ->first(); + + expect($post)->toBeInstanceOf(Post::class); + + expect($post->id)->toBe($postData['id']); + expect($post->title)->toBe($postData['title']); + expect($post->content)->toBe($postData['content']); + expect($post->createdAt)->toBeInstanceOf(Date::class); + expect($post->updatedAt)->toBeNull(); + + expect($post->user)->toBeInstanceOf(User::class); + + expect($post->user->id)->toBe($userData['id']); + expect($post->user->name)->toBe($userData['name']); + expect($post->user->email)->toBe($userData['email']); +}); + +it('loads relationship with short syntax to select columns', function () { + $userData = [ + 'id' => 1, + 'name' => 'John Doe', + ]; + + $userCollection[] = $userData; + + $postData = [ + 'id' => 1, + 'title' => 'PHP is great', + 'content' => faker()->sentence(), + 'user_id' => $userData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $postCollection[] = $postData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($postCollection)), + new Statement(new Result($userCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var Post $post */ + $post = Post::query()->selectAllColumns() + ->with('user:id,name') + ->first(); + + expect($post)->toBeInstanceOf(Post::class); + + expect($post->id)->toBe($postData['id']); + expect($post->title)->toBe($postData['title']); + expect($post->content)->toBe($postData['content']); + expect($post->createdAt)->toBeInstanceOf(Date::class); + expect($post->updatedAt)->toBeNull(); + + expect($post->user)->toBeInstanceOf(User::class); + + expect($post->user->id)->toBe($userData['id']); + expect($post->user->name)->toBe($userData['name']); + expect(isset($post->user->email))->toBeFalse(); +}); + +it('loads relationship when the model belongs to a parent model with column selection', function () { + $userData = [ + 'id' => 1, + 'name' => 'John Doe', + ]; + + $userCollection[] = $userData; + + $postData = [ + 'id' => 1, + 'title' => 'PHP is great', + 'content' => faker()->sentence(), + 'user_id' => $userData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $postCollection[] = $postData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($postCollection)), + new Statement(new Result($userCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var Post $post */ + $post = Post::query()->selectAllColumns() + ->with([ + 'user' => function (BelongsTo $belongsTo) { + $belongsTo->query() + ->select(['id', 'name']); + }, + ]) + ->first(); + + expect($post)->toBeInstanceOf(Post::class); + + expect($post->id)->toBe($postData['id']); + expect($post->title)->toBe($postData['title']); + expect($post->content)->toBe($postData['content']); + expect($post->createdAt)->toBeInstanceOf(Date::class); + expect($post->updatedAt)->toBeNull(); + + expect($post->user)->toBeInstanceOf(User::class); + + expect($post->user->id)->toBe($userData['id']); + expect($post->user->name)->toBe($userData['name']); + expect(isset($post->user->email))->toBeFalse(); +}); + +it('loads relationship when the model has many child models without chaperone', function () { + $userData = [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $userCollection[] = $userData; + + $productData = [ + 'id' => 1, + 'description' => 'Phenix shirt', + 'price' => 100, + 'stock' => 6, + 'user_id' => $userData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $productCollection[] = $productData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($userCollection)), + new Statement(new Result($productCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var User $user */ + $user = User::query() + ->selectAllColumns() + ->whereEqual('id', 1) + ->with(['products']) + ->first(); + + expect($user)->toBeInstanceOf(User::class); + + expect($user->id)->toBe($userData['id']); + expect($user->name)->toBe($userData['name']); + expect($user->email)->toBe($userData['email']); + + expect($user->products)->toBeInstanceOf(Collection::class); + expect($user->products->count())->toBe(1); + + /** @var Product $products */ + $product = $user->products->first(); + + expect($product->id)->toBe($productData['id']); + expect($product->description)->toBe($productData['description']); + expect($product->price)->toBe((float) $productData['price']); + expect($product->createdAt)->toBeInstanceOf(Date::class); + expect($product->userId)->toBe($userData['id']); + + expect(isset($product->user))->toBeFalse(); +}); + +it('loads relationship when the model has many child models loading chaperone from relationship method', function () { + $userData = [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $userCollection[] = $userData; + + $productData = [ + 'id' => 1, + 'description' => 'Phenix shirt', + 'price' => 100, + 'stock' => 6, + 'user_id' => $userData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $productCollection[] = $productData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($userCollection)), + new Statement(new Result($productCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var User $user */ + $user = User::query() + ->selectAllColumns() + ->whereEqual('id', 1) + ->with([ + 'products' => function (HasMany $hasMany): void { + $hasMany->withChaperone(); + }, + ]) + ->first(); + + expect($user)->toBeInstanceOf(User::class); + + expect($user->id)->toBe($userData['id']); + expect($user->name)->toBe($userData['name']); + expect($user->email)->toBe($userData['email']); + + expect($user->products)->toBeInstanceOf(Collection::class); + expect($user->products->count())->toBe(1); + + /** @var Product $products */ + $product = $user->products->first(); + + expect($product->id)->toBe($productData['id']); + expect($product->description)->toBe($productData['description']); + expect($product->price)->toBe((float) $productData['price']); + expect($product->createdAt)->toBeInstanceOf(Date::class); + expect($product->userId)->toBe($userData['id']); + + expect(isset($product->user))->toBeTrue(); + expect($product->user->id)->toBe($userData['id']); + + $userData['createdAt'] = Date::parse($userData['created_at'])->toIso8601String(); + unset($userData['created_at']); + $userData['updatedAt'] = null; + + $productData['createdAt'] = Date::parse($productData['created_at'])->toIso8601String(); + $productData['updatedAt'] = null; + $productData['price'] = (float) $productData['price']; + $productData['userId'] = $userData['id']; + unset($productData['user_id'], $productData['created_at']); + $productData['user'] = $userData; + + $userData['products'][] = $productData; + $output = $user->toArray(); + + ksort($output['products'][0]); + ksort($userData['products'][0]); + + expect($output)->toBe($userData); +}); + +it('loads relationship when the model has many child models loading chaperone by default', function () { + $userData = [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $userCollection[] = $userData; + + $commentData = [ + 'id' => 1, + 'content' => 'PHP is awesome', + 'user_id' => $userData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $commentCollection[] = $commentData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($userCollection)), + new Statement(new Result($commentCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var User $user */ + $user = User::query() + ->selectAllColumns() + ->whereEqual('id', 1) + ->with(['comments']) + ->first(); + + expect($user)->toBeInstanceOf(User::class); + + expect($user->id)->toBe($userData['id']); + expect($user->name)->toBe($userData['name']); + expect($user->email)->toBe($userData['email']); + + expect($user->comments)->toBeInstanceOf(Collection::class); + expect($user->comments->count())->toBe(1); + + /** @var Comment $comments */ + $comment = $user->comments->first(); + + expect($comment->id)->toBe($commentData['id']); + expect($comment->content)->toBe($commentData['content']); + expect($comment->createdAt)->toBeInstanceOf(Date::class); + expect($comment->userId)->toBe($userData['id']); + + expect(isset($comment->user))->toBeTrue(); + expect($comment->user->id)->toBe($userData['id']); +}); + +it('loads relationship when the model belongs to many models', function () { + $invoiceData = [ + 'id' => 20, + 'reference' => '1234', + 'value' => 100.0, + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $invoiceCollection[] = $invoiceData; + + $productData = [ + 'id' => 122, + 'description' => 'PHP Plush', + 'price' => 50.0, + 'created_at' => Date::now()->toDateTimeString(), + 'pivot_product_id' => 122, + 'pivot_invoice_id' => 20, + ]; + + $productCollection[] = $productData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($invoiceCollection)), + new Statement(new Result($productCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var Collection $invoices */ + $invoices = Invoice::query() + ->with(['products']) + ->get(); + + expect($invoices)->toBeInstanceOf(Collection::class); + expect($invoices->count())->toBe(1); + + expect($invoices->first()->id)->toBe($invoiceData['id']); + expect($invoices->first()->reference)->toBe($invoiceData['reference']); + expect($invoices->first()->value)->toBe($invoiceData['value']); + + expect($invoices->first()->products)->toBeInstanceOf(Collection::class); + expect($invoices->first()->products->count())->toBe(1); + + /** @var Product $product */ + $product = $invoices->first()->products->first(); + + expect($product->id)->toBe($productData['id']); + expect($product->description)->toBe($productData['description']); + expect($product->price)->toBe($productData['price']); + expect($product->createdAt)->toBeInstanceOf(Date::class); + expect($product->pivot)->toBeInstanceOf(stdClass::class); + expect($product->pivot->product_id)->toBe(122); + expect($product->pivot->invoice_id)->toBe(20); +}); + +it('loads relationship when the model belongs to many models with pivot columns', function () { + $invoiceData = [ + 'id' => 20, + 'reference' => '1234', + 'value' => 100.0, + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $invoiceCollection[] = $invoiceData; + + $productData = [ + 'id' => 122, + 'description' => 'PHP Plush', + 'price' => 50.0, + 'created_at' => Date::now()->toDateTimeString(), + 'pivot_product_id' => 122, + 'pivot_invoice_id' => 20, + 'pivot_quantity' => 2, + 'pivot_value' => 100.0, + ]; + + $productCollection[] = $productData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($invoiceCollection)), + new Statement(new Result($productCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var Collection $invoices */ + $invoices = Invoice::query() + ->with([ + 'products' => function (BelongsToMany $relation) { + $relation->withPivot(['quantity', 'value']); + }, + ]) + ->get(); + + expect($invoices)->toBeInstanceOf(Collection::class); + expect($invoices->count())->toBe(1); + + expect($invoices->first()->id)->toBe($invoiceData['id']); + expect($invoices->first()->reference)->toBe($invoiceData['reference']); + expect($invoices->first()->value)->toBe($invoiceData['value']); + + expect($invoices->first()->products)->toBeInstanceOf(Collection::class); + expect($invoices->first()->products->count())->toBe(1); + + /** @var Product $product */ + $product = $invoices->first()->products->first(); + + expect($product->id)->toBe($productData['id']); + expect($product->description)->toBe($productData['description']); + expect($product->price)->toBe($productData['price']); + expect($product->createdAt)->toBeInstanceOf(Date::class); + expect($product->pivot)->toBeInstanceOf(stdClass::class); + expect($product->pivot->product_id)->toBe(122); + expect($product->pivot->invoice_id)->toBe(20); + expect($product->pivot->quantity)->toBe(2); + expect($product->pivot->value)->toBe(100.0); +}); + +it('loads relationship when the model belongs to many models with column selection', function () { + $invoiceData = [ + 'id' => 20, + 'reference' => '1234', + 'value' => 100.0, + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $invoiceCollection[] = $invoiceData; + + $productData = [ + 'id' => 122, + 'description' => 'PHP Plush', + 'pivot_product_id' => 122, + 'pivot_invoice_id' => 20, + 'pivot_quantity' => 2, + 'pivot_value' => 100.0, + ]; + + $productCollection[] = $productData; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($invoiceCollection)), + new Statement(new Result($productCollection)), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var Collection $invoices */ + $invoices = Invoice::query() + ->with([ + 'products' => function (BelongsToMany $relation) { + $relation->withPivot(['quantity', 'value']) + ->query() + ->select(['id', 'description']); + }, + ]) + ->get(); + + expect($invoices)->toBeInstanceOf(Collection::class); + expect($invoices->count())->toBe(1); + + expect($invoices->first()->id)->toBe($invoiceData['id']); + expect($invoices->first()->reference)->toBe($invoiceData['reference']); + expect($invoices->first()->value)->toBe($invoiceData['value']); + + expect($invoices->first()->products)->toBeInstanceOf(Collection::class); + expect($invoices->first()->products->count())->toBe(1); + + /** @var Product $product */ + $product = $invoices->first()->products->first(); + + expect($product->id)->toBe($productData['id']); + expect($product->description)->toBe($productData['description']); + expect(isset($product->price))->toBeFalse(); + expect(isset($product->createdAt))->toBeFalse(); + expect($product->pivot)->toBeInstanceOf(stdClass::class); + expect($product->pivot->product_id)->toBe(122); + expect($product->pivot->invoice_id)->toBe(20); + expect($product->pivot->quantity)->toBe(2); + expect($product->pivot->value)->toBe(100.0); +}); + +it('loads nested relationship using dot notation', function () { + $userData = [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $productData = [ + 'id' => 1, + 'description' => 'Phenix shirt', + 'price' => 100, + 'stock' => 6, + 'user_id' => $userData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $commentData = [ + 'id' => 1, + 'content' => 'PHP is awesome', + 'product_id' => $productData['id'], + 'created_at' => Date::now()->toDateTimeString(), + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(3)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([$commentData])), + new Statement(new Result([$productData])), + new Statement(new Result([$userData])), + ); + + $this->app->swap(Connections::default(), $connection); + + /** @var Comment $comment */ + $comment = Comment::query() + ->with([ + 'product:id,description,price,stock,user_id,created_at', + 'product.user:id,name,email,created_at', + ]) + ->first(); + + expect($comment)->toBeInstanceOf(DatabaseModel::class); + + expect($comment->id)->toBe($commentData['id']); + expect($comment->content)->toBe($commentData['content']); + expect($comment->createdAt)->toBeInstanceOf(Date::class); + expect($comment->productId)->toBe($productData['id']); + + expect($comment->product)->toBeInstanceOf(DatabaseModel::class); + expect($comment->product->id)->toBe($productData['id']); + expect($comment->product->description)->toBe($productData['description']); + + expect($comment->product->user)->toBeInstanceOf(DatabaseModel::class); + expect($comment->product->user->id)->toBe($userData['id']); + expect($comment->product->user->name)->toBe($userData['name']); + expect($comment->product->user->email)->toBe($userData['email']); +}); + +it('dispatches error on unknown column', function () { + expect(function () { + $data = [ + [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john.doe@email.com', + 'created_at' => Date::now()->toDateTimeString(), + 'unknown_column' => 'unknown', + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($data)), + ); + + $this->app->swap(Connections::default(), $connection); + + User::query()->selectAllColumns()->get(); + })->toThrow( + ModelException::class, + "Unknown column 'unknown_column' for model " . User::class, + ); +}); + +it('dispatches error on unknown relationship', function () { + expect(function () { + Post::query()->selectAllColumns() + ->with('company') + ->first(); + })->toThrow( + ModelException::class, + "Undefined relationship company for " . Post::class, + ); +}); diff --git a/tests/Feature/Database/Models/Comment.php b/tests/Feature/Database/Models/Comment.php new file mode 100644 index 00000000..b6352114 --- /dev/null +++ b/tests/Feature/Database/Models/Comment.php @@ -0,0 +1,45 @@ + 1, 'name' => 'John Doe'], + ]; + + $this->app->swap(Connections::name('mysql'), MysqlConnectionPool::fake($data)); + + $queryBuilder = new DatabaseQueryBuilder(); + $queryBuilder->connection('mysql'); + $queryBuilder->setModel(new User()); + + $result = $queryBuilder->get()->toArray(); + + expect($result[0]['id'])->toBe($data[0]['id']); +}); diff --git a/tests/Feature/Database/Models/User.php b/tests/Feature/Database/Models/User.php new file mode 100644 index 00000000..ab85847d --- /dev/null +++ b/tests/Feature/Database/Models/User.php @@ -0,0 +1,41 @@ +toBeInstanceOf(Collection::class); expect($collection->isEmpty())->toBe(false); }); + +it('returns first element', function () { + $collection = Collection::fromArray([['name' => 'John'], ['name' => 'Jane']]); + + expect($collection->first())->toBe(['name' => 'John']); +}); + +it('returns null when collection is empty', function () { + $collection = new Collection('array'); + + expect($collection->first())->toBeNull(); +}); diff --git a/tests/Unit/Database/Models/CollectionTest.php b/tests/Unit/Database/Models/CollectionTest.php new file mode 100644 index 00000000..0cd7caec --- /dev/null +++ b/tests/Unit/Database/Models/CollectionTest.php @@ -0,0 +1,52 @@ +id = 1; + $product1->description = 'Product 1'; + $product1->price = 10.0; + $product1->stock = 100; + $product1->userId = 1; + $product1->createdAt = new Date('2023-01-01'); + $product1->updatedAt = new Date('2023-01-02'); + + $product2 = new Product(); + $product2->id = 2; + $product2->description = 'Product 2'; + $product2->price = 20.0; + $product2->stock = 200; + $product2->userId = 2; + $product2->createdAt = new Date('2023-01-03'); + $product2->updatedAt = new Date('2023-01-04'); + + $collection = new Collection([$product1, $product2]); + + $expected = [ + [ + 'id' => 1, + 'description' => 'Product 1', + 'price' => 10.0, + 'stock' => 100, + 'userId' => 1, + 'createdAt' => '2023-01-01T00:00:00+00:00', + 'updatedAt' => '2023-01-02T00:00:00+00:00', + ], + [ + 'id' => 2, + 'description' => 'Product 2', + 'price' => 20.0, + 'stock' => 200, + 'userId' => 2, + 'createdAt' => '2023-01-03T00:00:00+00:00', + 'updatedAt' => '2023-01-04T00:00:00+00:00', + ], + ]; + + expect($collection->toArray())->toBe($expected); +}); diff --git a/tests/Unit/Database/Models/Properties/Json.php b/tests/Unit/Database/Models/Properties/Json.php new file mode 100644 index 00000000..9732ecdd --- /dev/null +++ b/tests/Unit/Database/Models/Properties/Json.php @@ -0,0 +1,20 @@ +data = json_decode($data, true); + } + + public function getData(): array + { + return $this->data; + } +} diff --git a/tests/Unit/Database/Models/Properties/ModelPropertyTest.php b/tests/Unit/Database/Models/Properties/ModelPropertyTest.php new file mode 100644 index 00000000..7c605eac --- /dev/null +++ b/tests/Unit/Database/Models/Properties/ModelPropertyTest.php @@ -0,0 +1,46 @@ +resolveInstance())->toBeInstanceOf(Json::class); +}); + +it('gets null when value is nullable', function () { + $property = new ModelProperty( + 'data', + '?' . Json::class, + true, + new Column(name: 'data'), + null + ); + + expect($property->resolveInstance())->toBeNull(); + expect($property->getValue())->toBeNull(); +}); + +it('gets null when date is nullable', function () { + $property = new ModelProperty( + 'date', + '?' . Date::class, + true, + new Column(name: 'date'), + null + ); + + expect($property->resolveInstance())->toBeNull(); + expect($property->getValue())->toBeNull(); +}); diff --git a/tests/Unit/Database/Models/Relationships/BelongsToManyTest.php b/tests/Unit/Database/Models/Relationships/BelongsToManyTest.php new file mode 100644 index 00000000..7ed0e590 --- /dev/null +++ b/tests/Unit/Database/Models/Relationships/BelongsToManyTest.php @@ -0,0 +1,35 @@ +withPivot(['amount', 'value']); + + expect($relationship->getColumns())->toBe([ + 'invoice_product.invoice_id' => 'pivot_invoice_id', + 'invoice_product.product_id' => 'pivot_product_id', + 'invoice_product.amount' => 'pivot_amount', + 'invoice_product.value' => 'pivot_value', + ]); +}); diff --git a/tests/Unit/Database/Models/Relationships/RelationshipParserTest.php b/tests/Unit/Database/Models/Relationships/RelationshipParserTest.php new file mode 100644 index 00000000..d4731066 --- /dev/null +++ b/tests/Unit/Database/Models/Relationships/RelationshipParserTest.php @@ -0,0 +1,118 @@ +parse(); + + expect($parser->toArray())->toBe([ + 'user' => [ + 'columns' => ['*'], + 'relationships' => [], + ], + ]); +}); + +it('parse single relationship with closure', function () { + $closure = function (BelongsTo $belongsTo) { + $belongsTo->query() + ->select(['id', 'name']); + }; + + $parser = new RelationshipParser([ + 'user' => $closure, + ]); + + $parser->parse(); + + expect($parser->toArray())->toBe([ + 'user' => [ + 'columns' => $closure, + 'relationships' => [], + ], + ]); +}); + +it('parse multiple relationships', function () { + $parser = new RelationshipParser([ + 'user', + 'posts', + ]); + + $parser->parse(); + + expect($parser->toArray())->toBe([ + 'user' => [ + 'columns' => ['*'], + 'relationships' => [], + ], + 'posts' => [ + 'columns' => ['*'], + 'relationships' => [], + ], + ]); +}); + +it('parse relationships with dot notation in second level', function () { + $parser = new RelationshipParser([ + 'user.company', + ]); + + $parser->parse(); + + expect($parser->toArray())->toBe([ + 'user' => [ + 'columns' => ['*'], + 'relationships' => [ + 'company', + ], + ], + ]); +}); + +it('parse relationships with dot notation in nested level', function () { + $parser = new RelationshipParser([ + 'user', + 'user.company', + 'user.company.account', + ]); + + $parser->parse(); + + expect($parser->toArray())->toBe([ + 'user' => [ + 'columns' => ['*'], + 'relationships' => [ + 'company', + 'company.account', + ], + ], + ]); +}); + +it('parse relationships with dot notation in nested level with column selection', function () { + $parser = new RelationshipParser([ + 'user:id,name,company_id', + 'user.company:id,name,account_id', + 'user.company.account:id,name', + ]); + + $parser->parse(); + + expect($parser->toArray())->toBe([ + 'user' => [ + 'columns' => ['id', 'name', 'company_id'], + 'relationships' => [ + 'company:id,name,account_id', + 'company.account:id,name', + ], + ], + ]); +}); diff --git a/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php b/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php index f2b60eaf..4655de36 100644 --- a/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php +++ b/tests/Unit/Database/QueryGenerator/SelectColumnsTest.php @@ -160,6 +160,24 @@ expect($params)->toBeEmpty(); }); +it('generates query with many column alias', function () { + $query = new QueryGenerator(); + + $sql = $query->select([ + 'id' => 'model_id', + 'name' => 'full_name', + ]) + ->from('users') + ->get(); + + [$dml, $params] = $sql; + + $expected = "SELECT id AS model_id, name AS full_name FROM users"; + + expect($dml)->toBe($expected); + expect($params)->toBeEmpty(); +}); + it('generates query with select-cases using comparisons', function ( string $method, array $data, diff --git a/tests/Unit/Util/ArrTest.php b/tests/Unit/Util/ArrTest.php new file mode 100644 index 00000000..ca5b471c --- /dev/null +++ b/tests/Unit/Util/ArrTest.php @@ -0,0 +1,122 @@ + 'John', + 'age' => 30, + 'address' => [ + 'city' => 'New York', + 'zip' => '10001', + ], + ]; + expect(Arr::get($array, 'name'))->toBe('John'); + expect(Arr::get($array, 'age'))->toBe(30); + expect(Arr::get($array, 'address.city'))->toBe('New York'); + expect(Arr::get($array, 'nonexistent_key'))->toBeNull(); + expect(Arr::get($array, 'nonexistent_key.dotted'))->toBeNull(); + expect(Arr::get($array, null))->toBe($array); +}); + +it('can set a value in an array', function () { + $array = ['name' => 'John']; + Arr::set($array, 'age', 30); + expect($array['age'])->toBe(30); +}); + +it('can check if an array has a key', function () { + $array = [ + 'name' => 'John', + 'age' => 30, + 'address' => [ + 'city' => 'New York', + 'zip' => '10001', + ], + ]; + expect(Arr::has($array, 'name'))->toBeTrue(); + expect(Arr::has($array, 'nonexistent_key'))->toBeFalse(); + expect(Arr::has($array, []))->toBeFalse(); + expect(Arr::has($array, 'address.city'))->toBeTrue(); + +}); + +it('can undot an array', function () { + $array = [ + 'user.name' => 'John', + 'user.age' => 30, + 'address.city' => 'New York', + 'address.zip' => '10001', + ]; + $expected = [ + 'user' => [ + 'name' => 'John', + 'age' => 30, + ], + 'address' => [ + 'city' => 'New York', + 'zip' => '10001', + ], + ]; + expect(Arr::undot($array))->toBe($expected); +}); + +it('can get the first value from an array', function () { + $array = [10, 20, 30, 40]; + expect(Arr::first($array))->toBe(10); + + $array = ['name' => 'John', 'age' => 30]; + expect(Arr::first($array))->toBe('John'); + + $array = []; + expect(Arr::first($array))->toBeNull(); +}); + +it('can wrap a value in an array', function () { + expect(Arr::wrap('John'))->toBe(['John']); + expect(Arr::wrap(['John']))->toBe(['John']); + expect(Arr::wrap(null))->toBe([]); + expect(Arr::wrap(['name' => 'John']))->toBe(['name' => 'John']); +}); + +it('can check if a key exists in an array', function () { + $array = ['name' => 'John', 'age' => 30]; + + expect(Arr::exists($array, 'name'))->toBeTrue(); + expect(Arr::exists($array, 'nonexistent_key'))->toBeFalse(); + expect(Arr::exists($array, 'age'))->toBeTrue(); + + $arrClass = new class () implements ArrayAccess { + private $container = []; + + public function offsetSet($offset, $value): void + { + if ($offset === null) { + $this->container[] = $value; + } else { + $this->container[$offset] = $value; + } + } + + public function offsetExists($offset): bool + { + return isset($this->container[$offset]); + } + + public function offsetUnset($offset): void + { + unset($this->container[$offset]); + } + + public function offsetGet($offset): string|null + { + return isset($this->container[$offset]) ? $this->container[$offset] : null; + } + }; + + $arrClass['name'] = 'John'; + + expect(Arr::exists($arrClass, 'name'))->toBeTrue(); +});