_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; } }