🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
535 lines
17 KiB
PHP
Executable File
535 lines
17 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Database;
|
|
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use App\RSpade\Core\Database\TypeRefs\Type_Ref_Registry;
|
|
|
|
/**
|
|
* Custom Eloquent query builder that prevents eager loading and unsafe operations
|
|
*
|
|
* This builder overrides dangerous methods to enforce RSpade framework safety rules:
|
|
* - All eager loading methods throw exceptions (with/withCount/etc)
|
|
* - DELETE without WHERE clause throws exception to prevent accidental mass deletion
|
|
* - Automatic type_ref column conversion in WHERE clauses
|
|
*/
|
|
class RestrictedEloquentBuilder extends Builder
|
|
{
|
|
/**
|
|
* Cached list of type_ref columns for this builder's model
|
|
* @var array|null
|
|
*/
|
|
protected $_type_ref_columns_cache = null;
|
|
|
|
/**
|
|
* Get the type_ref columns for the current model
|
|
*
|
|
* @return array
|
|
*/
|
|
protected function _get_type_ref_columns(): array
|
|
{
|
|
if ($this->_type_ref_columns_cache === null) {
|
|
$model = $this->getModel();
|
|
$reflection = new \ReflectionClass($model);
|
|
|
|
// Access protected static property
|
|
if ($reflection->hasProperty('type_ref_columns')) {
|
|
$property = $reflection->getProperty('type_ref_columns');
|
|
$property->setAccessible(true);
|
|
$this->_type_ref_columns_cache = $property->getValue() ?: [];
|
|
} else {
|
|
$this->_type_ref_columns_cache = [];
|
|
}
|
|
}
|
|
|
|
return $this->_type_ref_columns_cache;
|
|
}
|
|
|
|
/**
|
|
* Check if a column is a type_ref column
|
|
*
|
|
* @param string $column
|
|
* @return bool
|
|
*/
|
|
protected function _is_type_ref_column(string $column): bool
|
|
{
|
|
return in_array($column, $this->_get_type_ref_columns());
|
|
}
|
|
|
|
/**
|
|
* Convert a type_ref value (class name string) to its integer ID
|
|
*
|
|
* @param mixed $value
|
|
* @return mixed
|
|
*/
|
|
protected function _convert_type_ref_value($value)
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return $value;
|
|
}
|
|
|
|
// If already an integer, return as-is
|
|
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
|
|
return (int) $value;
|
|
}
|
|
|
|
// Convert class name to ID
|
|
return Type_Ref_Registry::class_to_id((string) $value);
|
|
}
|
|
|
|
/**
|
|
* Override where() to auto-convert type_ref columns
|
|
*
|
|
* @param \Closure|string|array $column
|
|
* @param mixed $operator
|
|
* @param mixed $value
|
|
* @param string $boolean
|
|
* @return $this
|
|
*/
|
|
public function where($column, $operator = null, $value = null, $boolean = 'and')
|
|
{
|
|
// Handle closure form - no conversion needed
|
|
if ($column instanceof \Closure) {
|
|
return parent::where($column, $operator, $value, $boolean);
|
|
}
|
|
|
|
// Handle array form: ['column' => 'value']
|
|
if (is_array($column)) {
|
|
$converted = [];
|
|
foreach ($column as $col => $val) {
|
|
if (is_string($col) && $this->_is_type_ref_column($col)) {
|
|
$converted[$col] = $this->_convert_type_ref_value($val);
|
|
} else {
|
|
$converted[$col] = $val;
|
|
}
|
|
}
|
|
return parent::where($converted, $operator, $value, $boolean);
|
|
}
|
|
|
|
// Handle 2-arg form: where('column', 'value') -> operator is actually value
|
|
// Handle 3-arg form: where('column', '=', 'value')
|
|
if (is_string($column) && $this->_is_type_ref_column($column)) {
|
|
if (func_num_args() === 2) {
|
|
// 2-arg form: $operator is actually the value
|
|
$operator = $this->_convert_type_ref_value($operator);
|
|
} else {
|
|
// 3+ arg form: $value is the value
|
|
$value = $this->_convert_type_ref_value($value);
|
|
}
|
|
}
|
|
|
|
return parent::where($column, $operator, $value, $boolean);
|
|
}
|
|
|
|
/**
|
|
* Override orWhere() to auto-convert type_ref columns
|
|
*
|
|
* @param \Closure|string|array $column
|
|
* @param mixed $operator
|
|
* @param mixed $value
|
|
* @return $this
|
|
*/
|
|
public function orWhere($column, $operator = null, $value = null)
|
|
{
|
|
return $this->where($column, $operator, $value, 'or');
|
|
}
|
|
|
|
/**
|
|
* Override whereIn() to auto-convert type_ref columns
|
|
*
|
|
* @param string $column
|
|
* @param mixed $values
|
|
* @param string $boolean
|
|
* @param bool $not
|
|
* @return $this
|
|
*/
|
|
public function whereIn($column, $values, $boolean = 'and', $not = false)
|
|
{
|
|
if (is_string($column) && $this->_is_type_ref_column($column)) {
|
|
if (is_array($values)) {
|
|
$values = array_map(fn($v) => $this->_convert_type_ref_value($v), $values);
|
|
}
|
|
}
|
|
|
|
return parent::whereIn($column, $values, $boolean, $not);
|
|
}
|
|
|
|
/**
|
|
* Override orWhereIn() to auto-convert type_ref columns
|
|
*
|
|
* @param string $column
|
|
* @param mixed $values
|
|
* @return $this
|
|
*/
|
|
public function orWhereIn($column, $values)
|
|
{
|
|
return $this->whereIn($column, $values, 'or');
|
|
}
|
|
|
|
/**
|
|
* Override whereNotIn() to auto-convert type_ref columns
|
|
*
|
|
* @param string $column
|
|
* @param mixed $values
|
|
* @param string $boolean
|
|
* @return $this
|
|
*/
|
|
public function whereNotIn($column, $values, $boolean = 'and')
|
|
{
|
|
return $this->whereIn($column, $values, $boolean, true);
|
|
}
|
|
|
|
/**
|
|
* Override orWhereNotIn() to auto-convert type_ref columns
|
|
*
|
|
* @param string $column
|
|
* @param mixed $values
|
|
* @return $this
|
|
*/
|
|
public function orWhereNotIn($column, $values)
|
|
{
|
|
return $this->whereIn($column, $values, 'or', true);
|
|
}
|
|
|
|
/**
|
|
* ==========================================
|
|
* POLYMORPHIC JOIN HELPERS
|
|
* ==========================================
|
|
*/
|
|
|
|
/**
|
|
* Add a polymorphic INNER JOIN to the query
|
|
*
|
|
* Joins a table with polymorphic columns (e.g., fileable_type, fileable_id)
|
|
* to the current model's table, automatically handling type_ref conversion.
|
|
*
|
|
* @param string $table The table containing the polymorphic columns
|
|
* @param string $morphName The morph column prefix (e.g., 'fileable' for fileable_type/fileable_id)
|
|
* @param string|null $morphClass The class name to match. If null, uses current model's class.
|
|
* @return $this
|
|
*
|
|
* @example
|
|
* // Get contacts with their attachments
|
|
* Contact_Model::query()
|
|
* ->joinMorph('file_attachments', 'fileable')
|
|
* ->get();
|
|
*
|
|
* // Explicit class (when joining from a different model)
|
|
* SomeModel::query()
|
|
* ->joinMorph('file_attachments', 'fileable', Contact_Model::class)
|
|
* ->get();
|
|
*/
|
|
public function joinMorph(string $table, string $morphName, ?string $morphClass = null): self
|
|
{
|
|
return $this->_addMorphJoin('join', $table, $morphName, $morphClass);
|
|
}
|
|
|
|
/**
|
|
* Add a polymorphic LEFT JOIN to the query
|
|
*
|
|
* @param string $table The table containing the polymorphic columns
|
|
* @param string $morphName The morph column prefix (e.g., 'fileable')
|
|
* @param string|null $morphClass The class name to match. If null, uses current model's class.
|
|
* @return $this
|
|
*
|
|
* @example
|
|
* // Get all contacts, with attachments if they exist
|
|
* Contact_Model::query()
|
|
* ->leftJoinMorph('file_attachments', 'fileable')
|
|
* ->get();
|
|
*/
|
|
public function leftJoinMorph(string $table, string $morphName, ?string $morphClass = null): self
|
|
{
|
|
return $this->_addMorphJoin('leftJoin', $table, $morphName, $morphClass);
|
|
}
|
|
|
|
/**
|
|
* Add a polymorphic RIGHT JOIN to the query
|
|
*
|
|
* @param string $table The table containing the polymorphic columns
|
|
* @param string $morphName The morph column prefix (e.g., 'fileable')
|
|
* @param string|null $morphClass The class name to match. If null, uses current model's class.
|
|
* @return $this
|
|
*/
|
|
public function rightJoinMorph(string $table, string $morphName, ?string $morphClass = null): self
|
|
{
|
|
return $this->_addMorphJoin('rightJoin', $table, $morphName, $morphClass);
|
|
}
|
|
|
|
/**
|
|
* Internal helper to add a polymorphic join
|
|
*
|
|
* @param string $joinMethod The join method to use (join, leftJoin, rightJoin)
|
|
* @param string $table The table containing the polymorphic columns
|
|
* @param string $morphName The morph column prefix
|
|
* @param string|null $morphClass The class name to match
|
|
* @return $this
|
|
*/
|
|
protected function _addMorphJoin(string $joinMethod, string $table, string $morphName, ?string $morphClass): self
|
|
{
|
|
// Determine the class name to use
|
|
if ($morphClass === null) {
|
|
// Use the current model's simple class name
|
|
$morphClass = class_basename($this->getModel());
|
|
} else {
|
|
// Extract simple class name if FQCN was provided
|
|
$morphClass = class_basename($morphClass);
|
|
}
|
|
|
|
// Get the type_ref ID for the class
|
|
$typeRefId = Type_Ref_Registry::class_to_id($morphClass);
|
|
|
|
// Get the current model's table name
|
|
$baseTable = $this->getModel()->getTable();
|
|
|
|
// Build the join
|
|
return $this->$joinMethod($table, function ($join) use ($table, $baseTable, $morphName, $typeRefId) {
|
|
$join->on("{$table}.{$morphName}_id", '=', "{$baseTable}.id")
|
|
->where("{$table}.{$morphName}_type", '=', $typeRefId);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Prevent eager loading via with()
|
|
*
|
|
* @param mixed $relations
|
|
* @return $this
|
|
* @throws \RuntimeException
|
|
*/
|
|
public function with($relations, $callback = null)
|
|
{
|
|
// Allow empty with() calls (Laravel uses these internally)
|
|
if (empty($relations)) {
|
|
return parent::with($relations, $callback);
|
|
}
|
|
|
|
// Also allow if relations is an empty array
|
|
if (is_array($relations) && count($relations) === 0) {
|
|
return parent::with($relations, $callback);
|
|
}
|
|
|
|
// Throw exception for actual eager loading attempts
|
|
throw new \RuntimeException(
|
|
'Eager loading via with() is not allowed in the RSpade framework. ' .
|
|
'Use explicit queries for each relationship instead. ' .
|
|
'Attempted to eager load: ' . $this->format_relations($relations)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Prevent eager loading via withCount()
|
|
*
|
|
* @param mixed $relations
|
|
* @return $this
|
|
* @throws \RuntimeException
|
|
*/
|
|
public function withCount($relations)
|
|
{
|
|
// Allow empty withCount() calls
|
|
if (empty($relations)) {
|
|
return parent::withCount($relations);
|
|
}
|
|
|
|
throw new \RuntimeException(
|
|
'Eager loading counts via withCount() is not allowed in the RSpade framework. ' .
|
|
'Use explicit count queries instead. ' .
|
|
'Attempted to eager load counts for: ' . $this->format_relations($relations)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Prevent eager loading via withMax()
|
|
*
|
|
* @param array|string $relation
|
|
* @param string $column
|
|
* @return $this
|
|
* @throws \RuntimeException
|
|
*/
|
|
public function withMax($relation, $column)
|
|
{
|
|
// Allow empty withMax() calls
|
|
if (empty($relation)) {
|
|
return parent::withMax($relation, $column);
|
|
}
|
|
|
|
throw new \RuntimeException(
|
|
'Eager loading max via withMax() is not allowed in the RSpade framework. ' .
|
|
'Use explicit max queries instead.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Prevent eager loading via withMin()
|
|
*
|
|
* @param array|string $relation
|
|
* @param string $column
|
|
* @return $this
|
|
* @throws \RuntimeException
|
|
*/
|
|
public function withMin($relation, $column)
|
|
{
|
|
// Allow empty withMin() calls
|
|
if (empty($relation)) {
|
|
return parent::withMin($relation, $column);
|
|
}
|
|
|
|
throw new \RuntimeException(
|
|
'Eager loading min via withMin() is not allowed in the RSpade framework. ' .
|
|
'Use explicit min queries instead.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Prevent eager loading via withSum()
|
|
*
|
|
* @param array|string $relation
|
|
* @param string $column
|
|
* @return $this
|
|
* @throws \RuntimeException
|
|
*/
|
|
public function withSum($relation, $column)
|
|
{
|
|
// Allow empty withSum() calls
|
|
if (empty($relation)) {
|
|
return parent::withSum($relation, $column);
|
|
}
|
|
|
|
throw new \RuntimeException(
|
|
'Eager loading sum via withSum() is not allowed in the RSpade framework. ' .
|
|
'Use explicit sum queries instead.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Prevent eager loading via withAvg()
|
|
*
|
|
* @param array|string $relation
|
|
* @param string $column
|
|
* @return $this
|
|
* @throws \RuntimeException
|
|
*/
|
|
public function withAvg($relation, $column)
|
|
{
|
|
// Allow empty withAvg() calls
|
|
if (empty($relation)) {
|
|
return parent::withAvg($relation, $column);
|
|
}
|
|
|
|
throw new \RuntimeException(
|
|
'Eager loading avg via withAvg() is not allowed in the RSpade framework. ' .
|
|
'Use explicit avg queries instead.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Prevent eager loading via withExists()
|
|
*
|
|
* @param array|string $relation
|
|
* @return $this
|
|
* @throws \RuntimeException
|
|
*/
|
|
public function withExists($relation)
|
|
{
|
|
// Allow empty withExists() calls
|
|
if (empty($relation)) {
|
|
return parent::withExists($relation);
|
|
}
|
|
|
|
throw new \RuntimeException(
|
|
'Eager loading exists via withExists() is not allowed in the RSpade framework. ' .
|
|
'Use explicit exists queries instead.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Prevent eager loading via withAggregate()
|
|
*
|
|
* @param mixed $relations
|
|
* @param string $column
|
|
* @param string $function
|
|
* @return $this
|
|
* @throws \RuntimeException
|
|
*/
|
|
public function withAggregate($relations, $column, $function = null)
|
|
{
|
|
// Allow empty withAggregate() calls
|
|
if (empty($relations)) {
|
|
return parent::withAggregate($relations, $column, $function);
|
|
}
|
|
|
|
throw new \RuntimeException(
|
|
'Eager loading aggregates via withAggregate() is not allowed in the RSpade framework. ' .
|
|
'Use explicit aggregate queries instead.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Prevent lazy eager loading via has()
|
|
* Note: has() is different - it's for filtering, not loading
|
|
* But we'll still prevent it if it tries to eager load
|
|
*
|
|
* @param string $relation
|
|
* @param string $operator
|
|
* @param int $count
|
|
* @param string $boolean
|
|
* @param \Closure|null $callback
|
|
* @return $this
|
|
*/
|
|
public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', ?\Closure $callback = null)
|
|
{
|
|
// has() is allowed as it's for filtering, not eager loading
|
|
// But log it for monitoring
|
|
if (app()->environment() !== 'testing') {
|
|
logger()->debug('has() query used on relation', [
|
|
'model' => get_class($this->model),
|
|
'relation' => $relation,
|
|
'trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5)
|
|
]);
|
|
}
|
|
|
|
return parent::has($relation, $operator, $count, $boolean, $callback);
|
|
}
|
|
|
|
/**
|
|
* Override delete to prevent deleting all records without WHERE clause
|
|
*
|
|
* @param mixed $id
|
|
* @return int
|
|
* @throws \RuntimeException
|
|
*/
|
|
public function delete($id = null)
|
|
{
|
|
// If $id is provided, allow it (deleting by primary key)
|
|
if ($id !== null) {
|
|
return parent::delete($id);
|
|
}
|
|
|
|
// Check if there are any WHERE clauses on the underlying query
|
|
$baseQuery = $this->getQuery();
|
|
if (empty($baseQuery->wheres)) {
|
|
// @PHP-DB-01-EXCEPTION - DB::table() mentioned in error message, not used
|
|
shouldnt_happen(
|
|
'Attempted to delete all records from ' . $this->getModel()->getTable() . ' without WHERE clause. ' .
|
|
'This operation is forbidden to prevent accidental data loss. ' .
|
|
'If you truly need to delete all records, use DB::table()->truncate() or add a WHERE clause.'
|
|
);
|
|
}
|
|
|
|
return parent::delete();
|
|
}
|
|
|
|
/**
|
|
* Format relations for error messages
|
|
*
|
|
* @param mixed $relations
|
|
* @return string
|
|
*/
|
|
protected function format_relations($relations)
|
|
{
|
|
if (is_array($relations)) {
|
|
return implode(', ', array_keys($relations));
|
|
}
|
|
|
|
return (string) $relations;
|
|
}
|
|
} |