🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1141 lines
44 KiB
Plaintext
Executable File
1141 lines
44 KiB
Plaintext
Executable File
MODEL_FETCH(3) RSX Framework Manual MODEL_FETCH(3)
|
|
|
|
NAME
|
|
model_fetch - RSX Ajax ORM with secure model fetching from JavaScript
|
|
|
|
SYNOPSIS
|
|
Secure, controlled access to ORM models from JavaScript with explicit opt-in
|
|
|
|
DESCRIPTION
|
|
The RSX model fetch system allows JavaScript to securely access ORM models
|
|
through an explicit opt-in mechanism. Unlike Laravel's default API routes
|
|
which expose all model data, RSX requires each model to implement its own
|
|
fetch() method with authorization and data filtering.
|
|
|
|
Key differences from Laravel:
|
|
- Laravel: API routes with resource controllers expose all fields
|
|
- RSX: Each model implements custom fetch() with authorization
|
|
|
|
Benefits:
|
|
- Explicit security control per model
|
|
- No mass exposure of sensitive data
|
|
- Individual authorization checks for each record
|
|
- Automatic JavaScript stub generation
|
|
|
|
STATUS (as of 2025-11-23)
|
|
The Model Fetch system is fully implemented for application development MVP.
|
|
All core functionality documented in this manual is production-ready:
|
|
|
|
Implemented:
|
|
- fetch() and fetch_or_null() methods
|
|
- Lazy relationship loading (belongsTo, hasMany, morphTo, etc.)
|
|
- Enum properties on instances (BEM-style {column}__{field} pattern)
|
|
- Static enum constants and accessor methods
|
|
- Automatic model hydration from Ajax responses
|
|
- JavaScript class hierarchy with Base_* stubs
|
|
- Authorization patterns and security model
|
|
|
|
Future enhancements (documented below under FUTURE DEVELOPMENT) will be
|
|
additive and non-breaking to the existing API.
|
|
|
|
SECURITY MODEL
|
|
Explicit Opt-In:
|
|
Models must deliberately implement fetch() with the attribute.
|
|
No models are fetchable by default.
|
|
|
|
Individual Authorization:
|
|
Each model controls who can fetch its records.
|
|
Authorization checked for every single record.
|
|
|
|
Data Filtering:
|
|
Models can filter sensitive fields before returning data.
|
|
Complete control over what data JavaScript receives.
|
|
|
|
Single Record Fetching:
|
|
Each fetch() call retrieves exactly one record.
|
|
See FUTURE DEVELOPMENT for planned batch fetching.
|
|
|
|
IMPLEMENTING FETCHABLE MODELS
|
|
Required Components:
|
|
1. Override static fetch() method
|
|
2. Add #[Ajax_Endpoint_Model_Fetch] attribute
|
|
3. Accept exactly one parameter: $id (single ID only)
|
|
4. Implement authorization checks
|
|
5. Return model object, custom array, or false
|
|
|
|
Return Value Options:
|
|
- Model object: Serialized via toArray(), includes __MODEL for hydration
|
|
- Augmented array: Start with $model->toArray(), add computed fields (recommended)
|
|
- false: Record not found or unauthorized (MUST be false, not null)
|
|
|
|
Note: Always use toArray() as the base when returning arrays. This preserves
|
|
the __MODEL property required for JavaScript hydration. See "Augmented Array
|
|
Return" section below for the correct pattern.
|
|
|
|
Basic Implementation:
|
|
use Ajax_Endpoint_Model_Fetch;
|
|
|
|
class Product_Model extends Rsx_Model
|
|
{
|
|
#[Ajax_Endpoint_Model_Fetch]
|
|
public static function fetch($id)
|
|
{
|
|
// Authorization check
|
|
if (!Session::is_logged_in()) {
|
|
return false;
|
|
}
|
|
|
|
// Fetch single record
|
|
$model = static::find($id);
|
|
return $model ?: false;
|
|
}
|
|
}
|
|
|
|
Advanced Authorization:
|
|
class Order_Model extends Rsx_Model
|
|
{
|
|
#[Ajax_Endpoint_Model_Fetch]
|
|
public static function fetch($id)
|
|
{
|
|
$user = Session::get_user();
|
|
if (!$user) {
|
|
return false;
|
|
}
|
|
|
|
$order = static::find($id);
|
|
if (!$order) {
|
|
return false;
|
|
}
|
|
|
|
// Only allow access to own orders or admin users
|
|
if ($order->user_id !== $user->id && !$user->is_admin) {
|
|
return false;
|
|
}
|
|
|
|
return $order;
|
|
}
|
|
}
|
|
|
|
Data Filtering:
|
|
class User_Model extends Rsx_Model
|
|
{
|
|
#[Ajax_Endpoint_Model_Fetch]
|
|
public static function fetch($id)
|
|
{
|
|
if (!Session::is_logged_in()) {
|
|
return false;
|
|
}
|
|
|
|
$user = static::find($id);
|
|
if (!$user) {
|
|
return false;
|
|
}
|
|
|
|
// Remove sensitive fields
|
|
unset($user->password_hash);
|
|
unset($user->remember_token);
|
|
unset($user->email_verification_token);
|
|
|
|
return $user;
|
|
}
|
|
}
|
|
|
|
Augmented Array Return (for model method outputs only):
|
|
class Contact_Model extends Rsx_Model
|
|
{
|
|
// Model methods that produce NEW computed values
|
|
public function full_name(): string
|
|
{
|
|
return trim($this->first_name . ' ' . $this->last_name);
|
|
}
|
|
|
|
public function mailto_link(): string
|
|
{
|
|
return '<a href="mailto:' . e($this->email) . '">' . e($this->email) . '</a>';
|
|
}
|
|
|
|
#[Ajax_Endpoint_Model_Fetch]
|
|
public static function fetch($id)
|
|
{
|
|
$contact = static::withTrashed()->find($id);
|
|
if (!$contact) {
|
|
return false;
|
|
}
|
|
|
|
// Start with model's toArray() to get __MODEL and base data
|
|
$data = $contact->toArray();
|
|
|
|
// Augment ONLY with outputs from defined model methods
|
|
$data['full_name'] = $contact->full_name();
|
|
$data['mailto_link'] = $contact->mailto_link();
|
|
|
|
return $data;
|
|
}
|
|
}
|
|
|
|
IMPORTANT: Always start with $model->toArray() when augmenting data.
|
|
This preserves the __MODEL property needed for JavaScript hydration.
|
|
|
|
DO NOT manually construct the return array like this (outdated pattern):
|
|
return [
|
|
'id' => $client->id,
|
|
'name' => $client->name,
|
|
// ... manually listing fields
|
|
];
|
|
This loses __MODEL and returns a plain object instead of a model instance.
|
|
|
|
FETCH() IS FOR SECURITY, NOT ALIASING
|
|
|
|
The fetch() method exists for ONE purpose: security filtering - removing private
|
|
data that the current user shouldn't see. It is NOT for:
|
|
- Renaming fields (aliasing)
|
|
- Formatting dates (use Rsx_Date/Rsx_Time on client)
|
|
- Adding computed properties that should be in enums
|
|
- Reshaping data for frontend convenience
|
|
|
|
ANTI-ALIASING POLICY
|
|
|
|
RSpade considers aliasing in fetch() an anti-pattern. Enum magic properties use
|
|
BEM-style double underscore naming specifically to be grepable and self-documenting.
|
|
|
|
Why aliasing is harmful:
|
|
1. Makes grep searches unreliable (can't find all usages of type_id__label)
|
|
2. Adds no value (we're not paying by the byte in source code)
|
|
3. Creates maintenance burden (two names for the same thing)
|
|
4. Obscures the data source (is 'type_label' a DB column or computed?)
|
|
|
|
VALID PATTERNS IN FETCH()
|
|
|
|
1. Security removal (the primary purpose):
|
|
unset($data['password_hash']);
|
|
unset($data['api_secret']);
|
|
|
|
2. Model method with MATCHING name:
|
|
$data['full_name'] = $model->full_name();
|
|
$data['formatted_address'] = $model->formatted_address();
|
|
$data['unread_count'] = $model->unread_count();
|
|
|
|
The key MUST match the method name. This ensures:
|
|
- Single source of truth (method defines the computation)
|
|
- Same name in PHP and JavaScript
|
|
- Grepable across codebase
|
|
|
|
3. Conditional with matching property or method:
|
|
$data['foo'] = $condition ? $model->foo : null;
|
|
$data['jazz'] = $model->jazz_allowed() ? $model->jazz : 'Not permitted';
|
|
$data['secret'] = $user->is_admin ? $model->secret : '[REDACTED]';
|
|
|
|
Both sides of the ternary must use matching property/method names
|
|
or be literal values. This allows conditional defaults or permission-
|
|
based field masking.
|
|
|
|
INVALID PATTERNS (Violations)
|
|
|
|
1. Aliasing enum properties:
|
|
$data['type_label'] = $model->type_id__label; // BAD - use full name
|
|
$data['status_badge'] = $model->status_id__badge; // BAD - use full name
|
|
|
|
2. Date formatting:
|
|
$data['created_formatted'] = $model->created_at->format('M d'); // BAD
|
|
$data['updated_human'] = $model->updated_at->diffForHumans(); // BAD
|
|
// Dates are YYYY-mm-dd, datetimes are ISO UTC - format on client!
|
|
|
|
3. Relationship plucking without method:
|
|
$data['client_name'] = $model->client->name; // BAD - make a method
|
|
|
|
4. Inline computations:
|
|
$data['is_owner'] = $model->user_id === Session::get_user_id(); // BAD
|
|
// Make a method: $data['is_owner'] = $model->is_owner();
|
|
|
|
5. Redundant explicit assignments:
|
|
$data['id'] = $model->id; // UNNECESSARY - already in toArray()
|
|
$data['name'] = $model->name; // UNNECESSARY - already in toArray()
|
|
|
|
6. Mismatched method names:
|
|
$data['addr'] = $model->formatted_address(); // BAD - name must match
|
|
// CORRECT: $data['formatted_address'] = $model->formatted_address();
|
|
|
|
CLIENT-SIDE PATTERNS
|
|
|
|
Use full BEM-style names in JavaScript:
|
|
contact.type_id__label
|
|
contact.type_id__icon
|
|
|
|
Format dates on client:
|
|
Rsx_Date.format(contact.created_at) // "Dec 24, 2025"
|
|
Rsx_Time.relative(contact.updated_at) // "2 hours ago"
|
|
|
|
JAVASCRIPT USAGE
|
|
Single Record Fetching:
|
|
// fetch() throws if record not found - no need to check for null/false
|
|
const project = await Project_Model.fetch(1);
|
|
console.log(project.name);
|
|
console.log(project.status_label); // Enum properties populated
|
|
|
|
// fetch() errors are caught by Universal_Error_Page_Component automatically
|
|
// in SPA actions, or can be caught with try/catch if needed
|
|
|
|
Fetch With Null Fallback:
|
|
// Use fetch_or_null() when you want graceful handling of missing records
|
|
const order = await Order_Model.fetch_or_null(999);
|
|
if (!order) {
|
|
console.log('Order not found or access denied');
|
|
return;
|
|
}
|
|
// order is guaranteed to exist here
|
|
|
|
When to Use Each:
|
|
- fetch() - View/edit pages where record MUST exist (throws on not found)
|
|
- fetch_or_null() - Optional lookups where missing is valid (returns null)
|
|
|
|
Enum Properties on Instances (BEM-style double underscore):
|
|
const project = await Project_Model.fetch(1);
|
|
|
|
// All enum helper properties from PHP are available
|
|
console.log(project.status_id); // 2 (raw value)
|
|
console.log(project.status_id__label); // "Active"
|
|
console.log(project.status_id__badge); // "bg-success"
|
|
|
|
Static Enum Constants:
|
|
// Constants available on the class
|
|
if (project.status_id === Project_Model.STATUS_ACTIVE) {
|
|
console.log('Project is active');
|
|
}
|
|
|
|
// Get all enum values for dropdowns
|
|
const statusOptions = Project_Model.status_id__enum_select();
|
|
// {1: "Planning", 2: "Active", 3: "On Hold", ...}
|
|
|
|
// Get full enum config with all metadata
|
|
const statusConfig = Project_Model.status_id__enum();
|
|
// {1: {label: "Planning", badge: "bg-info"}, ...}
|
|
|
|
// Get single enum's metadata by ID
|
|
const activeConfig = Project_Model.status_id__enum(Project_Model.STATUS_ACTIVE);
|
|
// {label: "Active", badge: "bg-success", ...}
|
|
|
|
Error Handling:
|
|
// In SPA actions, errors bubble up to Universal_Error_Page_Component
|
|
// No try/catch needed - just call fetch() and use the result
|
|
async on_load() {
|
|
this.data.user = await User_Model.fetch(this.args.id);
|
|
// If we get here, user exists and we have access
|
|
}
|
|
|
|
// For explicit error handling outside SPA context:
|
|
try {
|
|
const user = await User_Model.fetch(userId);
|
|
updateUserInterface(user);
|
|
} catch (error) {
|
|
if (error.code === Ajax.ERROR_NOT_FOUND) {
|
|
showNotFoundMessage();
|
|
} else {
|
|
showErrorMessage(error.message);
|
|
}
|
|
}
|
|
|
|
JAVASCRIPT CLASS ARCHITECTURE
|
|
Class Hierarchy:
|
|
The framework generates a three-level class hierarchy for each model:
|
|
|
|
Rsx_Js_Model // Framework base (fetch, refresh, toObject)
|
|
└── Base_Project_Model // Generated stub (enums, constants, relationships)
|
|
└── Project_Model // Concrete class (auto-generated or user-defined)
|
|
|
|
Base Stub Classes (Auto-Generated):
|
|
For each PHP model extending Rsx_Model_Abstract, the framework generates
|
|
a Base_* stub class with:
|
|
|
|
class Base_Project_Model extends Rsx_Js_Model {
|
|
static __MODEL = 'Project_Model'; // PHP class name for API calls
|
|
|
|
// Enum constants
|
|
static STATUS_PLANNING = 1;
|
|
static STATUS_ACTIVE = 2;
|
|
|
|
// Enum accessor methods (BEM-style double underscore)
|
|
static status__enum() { ... } // Full enum config (all values)
|
|
static status__enum(id) { ... } // Single enum's metadata by ID
|
|
static status__enum_select() { ... } // For dropdown population
|
|
static status__enum_labels() { ... } // Simple id => label map
|
|
static status__enum_ids() { ... } // Array of valid IDs
|
|
|
|
// Relationship discovery
|
|
static get_relationships() { ... } // Returns array of names
|
|
|
|
// Relationship methods (async, lazy-loaded)
|
|
async client() { ... } // belongsTo → Model or null
|
|
async tasks() { ... } // hasMany → Model[]
|
|
}
|
|
|
|
Concrete Classes:
|
|
Concrete classes (without Base_ prefix) are what you use in application code.
|
|
They are either auto-generated or user-defined.
|
|
|
|
Auto-Generated (default):
|
|
If no custom JS file exists, the bundle compiler generates:
|
|
class Project_Model extends Base_Project_Model {}
|
|
|
|
User-Defined (optional):
|
|
Create a JS file with matching class name to add custom methods:
|
|
|
|
// rsx/models/Project_Model.js
|
|
class Project_Model extends Base_Project_Model {
|
|
get_display_title() {
|
|
return `${this.name} (${this.status_label})`;
|
|
}
|
|
}
|
|
|
|
Custom Base Class (Optional):
|
|
Configure a custom base class in rsx/resource/config/rsx.php to add
|
|
application-wide model functionality:
|
|
|
|
'js_model_base_class' => 'App_Model_Abstract',
|
|
|
|
Hierarchy becomes:
|
|
Rsx_Js_Model → App_Model_Abstract → Base_Project_Model → Project_Model
|
|
|
|
Bundle Integration:
|
|
- Base_* stubs auto-included when PHP model is in bundle
|
|
- Concrete classes auto-generated unless user-defined JS exists
|
|
- User-defined JS classes validated to extend Base_* directly
|
|
- Error thrown if custom JS exists in manifest but not in bundle
|
|
|
|
AUTHORIZATION PATTERNS
|
|
User-Specific Access:
|
|
class User_Profile_Model extends Rsx_Model_Abstract
|
|
{
|
|
#[Ajax_Endpoint_Model_Fetch]
|
|
public static function fetch($id)
|
|
{
|
|
$current_user = Session::get_user();
|
|
|
|
// Users can only fetch their own profile
|
|
if (!$current_user || $current_user->id != $id) {
|
|
return false;
|
|
}
|
|
|
|
return static::find($id);
|
|
}
|
|
}
|
|
|
|
Role-Based Access:
|
|
class Admin_Report_Model extends Rsx_Model_Abstract
|
|
{
|
|
#[Ajax_Endpoint_Model_Fetch]
|
|
public static function fetch($id)
|
|
{
|
|
$user = Session::get_user();
|
|
|
|
// Only admin users can fetch reports
|
|
if (!$user || !$user->hasRole('admin')) {
|
|
return false;
|
|
}
|
|
|
|
return static::find($id);
|
|
}
|
|
}
|
|
|
|
Public Data Access:
|
|
class Public_Article_Model extends Rsx_Model_Abstract
|
|
{
|
|
#[Ajax_Endpoint_Model_Fetch]
|
|
public static function fetch($id)
|
|
{
|
|
// Public articles can be fetched by anyone
|
|
$article = static::find($id);
|
|
|
|
// But only return published articles
|
|
if ($article && $article->status === 'published') {
|
|
return $article;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
BASE MODEL PROTECTION
|
|
Default Behavior:
|
|
The base Rsx_Model::fetch() method throws an exception with
|
|
clear instructions on how to implement fetch() in your model.
|
|
|
|
Exception Message:
|
|
"Model MyModel does not implement fetch() method. To enable Ajax
|
|
fetching, add #[Ajax_Endpoint_Model_Fetch] attribute and implement
|
|
static fetch($id) method with authorization checks."
|
|
|
|
Purpose:
|
|
- Prevents accidental exposure of model data
|
|
- Forces developers to explicitly handle security
|
|
- Provides clear implementation guidance
|
|
- Ensures no models are fetchable by default
|
|
|
|
CODE QUALITY VALIDATION
|
|
The framework validates #[Ajax_Endpoint_Model_Fetch] attribute placement at
|
|
manifest build time. The attribute can ONLY be applied to:
|
|
|
|
1. Methods marked with #[Relationship] - exposes relationship to JavaScript
|
|
2. The static fetch($id) method - enables Model.fetch() in JavaScript
|
|
|
|
Invalid Placement:
|
|
// This will fail code quality check (MODEL-AJAX-FETCH-01)
|
|
#[Ajax_Endpoint_Model_Fetch]
|
|
public function get_display_name() // Not a relationship or fetch()
|
|
{
|
|
return $this->name;
|
|
}
|
|
|
|
For Custom Server-Side Methods:
|
|
If you need a custom method accessible from JavaScript:
|
|
1. Create a JS class extending Base_{ModelName}
|
|
2. Create an Ajax endpoint on an appropriate controller
|
|
3. Add the method to the JS class calling the Ajax endpoint
|
|
|
|
Example:
|
|
// PHP Controller
|
|
#[Ajax_Endpoint]
|
|
public static function get_project_stats(Request $request, array $params = []) {
|
|
return Project_Model::calculate_stats($params['id']);
|
|
}
|
|
|
|
// JavaScript (rsx/models/Project_Model.js)
|
|
class Project_Model extends Base_Project_Model {
|
|
async get_stats() {
|
|
return await Project_Controller.get_project_stats({id: this.id});
|
|
}
|
|
}
|
|
|
|
TESTING FETCH METHODS
|
|
PHP Testing:
|
|
// Test authorization
|
|
$user = User_Model::factory()->create();
|
|
Session::login($user);
|
|
|
|
$product = Product_Model::fetch(1);
|
|
$this->assertNotFalse($product);
|
|
|
|
Session::logout();
|
|
$product = Product_Model::fetch(1);
|
|
$this->assertFalse($product);
|
|
|
|
JavaScript Testing (via rsx:debug):
|
|
php artisan rsx:debug /demo --eval="Product_Model.fetch(1).then(r => console.log(r))"
|
|
|
|
COMMON PATTERNS
|
|
Soft Delete Handling:
|
|
public static function fetch($id)
|
|
{
|
|
// Only return non-deleted records
|
|
$model = static::where('id', $id)->whereNull('deleted_at')->first();
|
|
return $model ?: false;
|
|
}
|
|
|
|
Relationship Preloading:
|
|
public static function fetch($id)
|
|
{
|
|
$model = static::with(['category', 'tags'])->find($id);
|
|
return $model ?: false;
|
|
}
|
|
|
|
Conditional Field Removal:
|
|
public static function fetch($id)
|
|
{
|
|
$model = static::find($id);
|
|
if (!$model) return false;
|
|
|
|
$user = Session::get_user();
|
|
if (!$user || !$user->is_admin) {
|
|
// Remove admin-only fields for non-admin users
|
|
unset($model->internal_notes);
|
|
unset($model->cost_price);
|
|
}
|
|
|
|
return $model;
|
|
}
|
|
|
|
AUTOMATIC HYDRATION
|
|
All Ajax responses are automatically processed to convert objects with a
|
|
__MODEL property into proper JavaScript class instances. This happens
|
|
transparently in the Ajax layer.
|
|
|
|
How It Works:
|
|
1. PHP model's toArray() includes __MODEL property with class name
|
|
2. Ajax layer receives response with __MODEL: "Project_Model"
|
|
3. Ajax calls Rsx_Js_Model._instantiate_models_recursive() on response
|
|
4. Hydrator looks up class via Manifest.get_class_by_name()
|
|
5. If class extends Rsx_Js_Model, creates instance: new Project_Model(data)
|
|
6. Instance constructor strips __MODEL, assigns remaining properties
|
|
|
|
Result:
|
|
const project = await Project_Model.fetch(1);
|
|
console.log(project.constructor.name); // "Project_Model"
|
|
console.log(project instanceof Rsx_Js_Model); // true
|
|
|
|
Recursive Hydration:
|
|
The hydrator processes nested objects and arrays recursively. Any object
|
|
with __MODEL property at any depth will be instantiated as its class.
|
|
|
|
// If PHP returns related models in response:
|
|
{
|
|
id: 1,
|
|
name: "Project Alpha",
|
|
client: {
|
|
id: 5,
|
|
name: "Acme Corp",
|
|
__MODEL: "Client_Model"
|
|
},
|
|
__MODEL: "Project_Model"
|
|
}
|
|
|
|
// Both objects are hydrated:
|
|
project instanceof Project_Model; // true
|
|
project.client instanceof Client_Model; // true
|
|
|
|
Global Behavior:
|
|
This hydration applies to ALL Ajax responses, not just fetch() calls.
|
|
Any Ajax endpoint returning objects with __MODEL properties will have
|
|
them automatically instantiated as proper JavaScript class instances.
|
|
|
|
TROUBLESHOOTING
|
|
Model Not Fetchable:
|
|
- Verify #[Ajax_Endpoint_Model_Fetch] attribute present
|
|
- Check fetch() method is static and public
|
|
- Ensure method accepts single $id parameter
|
|
- Verify model included in bundle manifest
|
|
|
|
Authorization Failures:
|
|
- Check Session::is_logged_in() and Session::get_user() values
|
|
- Verify authorization logic in fetch() method
|
|
- Test with different user roles and permissions
|
|
- Use rsx_dump_die() to debug authorization flow
|
|
|
|
JavaScript Stub Missing:
|
|
- Verify model discovered during manifest build
|
|
- Check bundle includes model's directory
|
|
- Ensure bundle compiles without errors
|
|
- Confirm JavaScript bundle loads in browser
|
|
|
|
JAVASCRIPT ORM ARCHITECTURE
|
|
The JavaScript ORM provides secure, read-only access to server-side models
|
|
through explicit opt-in. This section describes the full architecture vision.
|
|
|
|
Design Philosophy:
|
|
- Read-only ORM: No save/create/delete operations from JavaScript
|
|
- Explicit opt-in: Each model controls its own fetchability
|
|
- Individual authorization: Per-record security checks
|
|
- No query builder: Search/filter handled by dedicated Ajax endpoints
|
|
- Instance methods: Records come as objects with helper methods
|
|
|
|
What JavaScript ORM DOES:
|
|
- Fetch individual records by ID
|
|
- Provide enum helper methods on instances
|
|
- Load related records via lazy relationship methods
|
|
- Access attachments associated with records (see FUTURE DEVELOPMENT)
|
|
- Receive real-time updates via websocket (see FUTURE DEVELOPMENT)
|
|
|
|
What JavaScript ORM does NOT do:
|
|
- Save or update records (use Ajax endpoints)
|
|
- Create new records (use Ajax endpoints)
|
|
- Delete records (use Ajax endpoints)
|
|
- Search or query records (use DataGrid or Ajax)
|
|
- Eager load relationships (lazy load only)
|
|
|
|
LAZY RELATIONSHIPS
|
|
Related records load through async methods that mirror PHP relationships.
|
|
Each relationship method returns a Promise resolving to the related model(s).
|
|
|
|
Supported Relationship Types:
|
|
- belongsTo → Returns single model instance or null
|
|
- hasOne → Returns single model instance or null
|
|
- hasMany → Returns array of model instances (may be empty)
|
|
- morphTo → Returns single model instance or null
|
|
- morphOne → Returns single model instance or null
|
|
- morphMany → Returns array of model instances (may be empty)
|
|
|
|
JavaScript Usage:
|
|
// Load belongsTo relationship
|
|
const contact = await Contact_Model.fetch(123);
|
|
const client = await contact.client(); // Returns Client_Model or null
|
|
|
|
// Load hasMany relationship
|
|
const client = await Client_Model.fetch(456);
|
|
const contacts = await client.contacts(); // Returns Contact_Model[]
|
|
|
|
// Load morphMany relationship
|
|
const project = await Project_Model.fetch(1);
|
|
const tasks = await project.tasks(); // Returns Task_Model[]
|
|
|
|
// Chain relationships
|
|
const contact = await Contact_Model.fetch(123);
|
|
const client = await contact.client();
|
|
if (client) {
|
|
const contacts = await client.contacts();
|
|
}
|
|
|
|
Get Available Relationships:
|
|
// Static method returns array of relationship names
|
|
const rels = Project_Model.get_relationships();
|
|
// ["client", "contact", "tasks", "created_by", "owner"]
|
|
|
|
Security Model:
|
|
1. Source model's fetch() called first to verify access to parent record
|
|
2. Relationship method called to get related IDs (efficient pluck query)
|
|
3. Each related record passed through its model's fetch() for authorization
|
|
4. Only records passing fetch() security check are returned
|
|
|
|
Enabling Relationships for Ajax Fetch:
|
|
Relationships require BOTH attributes to be fetchable from JavaScript:
|
|
|
|
#[Relationship]
|
|
#[Ajax_Endpoint_Model_Fetch]
|
|
public function contacts()
|
|
{
|
|
return $this->hasMany(Contact_Model::class, 'client_id');
|
|
}
|
|
|
|
Without #[Ajax_Endpoint_Model_Fetch], the relationship will not appear in
|
|
get_relationships() and attempting to fetch it will return an error message
|
|
instructing the developer to add the attribute.
|
|
|
|
Implementation Notes:
|
|
- #[Relationship] defines the method as a Laravel relationship
|
|
- #[Ajax_Endpoint_Model_Fetch] exposes it to the JavaScript ORM
|
|
- Only relationships with BOTH attributes are included in JS stubs
|
|
- Related model must also have fetch() with #[Ajax_Endpoint_Model_Fetch]
|
|
- Singular relationships return null if not found or unauthorized
|
|
- Plural relationships return empty array if none found/authorized
|
|
|
|
ENUM PROPERTIES
|
|
Enum values are exposed as properties on fetched model instances, mirroring
|
|
the PHP magic property behavior. Each custom field defined in the enum
|
|
becomes a property using BEM-style naming: {column}__{field} (double underscore).
|
|
|
|
PHP Enum Definition:
|
|
public static $enums = [
|
|
'status_id' => [
|
|
1 => ['constant' => 'STATUS_PLANNING', 'label' => 'Planning', 'badge' => 'bg-info'],
|
|
2 => ['constant' => 'STATUS_ACTIVE', 'label' => 'Active', 'badge' => 'bg-success'],
|
|
3 => ['constant' => 'STATUS_ON_HOLD', 'label' => 'On Hold', 'badge' => 'bg-warning'],
|
|
],
|
|
];
|
|
|
|
Resulting JavaScript Instance Properties (BEM-style double underscore):
|
|
const project = await Project_Model.fetch(123);
|
|
|
|
// Raw enum value
|
|
project.status_id // 2
|
|
|
|
// Auto-generated properties from enum definition
|
|
project.status_id__label // "Active"
|
|
project.status_id__badge // "bg-success"
|
|
|
|
// All custom fields become properties
|
|
// If enum had 'button_class' => 'btn-success':
|
|
project.status_id__button_class // "btn-success"
|
|
|
|
Static Enum Constants:
|
|
// Constants available on the class (from 'constant' field)
|
|
if (project.status_id === Project_Model.STATUS_ACTIVE) {
|
|
console.log('Project is active');
|
|
}
|
|
|
|
Static Enum Methods (BEM-style double underscore):
|
|
// Get full enum config (all values with metadata)
|
|
const statusConfig = Project_Model.status_id__enum();
|
|
// {1: {label: "Planning", badge: "bg-info"}, 2: {...}, ...}
|
|
|
|
// Get single enum's metadata by ID
|
|
const activeConfig = Project_Model.status_id__enum(Project_Model.STATUS_ACTIVE);
|
|
// {label: "Active", badge: "bg-success", ...}
|
|
|
|
// Get enum values for dropdown population (id => label)
|
|
const statusOptions = Project_Model.status_id__enum_select();
|
|
// {1: "Planning", 2: "Active", 3: "On Hold"}
|
|
|
|
// Get simple id => label map (all items)
|
|
const labels = Project_Model.status_id__enum_labels();
|
|
// {1: "Planning", 2: "Active", 3: "On Hold"}
|
|
|
|
// Get array of valid IDs
|
|
const ids = Project_Model.status_id__enum_ids();
|
|
// [1, 2, 3]
|
|
|
|
MODEL CONSTANTS
|
|
All public constants defined on a PHP model are automatically exported to
|
|
the JavaScript stub as static properties. This includes both enum constants
|
|
(generated from $enums) and manually defined constants.
|
|
|
|
PHP Model Definition:
|
|
class User_Model extends Rsx_Model_Abstract
|
|
{
|
|
// Enum constants (auto-generated by rsx:migrate:document_models)
|
|
const ROLE_ADMIN = 1;
|
|
const ROLE_USER = 2;
|
|
|
|
// Permission constants (manually defined)
|
|
const PERM_MANAGE_USERS = 1;
|
|
const PERM_EDIT_DATA = 2;
|
|
const PERM_VIEW_DATA = 3;
|
|
|
|
// Invitation status constants
|
|
const INVITATION_PENDING = 'pending';
|
|
const INVITATION_ACCEPTED = 'accepted';
|
|
const INVITATION_EXPIRED = 'expired';
|
|
}
|
|
|
|
JavaScript Usage:
|
|
// All constants available as static properties
|
|
if (permission === User_Model.PERM_MANAGE_USERS) {
|
|
showAdminPanel();
|
|
}
|
|
|
|
if (invite.status === User_Model.INVITATION_PENDING) {
|
|
showPendingBadge();
|
|
}
|
|
|
|
SECURITY WARNING:
|
|
All public constants are exported to JavaScript and visible in the
|
|
browser. NEVER put sensitive values in model constants:
|
|
|
|
// NEVER DO THIS - secrets visible in browser
|
|
const API_SECRET = 'sk-abc123...';
|
|
const ENCRYPTION_KEY = 'my-secret-key';
|
|
|
|
Use private constants for sensitive values:
|
|
|
|
// Safe - not exported to JavaScript
|
|
private const API_SECRET = 'sk-abc123...';
|
|
|
|
Constant Types Exported:
|
|
- Enum constants (from $enums 'constant' field)
|
|
- Non-enum public constants (string, int, float, bool, array)
|
|
- Constants with no visibility modifier (treated as public)
|
|
|
|
Constants NOT Exported:
|
|
- Private constants (private const FOO = 1)
|
|
- Protected constants (protected const BAR = 2)
|
|
- Constants inherited from parent classes
|
|
- Framework constants from Rsx_Model_Abstract
|
|
|
|
===============================================================================
|
|
FUTURE DEVELOPMENT
|
|
===============================================================================
|
|
|
|
The features below are planned enhancements. They will be additive and will not
|
|
break the existing API. Implementation priority and timeline are not committed.
|
|
|
|
ATTACHMENTS INTEGRATION (planned)
|
|
File attachments accessible through model instances.
|
|
|
|
JavaScript Usage (API TBD):
|
|
const project = await Project_Model.fetch(123);
|
|
|
|
// Get all attachments
|
|
const attachments = await project.attachments();
|
|
|
|
// Get specific attachment type
|
|
const documents = await project.attachments('documents');
|
|
|
|
// Get attachment URLs
|
|
attachments.forEach(att => {
|
|
console.log(att.url); // View URL
|
|
console.log(att.download_url); // Download URL
|
|
console.log(att.thumbnail_url); // Thumbnail (images)
|
|
});
|
|
|
|
DATE HANDLING (planned)
|
|
Date fields converted to JavaScript Date objects or date library instances.
|
|
Implementation details TBD - may use native Date or dayjs.
|
|
|
|
JavaScript Usage (API TBD):
|
|
const project = await Project_Model.fetch(123);
|
|
|
|
// Access date fields
|
|
project.created_at // Date object or dayjs instance
|
|
project.due_date // Date object or dayjs instance
|
|
|
|
// Format dates
|
|
project.created_at.format('YYYY-MM-DD') // If dayjs
|
|
project.created_at.toLocaleDateString() // If native Date
|
|
|
|
REAL-TIME UPDATES (planned)
|
|
Model instances can receive real-time updates via websocket.
|
|
|
|
JavaScript Usage (API TBD):
|
|
const project = await Project_Model.fetch(123);
|
|
|
|
// Subscribe to updates
|
|
project.subscribe((updatedProject) => {
|
|
// Called when server broadcasts changes
|
|
updateUI(updatedProject);
|
|
});
|
|
|
|
// Or use event-based approach
|
|
project.on('update', (changes) => {
|
|
// Partial update with changed fields
|
|
});
|
|
|
|
TAGS SYSTEM (future)
|
|
Tags integration for taggable models (implementation TBD).
|
|
|
|
Potential API:
|
|
const project = await Project_Model.fetch(123);
|
|
const tags = await project.tags(); // ['urgent', 'q4-2025']
|
|
|
|
FETCH BATCHING (planned, Phase 2)
|
|
Automatic batching of multiple fetch requests made within same tick.
|
|
Reduces HTTP requests and database queries when loading multiple records.
|
|
|
|
Behavior:
|
|
- Multiple Model.fetch() calls in same tick batched into single request
|
|
- Server groups requests by model, uses IN clause for efficient queries
|
|
- Only enabled when Ajax request batching is enabled (config)
|
|
- Integrates with existing Ajax batching system
|
|
|
|
Example - Without Batching (3 HTTP requests, 3 queries):
|
|
const client = await Client_Model.fetch(1);
|
|
const contact = await Contact_Model.fetch(5);
|
|
const project = await Project_Model.fetch(10);
|
|
|
|
Example - With Batching (1 HTTP request, 3 queries):
|
|
// Same code, but requests batched automatically
|
|
// Server receives: {Client_Model: [1], Contact_Model: [5], Project_Model: [10]}
|
|
|
|
Example - Same Model Batching (1 HTTP request, 1 query with IN clause):
|
|
const clients = await Promise.all([
|
|
Client_Model.fetch(1),
|
|
Client_Model.fetch(2),
|
|
Client_Model.fetch(3)
|
|
]);
|
|
// Server receives: {Client_Model: [1, 2, 3]}
|
|
// Executes: SELECT * FROM clients WHERE id IN (1, 2, 3)
|
|
|
|
Server Implementation:
|
|
- Orm_Controller receives batched request with model->ids map
|
|
- Groups IDs by model class
|
|
- Executes single query per model using WHERE id IN (...)
|
|
- Returns results keyed by model and id
|
|
- Client resolves individual promises from batched response
|
|
|
|
Prerequisites:
|
|
- Ajax request batching enabled in config
|
|
- Works alongside existing Ajax.call() batching
|
|
|
|
JQHTML CACHE INTEGRATION (planned, Phase 2, low priority)
|
|
Integration with jqhtml's component caching for instant first renders.
|
|
Visual polish feature - avoids brief loading indicators, not a performance gain.
|
|
|
|
Problem:
|
|
- jqhtml caches on_load results for instant duplicate component renders
|
|
- First render still shows loading indicator while fetching
|
|
- If ORM data already cached from prior fetch, loading indicator unnecessary
|
|
|
|
Solution:
|
|
- Provide jqhtml with mock data fetch functions during first render
|
|
- Mock functions check ORM cache, return cached data if available
|
|
- If cache hit: on_load completes synchronously, no loading indicator
|
|
- If cache miss: falls back to normal async fetch with loading indicator
|
|
|
|
Example Scenario:
|
|
// User views Contact #5, data cached
|
|
const contact = await Contact_Model.fetch(5);
|
|
|
|
// User navigates away, then back to same contact
|
|
// Without integration: loading indicator shown briefly
|
|
// With integration: cached data used, renders instantly
|
|
|
|
Implementation Notes:
|
|
- ORM maintains client-side cache of fetched records
|
|
- jqhtml receives cache-aware fetch stubs during render
|
|
- Cache keyed by model name + id
|
|
- Cache invalidation TBD (TTL, manual, websocket push)
|
|
|
|
Priority: Low - purely visual improvement, no server/client perf benefit
|
|
|
|
BATCH SECURITY OPTIMIZATION (todo)
|
|
Current Limitation:
|
|
The static fetch($id) method signature accepts a single ID, which means
|
|
when fetching multiple related records (e.g., via relationship loading),
|
|
each record requires a separate fetch($id) call and database query.
|
|
|
|
This prevents optimization with IN (id1, id2, ...) queries when loading
|
|
relationship result sets.
|
|
|
|
Problem Scenario:
|
|
// Loading a client's 50 contacts via relationship
|
|
// Currently requires 50 individual fetch() calls:
|
|
foreach ($contact_ids as $id) {
|
|
$contact = Contact_Model::fetch($id); // Individual query each
|
|
}
|
|
|
|
// Cannot optimize to:
|
|
// SELECT * FROM contacts WHERE id IN (1, 2, 3, ...)
|
|
|
|
Proposed Solutions:
|
|
|
|
Option A - Query Prefetch Cache:
|
|
Before iterating through relationship results, prefetch all IDs
|
|
with a single query and cache the results. Individual fetch() calls
|
|
then hit the cache instead of the database.
|
|
|
|
// Pseudocode
|
|
$ids = $relationship->pluck('id');
|
|
static::prefetch_cache($ids); // Single IN query, cache results
|
|
foreach ($ids as $id) {
|
|
$record = static::fetch($id); // Hits cache, not database
|
|
}
|
|
|
|
Benefits: No API change, backwards compatible
|
|
Drawback: Cache management complexity, memory usage
|
|
|
|
Option B - Batch fetch() Variant:
|
|
Add a new method like fetch_batch($ids) that accepts an array
|
|
and returns filtered results using single query.
|
|
|
|
// fetch_batch must apply same security logic as fetch()
|
|
public static function fetch_batch(array $ids) {
|
|
$records = static::whereIn('id', $ids)->get();
|
|
return $records->filter(fn($r) => static::can_fetch($r));
|
|
}
|
|
|
|
Benefits: Clean API, explicit batch operation
|
|
Drawback: Requires refactoring existing security logic
|
|
|
|
Option C - Automatic Query Batching Layer:
|
|
Database query layer automatically batches identical queries
|
|
made within same request cycle using query deduplication.
|
|
|
|
Benefits: Transparent, no code changes
|
|
Drawback: Complex implementation, limited optimization scope
|
|
|
|
Recommended Approach:
|
|
Option A (Query Prefetch Cache) is likely the best balance of
|
|
simplicity and effectiveness. It can be implemented without changing
|
|
the fetch() API contract, and the cache can be request-scoped to
|
|
avoid memory/staleness issues.
|
|
|
|
Implementation Priority: Medium
|
|
Required before relationship fetching is considered production-ready
|
|
for models with large relationship sets.
|
|
|
|
OPT-IN FETCH CACHING (todo)
|
|
Current Limitation:
|
|
When a model's fetch() method returns an array (for augmented data),
|
|
relationship fetching must call the database twice: once via fetch()
|
|
to verify access, and once via find() to get the actual Eloquent model
|
|
for relationship method calls.
|
|
|
|
Proposed Solution - Request-Scoped Cache:
|
|
Implement opt-in caching of fetch() results scoped to the current
|
|
page/action lifecycle. Cache automatically invalidates on navigation
|
|
or form submission.
|
|
|
|
Cache Scope:
|
|
- Traditional pages: Single page instance (cache lives until navigation)
|
|
- SPA applications: Single SPA action (cache resets on action navigation)
|
|
|
|
Cache Invalidation Events:
|
|
1. Page navigation (traditional or SPA)
|
|
2. Rsx_Form submission (any form submit clears cache)
|
|
3. Developer-initiated (explicit cache clear call)
|
|
|
|
Opt-In API (TBD):
|
|
// Enable caching for a model
|
|
class Project_Model extends Rsx_Model_Abstract {
|
|
protected static $fetch_cache_enabled = true;
|
|
}
|
|
|
|
// Manual invalidation
|
|
Project_Model.clear_fetch_cache(); // Clear single model cache
|
|
Rsx_Js_Model.clear_all_fetch_caches(); // Clear all model caches
|
|
|
|
Implementation Notes:
|
|
- Cache keyed by model class + record ID
|
|
- Cache stores the raw fetch() result (model or array)
|
|
- For array results, also cache the underlying Eloquent model
|
|
- Relationship fetching checks cache before database query
|
|
- SPA integration via Spa.on('action:change') event
|
|
- Form integration via Rsx_Form.on('submit') event
|
|
|
|
Benefits:
|
|
- Eliminates duplicate database queries for relationship fetching
|
|
- Enables instant re-renders when returning to cached records
|
|
- Predictable invalidation tied to user actions
|
|
- Opt-in prevents unexpected caching behavior
|
|
|
|
Implementation Priority: Medium
|
|
Required before relationship fetching is considered production-ready
|
|
for models where fetch() returns augmented arrays.
|
|
|
|
PAGINATED RELATIONSHIP RESULTS (todo, low priority)
|
|
Current Limitation:
|
|
Relationship methods return all related records at once. For models
|
|
with large relationship sets (e.g., a client with 500 contacts),
|
|
this causes excessive data transfer and memory usage.
|
|
|
|
Proposed Solution - Paginated Relationship API:
|
|
Design a JavaScript API for paginated relationship fetching that
|
|
feels natural and is easy to use for common UI patterns like
|
|
infinite scroll, "load more" buttons, and traditional pagination.
|
|
|
|
API Design Goals:
|
|
- Simple default case: first page with sensible limit
|
|
- Easy iteration for "load more" patterns
|
|
- Support for offset/limit and cursor-based pagination
|
|
- Chainable or async iterator patterns
|
|
- Clear indication of "has more" and total count
|
|
|
|
Potential API Patterns (TBD):
|
|
|
|
Option A - Paginated Method Variant:
|
|
const page1 = await client.contacts_paginated({limit: 20});
|
|
// { data: Contact_Model[], has_more: true, total: 150, next_cursor: '...' }
|
|
|
|
const page2 = await client.contacts_paginated({cursor: page1.next_cursor});
|
|
|
|
Option B - Async Iterator:
|
|
for await (const contact of client.contacts_iterator({batch: 20})) {
|
|
// Yields one Contact_Model at a time, fetches in batches
|
|
}
|
|
|
|
Option C - Collection Object:
|
|
const contacts = await client.contacts({paginate: true, limit: 20});
|
|
// Returns Paginated_Collection object
|
|
|
|
contacts.data // Contact_Model[] (current page)
|
|
contacts.has_more // boolean
|
|
contacts.total // number (if available)
|
|
await contacts.next() // Load next page, returns same collection
|
|
await contacts.all() // Load all remaining (with warning for large sets)
|
|
|
|
Option D - Parameter on Existing Method:
|
|
const contacts = await client.contacts({limit: 20, offset: 0});
|
|
// Returns array but truncated to limit
|
|
|
|
const more = await client.contacts({limit: 20, offset: 20});
|
|
|
|
Server-Side Considerations:
|
|
- Orm_Controller::fetch_relationship() needs pagination params
|
|
- Efficient COUNT query for total (optional, can skip for performance)
|
|
- Cursor-based pagination for stable ordering during iteration
|
|
- Security: pagination params must not bypass fetch() security checks
|
|
|
|
UI Integration Patterns:
|
|
- DataGrid: Already handles pagination, may not need ORM integration
|
|
- Detail views: "Show more" buttons for related records
|
|
- Infinite scroll: Async iterator or cursor-based pagination
|
|
- Modal lists: Traditional page numbers
|
|
|
|
Implementation Priority: Low
|
|
Most large relationship sets are better served by DataGrid with
|
|
server-side filtering. This feature is for edge cases where
|
|
ORM-style access is preferred over DataGrid.
|
|
|
|
SEE ALSO
|
|
crud(3) - Standard CRUD implementation using Model.fetch()
|
|
controller(3) - Internal API attribute patterns
|
|
manifest_api(3) - Model discovery and stub generation
|
|
coding_standards(3) - Security patterns and authorization
|
|
enums(3) - Model enum definitions and magic properties
|
|
|
|
RSX Framework 2025-11-23 MODEL_FETCH(3) |