Add Polymorphic_Field_Helper for JSON-encoded polymorphic form fields
Fix incorrect data-sid selector in route-debug help example Fix Form_Utils to use component.$sid() instead of data-sid selector Add response helper functions and use _message as reserved metadata key 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
187
app/RSpade/Core/Polymorphic_Field_Helper.php
Executable file
187
app/RSpade/Core/Polymorphic_Field_Helper.php
Executable file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core;
|
||||
|
||||
/**
|
||||
* Polymorphic Field Helper
|
||||
*
|
||||
* Handles JSON-encoded polymorphic field values from form components.
|
||||
* Provides parsing, validation, and security checks for polymorphic relationships.
|
||||
*
|
||||
* Polymorphic fields are submitted as JSON: {"model":"Contact_Model","id":123}
|
||||
* This class parses that JSON and validates the model type against a whitelist.
|
||||
*
|
||||
* Usage in controllers:
|
||||
*
|
||||
* use App\RSpade\Core\Polymorphic_Field_Helper;
|
||||
*
|
||||
* // Parse and validate a polymorphic field
|
||||
* $eventable = Polymorphic_Field_Helper::parse($params['eventable'], [
|
||||
* Contact_Model::class,
|
||||
* Project_Model::class,
|
||||
* ]);
|
||||
*
|
||||
* // Quick validation with error message
|
||||
* $error = $eventable->validate('Please select an entity');
|
||||
* if ($error) {
|
||||
* $errors['eventable'] = $error;
|
||||
* }
|
||||
*
|
||||
* // Or check states manually
|
||||
* if ($eventable->is_empty()) {
|
||||
* $errors['eventable'] = 'Please select a contact or project';
|
||||
* } elseif (!$eventable->is_valid()) {
|
||||
* $errors['eventable'] = 'Invalid entity type selected';
|
||||
* }
|
||||
*
|
||||
* // Use the values
|
||||
* $model->eventable_type = $eventable->model;
|
||||
* $model->eventable_id = $eventable->id;
|
||||
*/
|
||||
#[Instantiatable]
|
||||
class Polymorphic_Field_Helper
|
||||
{
|
||||
/**
|
||||
* The model class name (e.g., "Contact_Model")
|
||||
*/
|
||||
public ?string $model = null;
|
||||
|
||||
/**
|
||||
* The entity ID
|
||||
*/
|
||||
public ?int $id = null;
|
||||
|
||||
/**
|
||||
* Whether any value was provided in the input
|
||||
*/
|
||||
private bool $was_provided = false;
|
||||
|
||||
/**
|
||||
* Whether the provided model type is in the allowed list
|
||||
*/
|
||||
private bool $model_allowed = false;
|
||||
|
||||
/**
|
||||
* List of allowed model basenames for error messages
|
||||
*/
|
||||
private array $allowed_models = [];
|
||||
|
||||
/**
|
||||
* Parse a JSON-encoded polymorphic field value
|
||||
*
|
||||
* @param string|null $json_value The JSON string from form submission (e.g., '{"model":"Contact_Model","id":123}')
|
||||
* @param array $allowed_model_classes Array of allowed model class names (use Model::class syntax)
|
||||
* @return self
|
||||
*/
|
||||
public static function parse(?string $json_value, array $allowed_model_classes): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->allowed_models = array_map(fn($class) => class_basename($class), $allowed_model_classes);
|
||||
|
||||
if (empty($json_value)) {
|
||||
return $instance;
|
||||
}
|
||||
|
||||
$decoded = json_decode($json_value, true);
|
||||
if (!is_array($decoded)) {
|
||||
return $instance;
|
||||
}
|
||||
|
||||
$instance->was_provided = true;
|
||||
|
||||
if (!empty($decoded['model']) && !empty($decoded['id'])) {
|
||||
$instance->model = $decoded['model'];
|
||||
$instance->id = (int) $decoded['id'];
|
||||
$instance->model_allowed = in_array($instance->model, $instance->allowed_models);
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if no value was provided (empty string or null)
|
||||
*/
|
||||
public function is_empty(): bool
|
||||
{
|
||||
return !$this->was_provided || $this->model === null || $this->id === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the value is valid (provided and model type is allowed)
|
||||
*/
|
||||
public function is_valid(): bool
|
||||
{
|
||||
return !$this->is_empty() && $this->model_allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value was provided but is invalid (wrong model type or malformed)
|
||||
*/
|
||||
public function is_invalid(): bool
|
||||
{
|
||||
return $this->was_provided && !$this->is_valid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model class name (e.g., "Contact_Model")
|
||||
*/
|
||||
public function get_model(): ?string
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity ID
|
||||
*/
|
||||
public function get_id(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get allowed model names for error messages
|
||||
*/
|
||||
public function get_allowed_models(): array
|
||||
{
|
||||
return $this->allowed_models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and return error message if invalid, null if valid
|
||||
*
|
||||
* Use this for required polymorphic fields.
|
||||
*
|
||||
* @param string $empty_message Message when field is empty/missing
|
||||
* @param string $invalid_message Message when model type is not allowed
|
||||
* @return string|null Error message or null if valid
|
||||
*/
|
||||
public function validate(string $empty_message, string $invalid_message = 'Invalid entity type selected'): ?string
|
||||
{
|
||||
if ($this->is_empty()) {
|
||||
return $empty_message;
|
||||
}
|
||||
if (!$this->model_allowed) {
|
||||
return $invalid_message;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate for optional field (only invalid if provided but wrong type)
|
||||
*
|
||||
* Use this for optional polymorphic fields where empty is acceptable.
|
||||
*
|
||||
* @param string $invalid_message Message when model type is not allowed
|
||||
* @return string|null Error message or null if valid/empty
|
||||
*/
|
||||
public function validate_optional(string $invalid_message = 'Invalid entity type selected'): ?string
|
||||
{
|
||||
if ($this->is_empty()) {
|
||||
return null; // Empty is OK for optional fields
|
||||
}
|
||||
if (!$this->model_allowed) {
|
||||
return $invalid_message;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
256
app/RSpade/man/polymorphic.txt
Executable file
256
app/RSpade/man/polymorphic.txt
Executable file
@@ -0,0 +1,256 @@
|
||||
POLYMORPHIC(7) RSX Framework Manual POLYMORPHIC(7)
|
||||
|
||||
NAME
|
||||
polymorphic - JSON-encoded polymorphic field handling
|
||||
|
||||
SYNOPSIS
|
||||
Server-side:
|
||||
use App\RSpade\Core\Polymorphic_Field_Helper;
|
||||
|
||||
$field = Polymorphic_Field_Helper::parse($params['fieldname'], [
|
||||
Contact_Model::class,
|
||||
Project_Model::class,
|
||||
]);
|
||||
|
||||
if ($error = $field->validate('Please select an entity')) {
|
||||
$errors['fieldname'] = $error;
|
||||
}
|
||||
|
||||
$model->poly_type = $field->model;
|
||||
$model->poly_id = $field->id;
|
||||
|
||||
Client-side (form input value format):
|
||||
{"model":"Contact_Model","id":123}
|
||||
|
||||
DESCRIPTION
|
||||
Polymorphic fields allow a single database relationship to reference
|
||||
multiple model types. For example, an Activity can be related to either
|
||||
a Contact or a Project. This document describes the standard pattern for
|
||||
handling polymorphic fields in RSX applications.
|
||||
|
||||
The Problem
|
||||
|
||||
Traditional form handling passes polymorphic data as separate fields:
|
||||
|
||||
eventable_type=Contact_Model
|
||||
eventable_id=123
|
||||
|
||||
This approach has issues:
|
||||
- Requires custom form submission handlers to inject hidden fields
|
||||
- Field naming is inconsistent (type vs _type, id vs _id)
|
||||
- No standard validation pattern
|
||||
- Security validation (allowed model types) is ad-hoc
|
||||
|
||||
The Solution
|
||||
|
||||
Polymorphic fields are submitted as a single JSON-encoded value:
|
||||
|
||||
eventable={"model":"Contact_Model","id":123}
|
||||
|
||||
Benefits:
|
||||
- Single field to validate
|
||||
- Standard val() getter/setter pattern on client
|
||||
- Polymorphic_Field_Helper handles parsing and security validation
|
||||
- Clean, reusable code on both client and server
|
||||
|
||||
SERVER-SIDE USAGE
|
||||
Polymorphic_Field_Helper Class
|
||||
|
||||
Location: App\RSpade\Core\Polymorphic_Field_Helper
|
||||
|
||||
Parse a field value:
|
||||
|
||||
use App\RSpade\Core\Polymorphic_Field_Helper;
|
||||
|
||||
$eventable = Polymorphic_Field_Helper::parse($params['eventable'], [
|
||||
Contact_Model::class,
|
||||
Project_Model::class,
|
||||
]);
|
||||
|
||||
The second argument is the whitelist of allowed model classes. Always
|
||||
use Model::class syntax - never hardcode model name strings.
|
||||
|
||||
Validation Methods
|
||||
|
||||
Required field validation:
|
||||
|
||||
if ($error = $eventable->validate('Please select an entity')) {
|
||||
$errors['eventable'] = $error;
|
||||
}
|
||||
|
||||
Optional field validation:
|
||||
|
||||
if ($error = $parent->validate_optional('Invalid parent type')) {
|
||||
$errors['parent'] = $error;
|
||||
}
|
||||
|
||||
Accessing Values
|
||||
|
||||
After validation:
|
||||
|
||||
$model->eventable_type = $eventable->model; // "Contact_Model"
|
||||
$model->eventable_id = $eventable->id; // 123
|
||||
|
||||
For optional fields, id will be null if not provided:
|
||||
|
||||
$model->parent_id = $parent->id; // null or integer
|
||||
|
||||
State Checking Methods
|
||||
|
||||
$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
|
||||
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];
|
||||
}
|
||||
|
||||
DATABASE SCHEMA
|
||||
Polymorphic relationships use two columns:
|
||||
|
||||
$table->string('eventable_type');
|
||||
$table->unsignedBigInteger('eventable_id');
|
||||
|
||||
The type column stores the model class basename (e.g., "Contact_Model").
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
form_conventions(7), ajax_error_handling(7)
|
||||
|
||||
RSX Framework 2025-12-23 POLYMORPHIC(7)
|
||||
Reference in New Issue
Block a user