🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
415 lines
14 KiB
Plaintext
Executable File
415 lines
14 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.
|
|
|
|
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)
|