Files
rspade_system/app/RSpade/man/polymorphic.txt
2025-12-27 23:55:05 +00:00

475 lines
16 KiB
Plaintext
Executable File

POLYMORPHIC(7) RSX Framework Manual POLYMORPHIC(7)
NAME
polymorphic - Polymorphic relationships with type references
SYNOPSIS
Model definition:
class Activity_Model extends Rsx_Model_Abstract
{
protected static $type_ref_columns = ['eventable_type'];
public function eventable()
{
return $this->morphTo();
}
}
Usage:
$activity->eventable_type = 'Contact_Model'; // Stores integer in DB
$activity->eventable_id = 123;
$activity->save();
echo $activity->eventable_type; // Returns "Contact_Model"
Form handling:
$eventable = Polymorphic_Field_Helper::parse($params['eventable'], [
Contact_Model::class,
Project_Model::class,
]);
DESCRIPTION
Polymorphic relationships allow a single database column pair to reference
multiple model types. For example, an Activity can be related to either a
Contact or a Project using eventable_type and eventable_id columns.
RSX uses a type reference system that stores integers in the database but
transparently converts to/from class name strings in PHP. This provides:
- Efficient integer storage instead of VARCHAR class names
- Automatic type discovery - no manual mapping required
- Transparent conversion - you work with class names, never see integers
- Laravel morphTo() compatibility through auto-registered morph maps
TYPE REFERENCE SYSTEM
How It Works
RSX maintains a _type_refs table that maps class names to integer IDs:
id | class_name | table_name
1 | Contact_Model | contacts
2 | Project_Model | projects
3 | Task_Model | tasks
When you set a type_ref column to a class name, RSX:
1. Checks if the class exists in _type_refs
2. If not, validates it's a valid Rsx_Model subclass and creates entry
3. Stores the integer ID in the database
When you read a type_ref column, RSX:
1. Looks up the integer ID in _type_refs
2. Returns the class name string
Defining Type Reference Columns
Declare which columns are type references in your model:
class File_Attachment_Model extends Rsx_Model_Abstract
{
protected static $type_ref_columns = ['fileable_type'];
public function fileable()
{
return $this->morphTo();
}
}
The cast is automatically applied - no manual $casts definition needed.
Database Schema
Type reference columns must be BIGINT, not VARCHAR:
fileable_type BIGINT NULL,
fileable_id BIGINT NULL,
The _type_refs table is created automatically by framework migrations.
Auto-Discovery
When storing a new class name that isn't in _type_refs yet:
$attachment->fileable_type = 'Custom_Model';
$attachment->save();
RSX will:
1. Verify Custom_Model exists and extends Rsx_Model_Abstract
2. Create a new _type_refs entry with the next available ID
3. Store that ID in the fileable_type column
This means any model can be used in polymorphic relationships without
pre-registration. The system is fully automatic.
Caching
The type refs map is cached:
1. In Redis (if available) - refreshed when entries are added
2. In PHP memory - loaded once per request
The map is typically small (< 100 entries), so full-map caching is used.
Validation
On write: Throws exception if value is not a valid Rsx_Model subclass
On read: Throws exception if ID doesn't exist in _type_refs table
Null values: Allowed if the database column is nullable
LARAVEL MORPH MAP INTEGRATION
RSX automatically registers all type refs with Laravel's morph map during
framework initialization. This means standard Laravel polymorphic methods
work transparently:
// These all work as expected
$attachment->fileable; // Returns the related model
$model->attachments; // morphMany relationship
Activity_Model::whereHasMorph(); // Polymorphic queries
The morph map uses simple class names (e.g., "Contact_Model") not fully
qualified names, matching how RSX models work throughout the framework.
QUERY BUILDER INTEGRATION
Transparent WHERE Clauses
The query builder automatically converts type_ref columns in WHERE
clauses. You can use class name strings directly:
// All of these work - class names auto-converted to IDs
File_Attachment_Model::where('fileable_type', 'Contact_Model')->get();
File_Attachment_Model::where('fileable_type', '=', 'Contact_Model')->get();
File_Attachment_Model::where(['fileable_type' => 'Contact_Model'])->get();
// whereIn also works
File_Attachment_Model::whereIn('fileable_type', [
'Contact_Model',
'Project_Model'
])->get();
// Integer IDs still work (pass-through)
File_Attachment_Model::where('fileable_type', 42)->get();
Supported methods: where(), orWhere(), whereIn(), orWhereIn(),
whereNotIn(), orWhereNotIn()
Polymorphic Join Helpers
Join tables with polymorphic columns using dedicated helpers:
// Get contacts that have attachments (INNER JOIN)
Contact_Model::query()
->joinMorph('file_attachments', 'fileable')
->select('contacts.*', 'file_attachments.filename')
->get();
// Get all contacts, with attachments if they exist (LEFT JOIN)
Contact_Model::query()
->leftJoinMorph('file_attachments', 'fileable')
->get();
// RIGHT JOIN
Contact_Model::query()
->rightJoinMorph('file_attachments', 'fileable')
->get();
Parameters:
$table - Table with polymorphic columns (e.g., 'file_attachments')
$morphName - Column prefix (e.g., 'fileable' for fileable_type/fileable_id)
$morphClass - Optional class name (defaults to current model)
Explicit class (when querying from a different model context):
SomeModel::query()
->leftJoinMorph('file_attachments', 'fileable', Contact_Model::class)
->get();
The generated SQL is equivalent to:
LEFT JOIN file_attachments
ON file_attachments.fileable_id = contacts.id
AND file_attachments.fileable_type = <type_ref_id>
FORM HANDLING
Client-Side Format
Polymorphic fields are submitted as JSON-encoded values:
eventable={"model":"Contact_Model","id":123}
This single-field approach simplifies form handling compared to
separate type and id fields.
Polymorphic_Field_Helper
Server-side parsing with security validation:
use App\RSpade\Core\Polymorphic_Field_Helper;
$eventable = Polymorphic_Field_Helper::parse($params['eventable'], [
Contact_Model::class,
Project_Model::class,
]);
// Validation
if ($error = $eventable->validate('Please select an entity')) {
$errors['eventable'] = $error;
}
// Access values
$model->eventable_type = $eventable->model; // "Contact_Model"
$model->eventable_id = $eventable->id; // 123
The whitelist ensures only allowed model types are accepted.
Validation Methods
Required field:
if ($error = $field->validate('Please select')) {
$errors['field'] = $error;
}
Optional field:
if ($error = $field->validate_optional('Invalid type')) {
$errors['field'] = $error;
}
State Checking
$field->is_empty() // No value provided
$field->is_valid() // Value provided and model type allowed
$field->is_invalid() // Value provided but model type not allowed
CLIENT-SIDE IMPLEMENTATION
Building a Polymorphic Picker Component
To create a picker for polymorphic fields, build a component that:
1. Maintains a hidden input with the JSON-encoded value
2. Implements val() to get/set as {model: 'Model_Name', id: number}
3. Syncs the hidden input on every change
4. Handles pre-load value setting (value set before options loaded)
Hidden Input Pattern
The component template should include a hidden input:
<input type="hidden" $sid="hidden_value" name="<%= this.args.name %>" />
On value change, sync to hidden input as JSON:
_sync_hidden_value() {
const $hidden = this.$sid('hidden_value');
if (this._value && this._value.model && this._value.id) {
$hidden.val(JSON.stringify(this._value));
} else {
$hidden.val('');
}
}
val() Implementation
The val() method should work before and after component loads:
val(value) {
if (arguments.length === 0) {
// Getter
return this._value || this._pending_value || null;
}
// Setter
const parsed = value ? {model: value.model, id: parseInt(value.id)} : null;
if (this.data.loading) {
this._pending_value = parsed;
this._value = parsed;
this._sync_hidden_value();
} else {
this._value = parsed;
this._apply_to_ui();
this._sync_hidden_value();
}
}
Using Tom Select
For searchable dropdowns, use Tom Select with a compound value format:
// Compound value: "Model_Name:id"
valueField: 'compound_value',
onChange: function(compound_value) {
if (!compound_value) {
that._value = null;
} else {
const parts = compound_value.split(':');
that._value = {model: parts[0], id: parseInt(parts[1])};
}
that._sync_hidden_value();
}
Add options with the compound format:
all_options.push({
compound_value: 'Contact_Model:' + contact.id,
model: 'Contact_Model',
id: contact.id,
name: contact.name,
});
COMPLETE EXAMPLE
Model Definition
class Activity_Model extends Rsx_Model_Abstract
{
protected static $type_ref_columns = ['eventable_type'];
public static $enums = [];
public static $rel = [];
protected $table = 'activities';
public function eventable()
{
return $this->morphTo();
}
}
Controller
use App\RSpade\Core\Polymorphic_Field_Helper;
#[Ajax_Endpoint]
public static function save(Request $request, array $params = [])
{
$eventable = Polymorphic_Field_Helper::parse($params['eventable'] ?? null, [
Contact_Model::class,
Project_Model::class,
]);
$errors = [];
if ($error = $eventable->validate('Please select an entity')) {
$errors['eventable'] = $error;
}
if (!empty($errors)) {
return response_form_error('Please correct errors', $errors);
}
$activity = new Activity_Model();
$activity->eventable_type = $eventable->model;
$activity->eventable_id = $eventable->id;
$activity->save();
return ['success' => true];
}
Migration
DB::statement("
CREATE TABLE activities (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
eventable_type BIGINT NULL,
eventable_id BIGINT NULL,
description TEXT NULL,
site_id BIGINT NOT NULL,
created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
updated_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
created_by BIGINT NULL,
updated_by BIGINT NULL,
INDEX idx_eventable (eventable_type, eventable_id),
FOREIGN KEY (site_id) REFERENCES sites(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
RSX VS LARAVEL
Laravel Default
- Stores full class name as VARCHAR: "App\\Models\\Contact"
- Requires manual morph map configuration for aliases
- No automatic discovery of new types
RSX Approach
- Stores integer ID in BIGINT column
- Automatic class name to ID mapping via _type_refs table
- Auto-discovery creates new entries on first use
- Morph map auto-registered for Laravel relationship compatibility
- Simple class names: "Contact_Model" not "App\\Models\\Contact"
Benefits
- Smaller database storage (BIGINT vs VARCHAR)
- Faster queries (integer comparison vs string comparison)
- Indexable without concerns about string length
- Consistent with RSX simple class name conventions
- Zero configuration - just declare $type_ref_columns
CONVERTING EXISTING COLUMNS
To convert a VARCHAR polymorphic column to use type_refs:
1. Create migration to add temporary column and convert data
2. Update model to declare $type_ref_columns
3. Drop old VARCHAR column
Example migration (if you have existing data to preserve):
// Add new integer column
DB::statement("ALTER TABLE my_table ADD COLUMN entity_type_new BIGINT NULL AFTER entity_type");
// Note: Data migration would go here if needed
// For new installations, just drop and recreate the column
// Drop old column and rename
DB::statement("ALTER TABLE my_table DROP COLUMN entity_type");
DB::statement("ALTER TABLE my_table CHANGE entity_type_new entity_type BIGINT NULL");
For fresh installations with no data to preserve, simply:
DB::statement("ALTER TABLE my_table MODIFY entity_type BIGINT NULL");
SECURITY CONSIDERATIONS
Model Type Validation
CRITICAL: Always validate that submitted model types are in the allowed
list. Attackers could submit arbitrary model names to exploit the
polymorphic relationship.
The Polymorphic_Field_Helper enforces this automatically:
// Only Contact_Model and Project_Model are accepted
$eventable = Polymorphic_Field_Helper::parse($params['eventable'], [
Contact_Model::class,
Project_Model::class,
]);
// Submitting {"model":"User_Model","id":1} will fail validation
$eventable->is_valid(); // false
Type Ref Auto-Creation
The type_refs system validates that any new class name is a valid
Rsx_Model subclass before creating an entry. This prevents attackers
from polluting the _type_refs table with invalid class names.
Use Model::class, Not Strings
Always use the ::class constant to specify allowed models:
// CORRECT - uses class constant
Polymorphic_Field_Helper::parse($value, [Contact_Model::class]);
// WRONG - hardcoded string
Polymorphic_Field_Helper::parse($value, ['Contact_Model']);
Using ::class ensures:
- IDE autocompletion and refactoring support
- Compile-time error if class doesn't exist
- Consistent naming with actual class names
SEE ALSO
model(7), form_conventions(7), enum(7)
RSX Framework 2025-12-27 POLYMORPHIC(7)