Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
675 changes: 675 additions & 0 deletions phpunit.out

Large diffs are not rendered by default.

12 changes: 0 additions & 12 deletions resources/js/App.jsx

This file was deleted.

26 changes: 22 additions & 4 deletions src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -181,12 +181,30 @@ protected function resolveParameter(ReflectionParameter $param): mixed

$type = $param->getType();

// If no type or built-in type without default, we can't resolve it
if (! $type || $type->isBuiltin()) {
throw new \RuntimeException("Cannot resolve parameter {$param->getName()} of class " . ($param->getDeclaringClass() ? $param->getDeclaringClass()->getName() : 'unknown'));
// Named, non-union type
if ($type instanceof \ReflectionNamedType) {
// class / interface type -> let container build
if (! $type->isBuiltin()) {
return $this->make($type->getName());
}

// Built-in types (lenient for array/iterable)
$builtin = $type->getName();

if ($builtin === 'array' || $builtin === 'iterable') {
return [];
}
}

return $this->make($type->getName());
$owner = $param->getDeclaringClass()->getName() ?? 'unknown';

throw new \RuntimeException(
sprintf(
'Cannot resolve parameter $%s of %s::__construct(). Either give it a default value or bind it explicitly to the container.',
$param->getName(),
$owner
)
);
}

public function registerProviders(array $providers): void
Expand Down
6 changes: 4 additions & 2 deletions src/Auth/Auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ public static function check(): bool
*/
public static function id(): ?int
{
return Session::get(self::SESSION_KEY);
$userId = Session::get(self::SESSION_KEY);
return $userId ? (int)$userId : null;
}

/**
Expand All @@ -50,7 +51,8 @@ public static function id(): ?int
public static function login(User $user): void
{
Session::regenerate();
Session::set(self::SESSION_KEY, (int)$user->getAttribute('id'));
$userId = $user->id ?? $user->getAttribute('id');
Session::set(self::SESSION_KEY, $userId ? (int)$userId : null);
}

/**
Expand Down
103 changes: 95 additions & 8 deletions src/Database/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ class Builder
*/
protected array $orders = [];

/**
* Columns that are allowed to be used in ORDER BY clauses.
*
* If empty, a conservative identifier pattern will be used instead.
*
* @var string[]
*/
protected array $allowedOrderColumns = [];

/**
* Columns that are safe to sort on via orderBy().
*
* @var string[] $sortable
*/
protected array $sortable = [];

protected ?int $limit = null;
protected ?int $offset = null;

Expand All @@ -40,7 +56,7 @@ public function __construct(PDO $pdo, string $table, ?string $modelClass = null,
$this->pdo = $pdo;
$this->table = $table;
$this->modelClass = $modelClass;

// Store connection for driver access
// If not provided, try to get it from a static connection if available
if ($connection === null) {
Expand All @@ -64,7 +80,7 @@ protected function createConnectionFromPdo(PDO $pdo): Connection
$pdoProperty = $connection->getProperty('pdo');
$pdoProperty->setAccessible(true);
$pdoProperty->setValue($instance, $pdo);

return $instance;
}

Expand Down Expand Up @@ -95,7 +111,7 @@ public function __call(string $method, array $parameters): self

/**
* Specify relationships to eager load.
*
*
* Example:
* User::query()->with('posts', 'profile')->get()
* User::query()->with(['posts', 'profile'])->get()
Expand Down Expand Up @@ -146,20 +162,44 @@ public function orWhere(string $column, string $operator, mixed $value = null):

public function whereIn(string $column, array $values): self
{
$this->wheres[] = ['AND', $column, 'IN', $values];
// empty values for IN, condition should match nothing but sql must still be valid
if (empty($values)) {
$this->wheres[] = ['AND', '1 = 0', 'RAW', null];

return $this;
}


$this->wheres[] = ['AND', $column, 'IN', array_values($values)];
return $this;
}

public function whereNotIn(string $column, array $values): self
{
$this->wheres[] = ['AND', $column, 'NOT IN', $values];
// Empty NOT IN matches everything (no restrictions).
if (empty($values)) {
// we can safely ignore it
return $this;
}
$this->wheres[] = ['AND', $column, 'NOT IN', array_values($values)];
return $this;
}

public function whereRaw(string $sql, string $boolean = 'AND'): self
{
$this->wheres[] = [$boolean, $sql, 'RAW', null];

return $this;
}



public function orderBy(string $column, string $direction = 'ASC'): self
{
if (! $this->isAllowedOrderColumn($column)) {
throw new \InvalidArgumentException("Invalid order by column: {$column}");
}

$direction = strtoupper($direction);
if (!in_array($direction, ['ASC', 'DESC'], true)) {
$direction = 'ASC';
Expand All @@ -181,11 +221,58 @@ public function offset(int $offset): self
return $this;
}

/**
* Determine if the given column is allowed in ORDER BY clauses.
*
* @param string $column
* @return bool
*/
protected function isAllowedOrderColumn(string $column): bool
{
// If an explicit allowlist has been set, enforce it strictly.
if (!empty($this->allowedOrderColumns)) {
return in_array($column, $this->allowedOrderColumns, true);
}

// Fallback: allow only simple identifiers (no spaces, commas, operators, etc.)
// This blocks payloads like "name; DROP TABLE users" or "name DESC, (SELECT ...)".
return (bool) preg_match('/^[A-Za-z0-9_]+$/', $column);
}

/**
* Optionally set an explicit allowlist of sortable columns.
*
* @param string[] $columns
* @return $this
*/
public function setAllowedOrderColumns(array $columns): self
{
$this->allowedOrderColumns = array_values(array_unique($columns));

return $this;
}


/**
* Get new rows for the current query without model hydration.
*
* @return array<int, array<string, mixed>>
*/
public function getRows(): array
{
[$sql, $bindings] = $this->compileSelect();

$stmt = $this->pdo->prepare($sql);
$stmt->execute($bindings);

return $stmt->fetchAll();
}

protected function compileSelect(): array
{
$driver = $this->connection->getDriver();
$quotedTable = $driver->quoteIdentifier($this->table);

$sql = 'SELECT * FROM ' . $quotedTable;
$bindings = [];

Expand Down Expand Up @@ -257,5 +344,5 @@ public function toSql(): string
return $sql;
}


}
126 changes: 126 additions & 0 deletions src/Database/Entity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

namespace BareMetalPHP\Database;

/**
* Base class for Data Mapper entities.
*/
abstract class Entity
{
/**
* Original attribute values as loaded from the DB.
*
* @var array<array,mixed> $original
*/
protected array $original = [];

/**
* Attributers that have been modified since laad/flush.
*
* @var array>string,mixed>
*/
protected array $dirty = [];

/**
* Mark this entity as having been loaded from a given row.
*
* @param array $row
* @return void
*/
public function hydrateFromRow(array $row): void
{
foreach ($row as $key => $value) {
// Assign directly; subclasse can override for casting if needed.
$this->{$key} = $value;
}

$this->original = $row;
$this->dirty = [];
}

/**
* Called by setters / property hooks to mark a field dirty.
*
* @param string $field
* @param mixed $value
* @return void
*/
protected function trackDirty(string $field, mixed $value): void
{
$this->dirty[$field] = $value;
}

/**
* Get all dirty attributes.
*
* @return array<string,mixed>
*/
public function getDirty(): array
{
return $this->dirty;
}

/**
* Get all original attributes as last loaded from the database.
*
* @return array<string,mixed>
*/
public function getOriginal(): array
{
return $this->original;
}

/**
* Mark the entity as clean (after a successful insert/update).
*
* @return void
*/
public function markClean(): void
{
$data = $this->toArray();
$this->original = $data;
$this->dirty = [];
}

/**
* Determine if the entity represents a new row.
*
* @return bool
*/
public function isNew(): bool
{
$pk = static::primaryKey();
return !isset($this->{$pk});
}

/**
* Get the entity attributes as an array.
*
* Default behavior uses geT_object_vars() and strip internal properties.
*
* @return array<string,mixed>
*/
public function toArray(): array
{
$data = get_object_vars($this);

unset($data['original'], $data['dirty']);

return $data;
}

/**
* Get the database table name for this entity.
*
* @return string
*/
abstract public static function table(): string;

/**
* Get the primary key column for this entity.
*
* @return string
*/
abstract public static function primaryKey(): string;

}
Loading
Loading