Fix bin/publish: copy docs.dist from project root

Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-10-21 02:08:33 +00:00
commit f6fac6c4bc
79758 changed files with 10547827 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
# Database System Documentation
## Migration Policy
- **Forward-only migrations** - No rollbacks, no down() methods
- **Use migration snapshots** for development safety (migrate:begin/commit/rollback)
- **All migrations must be created via artisan** - Manual creation is blocked by whitelist
- Use `php artisan make:migration:safe` to create whitelisted migrations
- **Sequential execution only** - No --path option allowed
- **Fail on errors** - No fallback to old schemas, migrations must succeed or fail loudly
## Database Rules
- **No mass assignment**: All fields assigned explicitly
- **No eager loading**: ALL eager loading methods throw exceptions - use explicit queries for relationships
- *Note: Premature optimization is the root of all evil. When you have thousands of concurrent users in production and N+1 queries become a real bottleneck, you'll be motivated enough to remove these restrictions yourself.*
- **Forward-only migrations**: No down() methods
## Naming Conventions
- **Primary keys**: All tables must have `id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY` (exception: sessions table uses Laravel's VARCHAR id)
- **Foreign keys**: Always suffix with `_id` (e.g., `user_id`, `site_id`)
- **Boolean columns**: Always prefix with `is_` (e.g., `is_active`, `is_published`)
- **Timestamp columns**: Always suffix with `_at` (e.g., `created_at`, `updated_at`, `deleted_at`)
- **Integer types**: Use BIGINT for all integers, TINYINT(1) for booleans only
- **No unsigned integers**: All integers should be signed for easier migrations
- **UTF-8 encoding**: All text columns use UTF-8 (utf8mb4_unicode_ci collation)
## Schema Rules
- **BIGINT ID required**: ALL tables must have `id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY` - no exceptions (SIGNED for easier migrations)
- **Raw SQL migrations**: Use DB::statement() with raw MySQL, not Laravel schema builder
- **Restricted commands**: Several dangerous commands disabled (migrate:rollback, db:wipe, etc.)
- **Required columns enforced**: All tables have created_by, updated_by, etc.
- **UTF-8 encoding standard**: All text columns use UTF-8
## Migration Commands
```bash
php artisan make:migration:safe # Create whitelisted migration
php artisan migrate:begin # Start migration snapshot session
php artisan migrate # Run migrations with safety checks
php artisan migrate:commit # Commit migration changes
php artisan migrate:rollback # Rollback to snapshot (stays in session)
```

View File

@@ -0,0 +1,470 @@
<?php
namespace App\RSpade\Core\Database;
use App\RSpade\Core\Bundle\BundleIntegration_Abstract;
use App\RSpade\Core\Manifest\Manifest;
/**
* Database integration for RSX framework
*
* Handles generation of JavaScript stub files for ORM models,
* enabling Ajax ORM functionality and relationship methods.
*/
class Database_BundleIntegration extends BundleIntegration_Abstract
{
/**
* Get the integration's unique identifier
*
* @return string Integration identifier
*/
public static function get_name(): string
{
return 'database';
}
/**
* Get file extensions handled by this integration
*
* Models are PHP files, but we don't need to register
* extensions as the PHP files are already handled by the core.
*
* @return array Empty array as no special extensions needed
*/
public static function get_file_extensions(): array
{
return [];
}
/**
* Generate JavaScript stub files for ORM models
*
* These stubs enable IDE autocomplete and provide relationship methods
* for models that extend Rsx_Model_Abstract.
*
* TODO: This function needs cleanup
*
* @param array &$manifest_data The complete manifest data (passed by reference)
* @return void
*/
public static function generate_manifest_stubs(array &$manifest_data): void
{
// Debug: Track when and why this is called
// static $call_count = 0;
// $call_count++;
// console_debug("STUB_GEN", "Database stub generation call #{$call_count}");
// // Show backtrace to understand the call flow
// $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10);
// foreach ($backtrace as $idx => $frame) {
// if ($idx === 0) continue; // Skip this method itself
// $file = isset($frame['file']) ? basename($frame['file']) : 'unknown';
// $line = $frame['line'] ?? '?';
// $function = $frame['function'] ?? 'unknown';
// $class = isset($frame['class']) ? basename(str_replace('\\', '/', $frame['class'])) : '';
// console_debug("STUB_GEN", " [{$idx}] {$class}::{$function}() at {$file}:{$line}");
// }
$stub_dir = storage_path('rsx-build/js-model-stubs');
// Create directory if it doesn't exist
if (!is_dir($stub_dir)) {
mkdir($stub_dir, 0755, true);
}
// Track generated stub files for cleanup
$generated_stubs = [];
// Get all models from the manifest
$model_entries = Manifest::php_get_extending('Rsx_Model_Abstract');
console_debug('STUB_GEN', 'Found ' . count($model_entries) . ' models extending Rsx_Model_Abstract');
foreach ($model_entries as $model_entry) {
if (!isset($model_entry['fqcn'])) {
continue;
}
$fqcn = $model_entry['fqcn'];
$class_name = $model_entry['class'] ?? '';
// Skip if it extends Rsx_System_Model_Abstract
if (static::_php_is_subclass_of($fqcn, 'Rsx_System_Model_Abstract', $manifest_data)) {
console_debug('STUB_GEN', " Skipping {$class_name}: extends Rsx_System_Model_Abstract");
continue;
}
// Load the class and its hierarchy
Manifest::_load_class_hierarchy($fqcn, $manifest_data);
// Verify class loaded
if (!class_exists($fqcn)) {
shouldnt_happen("Failed to load model class {$fqcn} after _load_class_hierarchy");
}
// Note: Abstract classes already filtered by php_get_extending()
console_debug('STUB_GEN', " Processing {$class_name} for stub generation...");
// Get model metadata from manifest
$file_path = $model_entry['file'] ?? '';
$metadata = isset($manifest_data['data']['files'][$file_path]) ? $manifest_data['data']['files'][$file_path] : [];
// Generate stub filename and paths
$stub_filename = static::_sanitize_model_stub_filename($class_name) . '.js';
// Check if user has created their own JS class
$user_class_exists = static::_check_user_model_class_exists($class_name, $manifest_data);
// Use Base_ prefix if user class exists
$stub_class_name = $user_class_exists ? 'Base_' . $class_name : $class_name;
$stub_filename = static::_sanitize_model_stub_filename($stub_class_name) . '.js';
$stub_relative_path = 'storage/rsx-build/js-model-stubs/' . $stub_filename;
$stub_full_path = base_path($stub_relative_path);
// Check if stub needs regeneration
$needs_regeneration = true;
if (file_exists($stub_full_path)) {
// Get mtime of source PHP file
$source_mtime = $metadata['mtime'] ?? 0;
$stub_mtime = filemtime($stub_full_path);
// Only regenerate if source is newer than stub
if ($stub_mtime >= $source_mtime) {
// Also check if the model metadata has changed
// by comparing a hash of enums, relationships, and columns
$model_metadata = [];
// Get relationships
$model_metadata['rel'] = $fqcn::get_relationships();
// Get enums
if (property_exists($fqcn, 'enums')) {
$model_metadata['enums'] = $fqcn::$enums ?? [];
}
// Get columns from models metadata if available
if (isset($manifest_data['data']['models'][$class_name]['columns'])) {
$model_metadata['columns'] = $manifest_data['data']['models'][$class_name]['columns'];
}
$model_metadata_hash = md5(json_encode($model_metadata));
$old_metadata_hash = $metadata['model_metadata_hash'] ?? '';
if ($model_metadata_hash === $old_metadata_hash) {
$needs_regeneration = false;
}
// Store the hash for future comparisons
$manifest_data['data']['files'][$file_path]['model_metadata_hash'] = $model_metadata_hash;
}
}
if ($needs_regeneration) {
// Generate stub content
$stub_content = static::_generate_model_stub_content($fqcn, $class_name, $stub_class_name, $manifest_data);
// Write stub file
file_put_contents($stub_full_path, $stub_content);
// Store the metadata hash for future comparisons if not already done
if (!isset($manifest_data['data']['files'][$file_path]['model_metadata_hash'])) {
$model_metadata = [];
// Get relationships
$model_metadata['rel'] = $fqcn::get_relationships();
// Get enums
if (property_exists($fqcn, 'enums')) {
$model_metadata['enums'] = $fqcn::$enums ?? [];
}
// Get columns from models metadata if available
if (isset($manifest_data['data']['models'][$class_name]['columns'])) {
$model_metadata['columns'] = $manifest_data['data']['models'][$class_name]['columns'];
}
$manifest_data['data']['files'][$file_path]['model_metadata_hash'] = md5(json_encode($model_metadata));
}
}
$generated_stubs[] = $stub_filename;
// Add js_stub property to manifest data
$metadata['js_stub'] = $stub_relative_path;
// Write the updated metadata back to the manifest
$manifest_data['data']['files'][$file_path]['js_stub'] = $stub_relative_path;
// Debug: Verify the value was written
// console_debug('STUB_GEN', " Written js_stub for {$file_path}: {$stub_relative_path}");
// if (!isset($manifest_data['data']['files'][$file_path]['js_stub'])) {
// console_debug('STUB_GEN', ' ERROR: js_stub not set after writing!');
// }
// Add the stub file itself to the manifest
$stat = stat($stub_full_path);
$manifest_data['data']['files'][$stub_relative_path] = [
'file' => $stub_relative_path,
'hash' => sha1_file($stub_full_path),
'mtime' => $stat['mtime'],
'size' => $stat['size'],
'extension' => 'js',
'class' => $stub_class_name,
'is_model_stub' => true, // Mark this as a generated model stub
'source_model' => $file_path, // Reference to the source model
];
}
// Clean up orphaned stub files
$existing_stubs = glob($stub_dir . '/*.js');
foreach ($existing_stubs as $existing_stub) {
$filename = basename($existing_stub);
if (!in_array($filename, $generated_stubs)) {
// Remove from disk (check exists to avoid Windows errors)
if (file_exists($existing_stub)) {
unlink($existing_stub);
}
// Remove from manifest
$stub_relative_path = 'storage/rsx-build/js-model-stubs/' . $filename;
if (isset($manifest_data['data']['files'][$stub_relative_path])) {
unset($manifest_data['data']['files'][$stub_relative_path]);
}
}
}
}
/**
* Check if a class is a subclass of another class using manifest data
*
* IMPORTANT: This is NOT redundant with Manifest::php_is_subclass_of().
* This method operates on the raw manifest data array during the manifest
* build process, before the Manifest class has fully initialized its static
* data structures. It's called during Phase 5 (stub generation) when the
* manifest is being built, not when it's being queried.
*
* @param string $class_name The FQCN of the class to check
* @param string $parent_class The simple name of the parent class
* @param array $manifest_data The raw manifest data array being built
* @return bool True if class_name extends parent_class
*/
private static function _php_is_subclass_of(string $class_name, string $parent_class, array $manifest_data): bool
{
foreach ($manifest_data['data']['files'] as $file_path => $metadata) {
if (isset($metadata['fqcn']) && $metadata['fqcn'] === $class_name) {
$extends = $metadata['extends'] ?? '';
if ($extends === $parent_class) {
return true;
}
// Recursively check parent
if ($extends) {
return static::_php_is_subclass_of($extends, $parent_class, $manifest_data);
}
}
}
return false;
}
/**
* Check if user has created a JavaScript model class
*/
private static function _check_user_model_class_exists(string $model_name, array $manifest_data): bool
{
// Check if there's a JS file with this class name in the manifest
foreach ($manifest_data['data']['files'] as $file_path => $metadata) {
if (isset($metadata['extension']) && $metadata['extension'] === 'js') {
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
// Don't consider our own stubs
if (!isset($metadata['is_stub']) && !isset($metadata['is_model_stub'])) {
return true;
}
}
}
}
return false;
}
/**
* Sanitize model name for use as filename
*/
private static function _sanitize_model_stub_filename(string $model_name): string
{
// Replace underscores with hyphens and lowercase
// e.g., User_Model becomes user-model
return strtolower(str_replace('_', '-', $model_name));
}
/**
* Generate JavaScript stub content for a model
*/
private static function _generate_model_stub_content(string $fqcn, string $class_name, string $stub_class_name, array $manifest_data): string
{
// Ensure class is loaded before introspection
// (should already be loaded but double-check)
if (!class_exists($fqcn)) {
shouldnt_happen("Class {$fqcn} not loaded for stub generation");
}
// Get model instance to introspect
$model = new $fqcn();
// Get relationships
$relationships = $fqcn::get_relationships();
// Get enums
$enums = $fqcn::$enums ?? [];
// Get columns from models metadata if available
$columns = [];
if (isset($manifest_data['data']['models'][$class_name]['columns'])) {
$columns = $manifest_data['data']['models'][$class_name]['columns'];
}
// Start building the stub content
$content = "/**\n";
$content .= " * Auto-generated JavaScript stub for {$class_name}\n";
$content .= ' * Generated by RSX Manifest at ' . date('Y-m-d H:i:s') . "\n";
$content .= " * DO NOT EDIT - This file is automatically regenerated\n";
$content .= " */\n\n";
$content .= "class {$stub_class_name} extends Rsx_Js_Model {\n";
// Add static model name for API calls
$content .= " static get name() {\n";
$content .= " return '{$class_name}';\n";
$content .= " }\n\n";
// Generate enum constants and methods
foreach ($enums as $column => $enum_values) {
// Sort enum values by order property first, then by key
uksort($enum_values, function ($keyA, $keyB) use ($enum_values) {
$orderA = isset($enum_values[$keyA]['order']) ? $enum_values[$keyA]['order'] : 0;
$orderB = isset($enum_values[$keyB]['order']) ? $enum_values[$keyB]['order'] : 0;
// First compare by order
if ($orderA !== $orderB) {
return $orderA - $orderB;
}
// If order is same, compare by key (use spaceship operator for string comparison)
return $keyA <=> $keyB;
});
// Generate constants
foreach ($enum_values as $value => $props) {
if (!empty($props['constant'])) {
$value_json = json_encode($value);
$content .= " static {$props['constant']} = {$value_json};\n";
}
}
if (!empty($enum_values)) {
$content .= "\n";
}
// Generate enum value getter with Proxy for maintaining order
$content .= " static {$column}_enum_val() {\n";
$content .= " const data = {};\n";
$content .= " const order = [];\n";
// Generate the sorted entries
foreach ($enum_values as $value => $props) {
$value_json = json_encode($value);
$props_json = json_encode($props, JSON_UNESCAPED_SLASHES);
$content .= " data[{$value_json}] = {$props_json};\n";
$content .= " order.push({$value_json});\n";
}
$content .= " // Return Proxy that maintains sort order for enumeration\n";
$content .= " return new Proxy(data, {\n";
$content .= " ownKeys() {\n";
$content .= " return order.map(String);\n";
$content .= " },\n";
$content .= " getOwnPropertyDescriptor(target, prop) {\n";
$content .= " if (prop in target) {\n";
$content .= " return {\n";
$content .= " enumerable: true,\n";
$content .= " configurable: true,\n";
$content .= " value: target[prop]\n";
$content .= " };\n";
$content .= " }\n";
$content .= " }\n";
$content .= " });\n";
$content .= " }\n\n";
// Generate enum label list
$content .= " static {$column}_label_list() {\n";
$content .= " const values = {};\n";
foreach ($enum_values as $value => $props) {
if (isset($props['label'])) {
$value_json = json_encode($value);
$label = addslashes($props['label']);
$content .= " values[{$value_json}] = '{$label}';\n";
}
}
$content .= " return values;\n";
$content .= " }\n\n";
// Generate enum select method (for dropdowns)
// Consumes enum_val() data and extracts labels for selectable items
$content .= " static {$column}_enum_select() {\n";
$content .= " const fullData = this.{$column}_enum_val();\n";
$content .= " const data = {};\n";
$content .= " const order = [];\n";
$content .= " \n";
$content .= " // Extract labels from full data, respecting selectable flag\n";
$content .= " for (const key in fullData) {\n";
$content .= " const item = fullData[key];\n";
$content .= " if (item.selectable !== false && item.label) {\n";
$content .= " data[key] = item.label;\n";
$content .= " order.push(parseInt(key));\n";
$content .= " }\n";
$content .= " }\n";
$content .= " \n";
$content .= " // Return Proxy that maintains sort order for enumeration\n";
$content .= " return new Proxy(data, {\n";
$content .= " ownKeys() {\n";
$content .= " return order.map(String);\n";
$content .= " },\n";
$content .= " getOwnPropertyDescriptor(target, prop) {\n";
$content .= " if (prop in target) {\n";
$content .= " return {\n";
$content .= " enumerable: true,\n";
$content .= " configurable: true,\n";
$content .= " value: target[prop]\n";
$content .= " };\n";
$content .= " }\n";
$content .= " }\n";
$content .= " });\n";
$content .= " }\n\n";
}
// Generate relationship methods
foreach ($relationships as $relationship) {
$content .= " /**\n";
$content .= " * Fetch {$relationship} relationship\n";
$content .= " * @returns {Promise} Related model instance(s) or false\n";
$content .= " */\n";
$content .= " async {$relationship}() {\n";
$content .= " if (!this.id) {\n";
$content .= " shouldnt_happen('Cannot fetch relationship without id property');\n";
$content .= " }\n\n";
$content .= " const response = await $.ajax({\n";
$content .= " url: `/_fetch_rel/{$class_name}/\${this.id}/{$relationship}`,\n";
$content .= " method: 'POST',\n";
$content .= " dataType: 'json'\n";
$content .= " });\n\n";
$content .= " if (!response) return false;\n\n";
$content .= " // Convert response to model instance(s)\n";
$content .= " // Framework handles instantiation based on relationship type\n";
$content .= " return response;\n";
$content .= " }\n\n";
}
$content .= "}\n";
return $content;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\RSpade\Core\Database;
use App\RSpade\Core\Database\Database_BundleIntegration;
use App\RSpade\Core\Integration_Service_Provider_Abstract;
/**
* Database_Service_Provider - Service provider for database integration
*
* This provider registers the database integration with the RSX framework.
* It handles generation of JavaScript stub files for ORM models.
*/
class Database_Service_Provider extends Integration_Service_Provider_Abstract
{
/**
* Get the integration class for this provider
*
* @return string
*/
protected function get_integration_class(): string
{
return Database_BundleIntegration::class;
}
}

View File

@@ -0,0 +1,82 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Database;
/**
* Central location for migration path management
*
* RSpade supports migrations in two locations:
* 1. /database/migrations - Framework and system migrations
* 2. /rsx/resource/migrations - Application migrations (outside manifest scanning)
*
* The /resource/ directory is excluded from manifest scanning, making it suitable
* for migrations and other framework-related code that doesn't follow RSX conventions.
*/
class MigrationPaths
{
/**
* Get all migration directories in order they should be scanned
*
* @return array Array of absolute paths to migration directories
*/
public static function get_all_paths(): array
{
return [
database_path('migrations'),
base_path('rsx/resource/migrations'),
];
}
/**
* Get the default path for new migrations created via make:migration:safe
*
* @return string Absolute path to default migration directory
*/
public static function get_default_path(): string
{
return database_path('migrations');
}
/**
* Ensure all migration directories exist
*
* @return void
*/
public static function ensure_directories_exist(): void
{
foreach (static::get_all_paths() as $path) {
if (!is_dir($path)) {
mkdir($path, 0755, true);
}
}
}
/**
* Get all migration files from all paths, sorted by name (timestamp)
*
* @return array Array of absolute file paths
*/
public static function get_all_migration_files(): array
{
$files = [];
foreach (static::get_all_paths() as $path) {
if (is_dir($path)) {
$path_files = glob($path . '/*.php');
if ($path_files) {
$files = array_merge($files, $path_files);
}
}
}
// Sort by filename (timestamp)
sort($files);
return $files;
}
}

View File

@@ -0,0 +1,385 @@
<?php
namespace App\RSpade\Core\Database;
use Illuminate\Database\Migrations\MigrationRepositoryInterface;
use RuntimeException;
use App\RSpade\Core\Database\MigrationPaths;
/**
* Migration Validator - Enforces raw SQL usage in migrations
*
* This validator ensures migrations use only DB::statement() for schema changes,
* not Laravel's Schema builder. Also removes down() methods from migration files.
*/
class MigrationValidator
{
/**
* Validate a migration file for Schema builder usage
*
* @param string $filepath Path to migration file
* @return void
* @throws RuntimeException if validation fails
*/
public static function validate_migration_file(string $filepath): void
{
if (!file_exists($filepath)) {
throw new RuntimeException("Migration file not found: {$filepath}");
}
$content = file_get_contents($filepath);
$tokens = token_get_all($content);
// Check for Schema builder usage
self::__check_for_schema_builder($tokens, $content, $filepath);
}
/**
* Check tokens for Schema builder usage
*/
private static function __check_for_schema_builder(array $tokens, string $content, string $filepath): void
{
$forbidden_patterns = [
'Schema::create' => 'Use DB::statement("CREATE TABLE...") instead',
'Schema::table' => 'Use DB::statement("ALTER TABLE...") instead',
'Schema::drop' => 'Use DB::statement("DROP TABLE...") instead',
'Schema::dropIfExists' => 'Use DB::statement("DROP TABLE IF EXISTS...") instead',
'Schema::rename' => 'Use DB::statement("RENAME TABLE...") instead',
'Blueprint' => 'Use raw SQL instead of Blueprint',
'$table->' => 'Use raw SQL column definitions instead',
];
$lines = explode("\n", $content);
$in_schema_block = false;
$schema_start_line = null;
$current_line = 1;
$method_buffer = '';
for ($i = 0; $i < count($tokens); $i++) {
$token = $tokens[$i];
// Track line numbers
if (is_array($token) && $token[0] === T_WHITESPACE) {
$current_line += substr_count($token[1], "\n");
continue;
}
// Check for Schema:: usage
if (is_array($token) && $token[0] === T_STRING && $token[1] === 'Schema') {
// Look ahead for ::
$j = $i + 1;
while ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) {
$j++;
}
if ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_DOUBLE_COLON) {
// Found Schema::
$j++;
while ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) {
$j++;
}
if ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_STRING) {
$method = $tokens[$j][1];
// Check if it's a forbidden method
$pattern_key = "Schema::{$method}";
if ($method !== 'hasTable' && $method !== 'hasColumn' && $method !== 'getColumnListing') {
// This is a forbidden Schema method
$in_schema_block = true;
$schema_start_line = $current_line;
$method_buffer = "Schema::{$method}";
}
}
}
}
// Check for Blueprint usage
if (is_array($token) && $token[0] === T_STRING && $token[1] === 'Blueprint') {
$in_schema_block = true;
$schema_start_line = $current_line;
$method_buffer = 'Blueprint';
}
// Check for $table-> usage
if (is_array($token) && $token[0] === T_VARIABLE && $token[1] === '$table') {
$j = $i + 1;
while ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) {
$j++;
}
if ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_OBJECT_OPERATOR) {
$in_schema_block = true;
$schema_start_line = $current_line;
$method_buffer = '$table->';
}
}
// If we found a schema block, extract it
if ($in_schema_block) {
self::__report_schema_violation($filepath, $schema_start_line, $lines, $method_buffer, $forbidden_patterns);
return; // Stop at first violation
}
}
}
/**
* Report a Schema builder violation with colored output
*/
private static function __report_schema_violation(
string $filepath,
int $line_number,
array $lines,
string $pattern,
array $forbidden_patterns
): void {
echo "\n";
echo "\033[1;31m❌ Migration Validation Failed\033[0m\n";
echo "\n";
echo "\033[33mFile:\033[0m " . basename($filepath) . "\n";
echo "\033[33mLine:\033[0m {$line_number}\n";
echo "\n";
echo "\033[1;37mViolation:\033[0m Found forbidden Schema builder usage: \033[31m{$pattern}\033[0m\n";
echo "\n";
// Show code preview - find the complete statement
echo "\033[1;37mCode Preview:\033[0m\n";
echo "\033[90m" . str_repeat("", 60) . "\033[0m\n";
$preview = self::__extract_statement($lines, $line_number - 1);
echo "\033[36m" . $preview . "\033[0m\n";
echo "\033[90m" . str_repeat("", 60) . "\033[0m\n";
echo "\n";
// Get remediation advice
$remediation = 'Use raw DB::statement() instead';
foreach ($forbidden_patterns as $key => $advice) {
if (strpos($pattern, str_replace(['Schema::', '$table->'], '', $key)) !== false) {
$remediation = $advice;
break;
}
}
echo "\033[1;32mRemediation:\033[0m {$remediation}\n";
echo "\n";
echo "\033[1;37mExample:\033[0m\n";
if (strpos($pattern, 'create') !== false) {
echo " DB::statement('CREATE TABLE users (\n";
echo " id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,\n";
echo " name VARCHAR(255),\n";
echo " created_at TIMESTAMP NULL DEFAULT NULL\n";
echo " )');\n";
} elseif (strpos($pattern, 'table') !== false || strpos($pattern, 'Blueprint') !== false) {
echo " DB::statement('ALTER TABLE users ADD COLUMN age BIGINT NULL');\n";
} elseif (strpos($pattern, 'drop') !== false) {
echo " DB::statement('DROP TABLE IF EXISTS users');\n";
}
echo "\n";
echo "\033[1;31mMigrations must use raw SQL queries, not Laravel's Schema builder.\033[0m\n";
echo "\n";
throw new RuntimeException("Migration validation failed: Schema builder usage detected");
}
/**
* Extract complete statement from lines starting at given position
*/
private static function __extract_statement(array $lines, int $start_line): string
{
$statement = '';
$brace_count = 0;
$found_start = false;
for ($i = $start_line; $i < count($lines); $i++) {
$line = $lines[$i];
$statement .= $line . "\n";
// Count braces to find complete statement
for ($j = 0; $j < strlen($line); $j++) {
if ($line[$j] === '{') {
$brace_count++;
$found_start = true;
} elseif ($line[$j] === '}') {
$brace_count--;
if ($found_start && $brace_count === 0) {
return trim($statement);
}
} elseif ($line[$j] === ';' && $brace_count === 0) {
return trim($statement);
}
}
// Stop at 20 lines to prevent huge output
if ($i - $start_line > 20) {
return trim($statement) . "\n ...";
}
}
return trim($statement);
}
/**
* Remove down() method from a migration file
*/
public static function remove_down_method(string $filepath): bool
{
if (!file_exists($filepath)) {
return false;
}
$content = file_get_contents($filepath);
$tokens = token_get_all($content);
$down_start_pos = null;
$down_end_pos = null;
$brace_count = 0;
$in_down = false;
$positions = [];
$current_pos = 0;
// Build position map for each token
for ($i = 0; $i < count($tokens); $i++) {
$positions[$i] = $current_pos;
if (is_array($tokens[$i])) {
$current_pos += strlen($tokens[$i][1]);
} else {
$current_pos += strlen($tokens[$i]);
}
}
for ($i = 0; $i < count($tokens); $i++) {
$token = $tokens[$i];
// Look for "function down"
if (is_array($token) && $token[0] === T_FUNCTION) {
$j = $i + 1;
while ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) {
$j++;
}
if ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_STRING && $tokens[$j][1] === 'down') {
// Found down() function
// Look backwards to find start position including visibility modifier and comments
$start_index = $i;
// Look for visibility modifier (public, protected, private)
for ($k = $i - 1; $k >= 0; $k--) {
$prev = $tokens[$k];
if (is_array($prev)) {
if ($prev[0] === T_PUBLIC || $prev[0] === T_PROTECTED || $prev[0] === T_PRIVATE) {
$start_index = $k;
break;
} elseif ($prev[0] === T_COMMENT || $prev[0] === T_DOC_COMMENT) {
$start_index = $k;
} elseif ($prev[0] !== T_WHITESPACE) {
break;
}
} else {
break;
}
}
// Look for comments/whitespace before visibility or function
for ($k = $start_index - 1; $k >= 0; $k--) {
$prev = $tokens[$k];
if (is_array($prev)) {
if ($prev[0] === T_COMMENT || $prev[0] === T_DOC_COMMENT || $prev[0] === T_WHITESPACE) {
$start_index = $k;
} else {
break;
}
} else {
break;
}
}
$down_start_pos = $positions[$start_index];
$in_down = true;
$brace_count = 0;
}
}
// Track braces to find end of down() method
if ($in_down) {
// Skip string interpolation braces
if (is_array($token) && $token[0] === T_CURLY_OPEN) {
continue;
}
if (is_string($token)) {
if ($token === '{') {
$brace_count++;
} elseif ($token === '}') {
// Check if this is closing string interpolation
$is_string_interpolation = false;
for ($j = $i - 1; $j >= max(0, $i - 10); $j--) {
if (is_array($tokens[$j]) && $tokens[$j][0] === T_CURLY_OPEN) {
$is_string_interpolation = true;
break;
}
}
if (!$is_string_interpolation) {
$brace_count--;
if ($brace_count === 0) {
// Found closing brace of down() method
$down_end_pos = $positions[$i] + 1; // Include the closing brace
$in_down = false;
break;
}
}
}
}
}
}
// If we found down() method, remove it
if ($down_start_pos !== null && $down_end_pos !== null) {
$before = substr($content, 0, $down_start_pos);
$after = substr($content, $down_end_pos);
// Clean up trailing whitespace from before section
$before = rtrim($before);
// Ensure exactly one newline before closing brace
$new_content = $before . "\n};\n";
file_put_contents($filepath, $new_content);
return true;
}
return false;
}
/**
* Get pending migrations that haven't been run yet
*/
public static function get_pending_migrations(MigrationRepositoryInterface $repository): array
{
$ran = $repository->getRan();
$migration_files = [];
// Scan all migration paths
foreach (MigrationPaths::get_all_paths() as $path) {
if (!is_dir($path)) {
continue;
}
$files = scandir($path);
foreach ($files as $file) {
if (pathinfo($file, PATHINFO_EXTENSION) !== 'php') {
continue;
}
$name = str_replace('.php', '', $file);
if (!in_array($name, $ran)) {
$migration_files[] = $path . '/' . $file;
}
}
}
return $migration_files;
}
}

View File

@@ -0,0 +1,229 @@
<?php
namespace App\RSpade\Core\Database;
use App\RSpade\Core\Manifest\Manifest;
/**
* Database helper for accessing model and table metadata from the manifest
*/
class ModelHelper
{
/**
* Get all model class names in the system
*
* @return array Array of model class names (simple names, not FQCNs)
*/
public static function get_all_model_names(): array
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'])) {
return [];
}
return array_keys($manifest['data']['models']);
}
/**
* Get all database table names used by models
*
* @return array Array of table names
*/
public static function get_all_table_names(): array
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'])) {
return [];
}
$tables = [];
foreach ($manifest['data']['models'] as $model_data) {
if (isset($model_data['table'])) {
$tables[] = $model_data['table'];
}
}
return array_unique($tables);
}
/**
* Get full column data for a model by class name
*
* @param string $model_name Simple class name (e.g., 'User', not 'App\Models\User')
* @return array Column metadata array
* @throws \RuntimeException if model not found
*/
public static function get_columns_by_model(string $model_name): array
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'][$model_name])) {
throw new \RuntimeException("Model not found in manifest: {$model_name}");
}
return $manifest['data']['models'][$model_name]['columns'] ?? [];
}
/**
* Get full column data for a database table
*
* @param string $table_name Database table name
* @return array Column metadata array
* @throws \RuntimeException if table not found
*/
public static function get_columns_by_table(string $table_name): array
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'])) {
throw new \RuntimeException("No models found in manifest");
}
foreach ($manifest['data']['models'] as $model_data) {
if (isset($model_data['table']) && $model_data['table'] === $table_name) {
return $model_data['columns'] ?? [];
}
}
throw new \RuntimeException("Table not found in manifest: {$table_name}");
}
/**
* Get model metadata by class name
*
* @param string $model_name Simple class name
* @return array Full model metadata including table, columns, fqcn, etc.
* @throws \RuntimeException if model not found
*/
public static function get_model_metadata(string $model_name): array
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'][$model_name])) {
throw new \RuntimeException("Model not found in manifest: {$model_name}");
}
return $manifest['data']['models'][$model_name];
}
/**
* Get model name by table name
*
* @param string $table_name Database table name
* @return string Model class name
* @throws \RuntimeException if table not found
*/
public static function get_model_by_table(string $table_name): string
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'])) {
throw new \RuntimeException("No models found in manifest");
}
foreach ($manifest['data']['models'] as $model_name => $model_data) {
if (isset($model_data['table']) && $model_data['table'] === $table_name) {
return $model_name;
}
}
throw new \RuntimeException("No model found for table: {$table_name}");
}
/**
* Get table name by model name
*
* @param string $model_name Simple class name
* @return string Database table name
* @throws \RuntimeException if model not found
*/
public static function get_table_by_model(string $model_name): string
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'][$model_name])) {
throw new \RuntimeException("Model not found in manifest: {$model_name}");
}
if (!isset($manifest['data']['models'][$model_name]['table'])) {
throw new \RuntimeException("Model has no table defined: {$model_name}");
}
return $manifest['data']['models'][$model_name]['table'];
}
/**
* Check if a model exists in the manifest
*
* @param string $model_name Simple class name
* @return bool
*/
public static function model_exists(string $model_name): bool
{
$manifest = Manifest::get_full_manifest();
return isset($manifest['data']['models'][$model_name]);
}
/**
* Check if a table is managed by a model
*
* @param string $table_name Database table name
* @return bool
*/
public static function table_exists(string $table_name): bool
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'])) {
return false;
}
foreach ($manifest['data']['models'] as $model_data) {
if (isset($model_data['table']) && $model_data['table'] === $table_name) {
return true;
}
}
return false;
}
/**
* Get column type for a specific column in a model
*
* @param string $model_name Simple class name
* @param string $column_name Column name
* @return string Column type
* @throws \RuntimeException if model or column not found
*/
public static function get_column_type(string $model_name, string $column_name): string
{
$columns = static::get_columns_by_model($model_name);
if (!isset($columns[$column_name])) {
throw new \RuntimeException("Column '{$column_name}' not found in model '{$model_name}'");
}
return $columns[$column_name]['type'] ?? 'unknown';
}
/**
* Check if a column is nullable
*
* @param string $model_name Simple class name
* @param string $column_name Column name
* @return bool
* @throws \RuntimeException if model or column not found
*/
public static function is_column_nullable(string $model_name, string $column_name): bool
{
$columns = static::get_columns_by_model($model_name);
if (!isset($columns[$column_name])) {
throw new \RuntimeException("Column '{$column_name}' not found in model '{$model_name}'");
}
return $columns[$column_name]['nullable'] ?? false;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,451 @@
<?php
namespace App\RSpade\Core\Database\Models;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use RuntimeException;
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
use App\RSpade\Core\Locks\RsxLocks;
use App\RSpade\Core\Session\Session;
/**
* Abstract base model for site-scoped models with automatic concurrency control
*
* Models extending this class:
* - Automatically scope queries by site_id from session
* - Include site_id column in the database
* - Support soft deletes when configured
* - Provide automatic site-level database locking for write operations
* - Strict enforcement of site boundaries - no cross-site data access
*
* SITE ISOLATION:
* - All queries automatically filtered by current session site_id
* - All saves automatically set site_id from session
* - Changing site_id on existing records is FATAL
* - Site ID 0 used when no site in session (global/unscoped data)
* - No caching of site_id - always reads fresh from session
*
* CONCURRENCY CONTROL:
* Site locks are acquired when Session::get_site_id() is called:
* - Each site gets a READ lock when its site_id is accessed
* - First save() operation upgrades to WRITE lock automatically
* - No automatic transactions - handle manually when needed
*
* When config('rsx.locking.site_always_write') is true:
* - ALL requests start with WRITE lock (no read locks ever)
* - Completely serializes all requests for a given site
* - Severe performance impact - use only for critical operations
* - Displays warning on stderr in production CLI mode
*
* This prevents race conditions for critical operations like:
* - Inventory management
* - Auction bidding
* - Financial transactions
* - Any operation requiring strict consistency within a site
*/
abstract class Rsx_Site_Model_Abstract extends Rsx_Model_Abstract
{
/**
* Whether to automatically apply site scoping to queries
* Can be disabled for admin operations that need cross-site access
*
* @var bool
*/
protected static $apply_site_scope = true;
/**
* Site lock tokens by site_id
* @var array<int, string>
*/
protected static $site_lock_tokens = [];
/**
* Whether each site lock has been upgraded to write
* @var array<int, bool>
*/
protected static $site_lock_is_write = [];
/**
* Get the current site ID from session
* Always returns fresh value - never cached
*
* @return int
*/
public static function get_current_site_id(): int
{
$site_id = Session::get_site_id();
// Use site_id 0 if null/empty (global scope)
if ($site_id === null || $site_id === '') {
return 0;
}
return (int)$site_id;
}
/**
* Acquire a read lock for a specific site ID
* Called from Session::get_site_id() when site_id is accessed
*
* @param int $site_id The site ID to lock (defaults to 0)
* @return void
*/
public static function acquire_site_lock_for_id(int $site_id): void
{
// Don't lock if we already have a lock for this site
if (isset(static::$site_lock_tokens[$site_id])) {
return;
}
$always_write = config('rsx.locking.site_always_write', false);
// Issue warning if always_write_lock is enabled in production CLI mode
if ($always_write && php_sapi_name() === 'cli' && app()->environment('production')) {
fwrite(STDERR, "\033[33mWARNING: RSX_SITE_ALWAYS_WRITE is enabled in production. " .
'All site requests are serialized with exclusive write locks. ' .
"This severely impacts performance and should only be used for critical operations.\033[0m\n");
}
// Determine lock type based on configuration
$lock_type = $always_write ? RsxLocks::WRITE_LOCK : RsxLocks::READ_LOCK;
// Acquire lock for this site
static::$site_lock_tokens[$site_id] = RsxLocks::get_lock(
RsxLocks::DATABASE_LOCK,
RsxLocks::LOCK_SITE_PREFIX . $site_id,
$lock_type,
config('rsx.locking.timeout', 30)
);
// If we started with a write lock, mark it as such
static::$site_lock_is_write[$site_id] = $always_write;
// Register shutdown handler to cleanup (only once)
static $shutdown_registered = false;
if (!$shutdown_registered) {
register_shutdown_function([static::class, 'release_all_site_locks']);
$shutdown_registered = true;
}
}
/**
* Upgrade site lock from read to write
* Called automatically on first save() operation
*
* @return void
*/
protected static function __upgrade_to_write_lock(): void
{
$site_id = static::get_current_site_id();
// If no lock for this site yet, acquire write lock directly
if (!isset(static::$site_lock_tokens[$site_id])) {
static::$site_lock_tokens[$site_id] = RsxLocks::get_lock(
RsxLocks::DATABASE_LOCK,
RsxLocks::LOCK_SITE_PREFIX . $site_id,
RsxLocks::WRITE_LOCK,
config('rsx.locking.timeout', 30)
);
static::$site_lock_is_write[$site_id] = true;
return;
}
// Already have write lock
if (isset(static::$site_lock_is_write[$site_id]) && static::$site_lock_is_write[$site_id]) {
return;
}
// Upgrade read to write
try {
static::$site_lock_tokens[$site_id] = RsxLocks::upgrade_lock(
static::$site_lock_tokens[$site_id],
config('rsx.locking.timeout', 30)
);
static::$site_lock_is_write[$site_id] = true;
} catch (RuntimeException $e) {
throw new RuntimeException(
"Failed to upgrade site lock to write mode for site {$site_id}: " . $e->getMessage()
);
}
}
/**
* Release a specific site's lock
*
* @param int $site_id The site ID to release lock for
* @return void
*/
public static function release_site_lock(int $site_id): void
{
if (isset(static::$site_lock_tokens[$site_id])) {
try {
RsxLocks::release_lock(static::$site_lock_tokens[$site_id]);
} catch (Exception $e) {
// Ignore errors during cleanup
}
unset(static::$site_lock_tokens[$site_id]);
unset(static::$site_lock_is_write[$site_id]);
}
}
/**
* Release all site locks
* Called automatically on shutdown
*
* @return void
*/
public static function release_all_site_locks(): void
{
foreach (static::$site_lock_tokens as $site_id => $token) {
try {
RsxLocks::release_lock($token);
} catch (Exception $e) {
// Ignore errors during cleanup
}
}
static::$site_lock_tokens = [];
static::$site_lock_is_write = [];
}
/**
* Temporarily disable site scoping for admin operations
*
* @param callable $callback
* @return mixed
*/
public static function without_site_scope(callable $callback)
{
$was_applying = static::$apply_site_scope;
static::$apply_site_scope = false;
try {
return $callback();
} finally {
static::$apply_site_scope = $was_applying;
}
}
/**
* Boot the model and add global scope for site_id
*/
protected static function booted()
{
parent::booted();
// Add global scope to filter by site_id
static::addGlobalScope('site', function (Builder $builder) {
if (static::$apply_site_scope) {
$site_id = static::get_current_site_id();
$builder->where($builder->getModel()->getTable() . '.site_id', $site_id);
}
});
// Automatically set site_id when creating new models
static::creating(function ($model) {
if (static::$apply_site_scope) {
// Always set site_id from session, even if already set
// This ensures consistency and prevents injection attacks
$model->site_id = static::get_current_site_id();
}
});
// Validate site_id on save (both create and update)
static::saving(function ($model) {
if (static::$apply_site_scope) {
$current_site_id = static::get_current_site_id();
// For existing records, ensure site_id hasn't changed
if ($model->exists) {
$original_site_id = $model->getOriginal('site_id');
// Fatal error if trying to change site_id
if ($model->site_id != $original_site_id) {
shouldnt_happen(
"Attempted to change site_id from {$original_site_id} to {$model->site_id} " .
'on ' . get_class($model) . " ID {$model->id}. " .
'Changing site_id is not allowed.'
);
}
// Fatal error if record doesn't belong to current site
if ($model->site_id != $current_site_id) {
shouldnt_happen(
'Attempted to save ' . get_class($model) . " ID {$model->id} " .
"with site_id {$model->site_id} but current session site_id is {$current_site_id}. " .
'Cross-site saves are not allowed.'
);
}
} else {
// For new records, force the site_id
$model->site_id = $current_site_id;
}
}
});
// After retrieving records, validate they belong to current site
static::retrieved(function ($model) {
if (static::$apply_site_scope) {
$current_site_id = static::get_current_site_id();
// This shouldn't happen if global scope is working, but double-check
if ($model->site_id != $current_site_id) {
shouldnt_happen(
'Retrieved ' . get_class($model) . " ID {$model->id} " .
"with site_id {$model->site_id} but current session site_id is {$current_site_id}. " .
'Global scope should have prevented this.'
);
}
}
});
}
/**
* Override save to handle site locking
*
* @param array $options
* @return bool
*/
public function save(array $options = [])
{
// Always upgrade to write lock for saves
static::__upgrade_to_write_lock();
// Additional validation before save
if (static::$apply_site_scope) {
$current_site_id = static::get_current_site_id();
// Ensure we're not trying to save a record from wrong site
if ($this->exists && $this->site_id != $current_site_id) {
shouldnt_happen(
'Attempted to save ' . get_class($this) . " with site_id {$this->site_id} " .
"but current session site_id is {$current_site_id}. " .
'This indicates a serious security issue.'
);
}
// Force site_id for new records
if (!$this->exists) {
$this->site_id = $current_site_id;
}
}
return parent::save($options);
}
/**
* Override update to handle site locking
*
* @param array $attributes
* @param array $options
* @return bool
*/
public function update(array $attributes = [], array $options = [])
{
// Fatal if trying to change site_id via update
if (isset($attributes['site_id']) && static::$apply_site_scope) {
if ($attributes['site_id'] != $this->site_id) {
shouldnt_happen(
"Attempted to change site_id via update() from {$this->site_id} to {$attributes['site_id']} " .
'on ' . get_class($this) . " ID {$this->id}. " .
'Changing site_id is never allowed.'
);
}
// Remove site_id from attributes since it shouldn't change
unset($attributes['site_id']);
}
// Always upgrade to write lock for updates
static::__upgrade_to_write_lock();
return parent::update($attributes, $options);
}
/**
* Override delete to handle site locking
*
* @return bool|null
*/
public function delete()
{
// Always upgrade to write lock for updates
static::__upgrade_to_write_lock();
// Validate site ownership before delete
if (static::$apply_site_scope) {
$current_site_id = static::get_current_site_id();
if ($this->site_id != $current_site_id) {
shouldnt_happen(
'Attempted to delete ' . get_class($this) . " ID {$this->id} " .
"with site_id {$this->site_id} but current session site_id is {$current_site_id}. " .
'Cross-site deletes are not allowed.'
);
}
}
return parent::delete();
}
/**
* Scope a query to a specific site
*
* @param Builder $query
* @param int $site_id
* @return Builder
*/
public function scopeForSite($query, $site_id)
{
return $query->where('site_id', $site_id);
}
/**
* Get models for all sites (admin use only)
*
* @return Builder
*/
public static function for_all_sites()
{
return static::without_site_scope(function () {
return static::query();
});
}
/**
* Create a new model instance for a specific site
*
* @param array $attributes
* @param int|null $site_id Override site_id (admin use only)
* @return static
*/
public static function create_for_site(array $attributes = [], ?int $site_id = null)
{
if ($site_id !== null && !static::$apply_site_scope) {
// Admin mode - allow specific site_id
$attributes['site_id'] = $site_id;
} else {
// Normal mode - use session site_id
$attributes['site_id'] = static::get_current_site_id();
}
return static::create($attributes);
}
/**
* Find or create a model for the current site
*
* @param array $attributes
* @param array $values
* @return static
*/
public static function first_or_create_for_site(array $attributes, array $values = [])
{
$site_id = static::get_current_site_id();
$attributes['site_id'] = $site_id;
$values['site_id'] = $site_id;
return static::firstOrCreate($attributes, $values);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\RSpade\Core\Database\Models;
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
/**
* Abstract base model for system/internal models
*
* Models extending this class:
* - Represent server-side only metadata
* - Are never exported to JavaScript ORM
* - Include system data like logs, audit trails, IP addresses, etc.
*
* Examples: ip_addresses, activity_logs, system_settings
*/
abstract class Rsx_System_Model_Abstract extends Rsx_Model_Abstract
{
/**
* Mark this as a system model that should never be exported
*
* @var bool
*/
protected $is_system_model = true;
/**
* System models should never be included in JavaScript ORM exports
*
* @return bool
*/
public function is_exportable_to_javascript()
{
return false;
}
/**
* Get metadata indicating this is a system model
*
* @return array
*/
public function get_system_metadata()
{
return [
'is_system' => true,
'exportable' => false,
'model_type' => 'system',
'description' => 'Internal system model - not exported to client'
];
}
/**
* Override to ensure all columns are marked as never export for system models
*
* @return array
*/
public function get_never_exported_columns()
{
// For system models, ALL columns should never be exported
return array_keys($this->getAttributes());
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\RSpade\Core\Database\Query\Grammars;
/**
* Custom MySQL Grammar with millisecond precision for timestamps
*
* This extends Laravel's MySqlGrammar to support microsecond precision
* in date formats, allowing for more precise timestamp storage and
* comparison in MySQL databases.
*/
#[Instantiatable]
class Query_MySqlGrammar extends \Illuminate\Database\Query\Grammars\MySqlGrammar
{
/**
* Get the format for database stored dates with millisecond precision
*
* @return string
*/
public function getDateFormat()
{
return 'Y-m-d H:i:s.u';
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\RSpade\Core\Database\Schema\Grammars;
/**
* Custom MySQL Schema Grammar with millisecond precision for timestamps
*
* This extends Laravel's MySqlGrammar to support microsecond precision
* in date formats for schema operations, ensuring consistency with the
* Query grammar.
*/
#[Instantiatable]
class Schema_MySqlGrammar extends \Illuminate\Database\Schema\Grammars\MySqlGrammar
{
/**
* Get the format for database stored dates with millisecond precision
*
* @return string
*/
public function getDateFormat()
{
return 'Y-m-d H:i:s.u';
}
}

View File

@@ -0,0 +1,54 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Database;
/**
* Central location for seeder path management
*
* RSpade uses /rsx/resource/seeders as the default location for user seeders.
* The /resource/ directory is excluded from manifest scanning, making it suitable
* for seeders and other framework-related code that doesn't follow RSX conventions.
*/
class SeederPaths
{
/**
* Get the default path for new seeders created via make:seeder
*
* @return string Absolute path to default seeder directory
*/
public static function get_default_path(): string
{
return base_path('rsx/resource/seeders');
}
/**
* Get all seeder directories to scan
*
* @return array Array of absolute paths to seeder directories
*/
public static function get_all_paths(): array
{
return [
static::get_default_path(),
];
}
/**
* Ensure all seeder directories exist
*
* @return void
*/
public static function ensure_directories_exist(): void
{
foreach (static::get_all_paths() as $path) {
if (!is_dir($path)) {
mkdir($path, 0755, true);
}
}
}
}

View File

@@ -0,0 +1,412 @@
<?php
namespace App\RSpade\Core\Database;
/**
* SQL Query Transformer
*
* Enforces RSpade framework schema conventions by transforming DDL statements.
* Intercepts CREATE TABLE and ALTER TABLE queries during migrations to ensure
* consistent column types and character sets.
*
* Type conversions enforced:
* - INT/INTEGER/MEDIUMINT/SMALLINT BIGINT (except TINYINT(1) for booleans)
* - FLOAT/REAL DOUBLE
* - TEXT/MEDIUMTEXT LONGTEXT
* - CHAR(n) VARCHAR(n)
* - TIMESTAMP TIMESTAMP(3), DATETIME DATETIME(3) (millisecond precision)
* - All VARCHAR/TEXT utf8mb4 charset + utf8mb4_unicode_ci collation
*
* Forbidden types (throws exception):
* - ENUM - Use VARCHAR with validation instead
* - SET - Use JSON or separate table
* - YEAR - Use INT or DATE
* - TIME - Use DATETIME
* - CHAR - Use VARCHAR
*
* This eliminates the need for foreign key drop/recreate in normalize_schema
* because tables are created with correct types from the start.
*/
class SqlQueryTransformer
{
/**
* Whether the transformer is currently enabled
*/
private static bool $enabled = false;
/**
* Enable query transformation
*
* Call this at the start of migration runs to activate the transformer.
*/
public static function enable(): void
{
self::$enabled = true;
}
/**
* Disable query transformation
*
* Call this after migration runs complete.
*/
public static function disable(): void
{
self::$enabled = false;
}
/**
* Check if transformer is enabled
*/
public static function is_enabled(): bool
{
return self::$enabled;
}
/**
* Transform a SQL query according to framework conventions
*
* @param string $query The SQL query to transform
* @return string The transformed query
* @throws \RuntimeException If query contains forbidden column types
*/
public static function transform(string $query): string
{
if (!self::$enabled) {
return $query;
}
// Check if this is a CREATE TABLE or ALTER TABLE statement
$normalized = self::__normalize_whitespace($query);
if (!self::__is_ddl_statement($normalized)) {
return $query;
}
// Validate - throw exception for forbidden types
self::__validate_forbidden_types($query);
// Transform the query
$transformed = $query;
// 1. Integer type transformations
$transformed = self::__transform_integer_types($transformed);
// 2. Floating point transformations
$transformed = self::__transform_float_types($transformed);
// 3. Text type transformations
$transformed = self::__transform_text_types($transformed);
// 4. Datetime precision transformations
$transformed = self::__transform_datetime_types($transformed);
// 5. Character set enforcement
$transformed = self::__enforce_utf8mb4($transformed);
return $transformed;
}
/**
* Normalize whitespace in SQL query for easier parsing
*
* - Collapses multiple spaces to single space
* - Preserves quoted strings
* - Removes leading/trailing whitespace
*
* @param string $query The SQL query
* @return string Normalized query
*/
private static function __normalize_whitespace(string $query): string
{
// Remove leading/trailing whitespace
$query = trim($query);
// Collapse multiple spaces, tabs, newlines to single space
// But preserve quoted strings
$result = '';
$in_single_quote = false;
$in_double_quote = false;
$in_backtick = false;
$prev_was_space = false;
for ($i = 0; $i < strlen($query); $i++) {
$char = $query[$i];
// Handle quotes
if ($char === "'" && !$in_double_quote && !$in_backtick) {
$in_single_quote = !$in_single_quote;
$result .= $char;
$prev_was_space = false;
continue;
}
if ($char === '"' && !$in_single_quote && !$in_backtick) {
$in_double_quote = !$in_double_quote;
$result .= $char;
$prev_was_space = false;
continue;
}
if ($char === '`' && !$in_single_quote && !$in_double_quote) {
$in_backtick = !$in_backtick;
$result .= $char;
$prev_was_space = false;
continue;
}
// If we're inside quotes, preserve everything
if ($in_single_quote || $in_double_quote || $in_backtick) {
$result .= $char;
$prev_was_space = false;
continue;
}
// Outside quotes: collapse whitespace
if ($char === ' ' || $char === "\t" || $char === "\n" || $char === "\r") {
if (!$prev_was_space) {
$result .= ' ';
$prev_was_space = true;
}
continue;
}
// Regular character
$result .= $char;
$prev_was_space = false;
}
return $result;
}
/**
* Check if query is a DDL statement we need to transform
*
* @param string $normalized_query Normalized SQL query
* @return bool True if this is CREATE TABLE or ALTER TABLE
*/
private static function __is_ddl_statement(string $normalized_query): bool
{
$upper = strtoupper($normalized_query);
return str_starts_with($upper, 'CREATE TABLE')
|| str_starts_with($upper, 'ALTER TABLE');
}
/**
* Validate that query doesn't contain forbidden column types
*
* @param string $query The SQL query
* @throws \RuntimeException If forbidden types are found
*/
private static function __validate_forbidden_types(string $query): void
{
// Check for ENUM
if (preg_match('/\bENUM\s*\(/i', $query)) {
throw new \RuntimeException(
"ENUM column type is forbidden in RSpade. Use VARCHAR with validation instead.\n" .
"ENUMs cannot be modified without table locks and cause deployment issues.\n" .
"Query: " . substr($query, 0, 200)
);
}
// Check for SET
if (preg_match('/\bSET\s*\(/i', $query)) {
throw new \RuntimeException(
"SET column type is forbidden in RSpade. Use JSON or a separate table instead.\n" .
"Query: " . substr($query, 0, 200)
);
}
// Check for YEAR
if (preg_match('/\bYEAR\b/i', $query)) {
throw new \RuntimeException(
"YEAR column type is forbidden in RSpade. Use INT or DATE instead.\n" .
"Query: " . substr($query, 0, 200)
);
}
// Check for TIME (but allow DATETIME and TIMESTAMP)
if (preg_match('/\bTIME\b(?!STAMP)/i', $query)) {
throw new \RuntimeException(
"TIME column type is forbidden in RSpade. Use DATETIME instead.\n" .
"Query: " . substr($query, 0, 200)
);
}
}
/**
* Transform integer column types to BIGINT
*
* Converts: INT, INTEGER, MEDIUMINT, SMALLINT BIGINT
* Preserves: TINYINT(1) for booleans
* Removes: UNSIGNED attribute (framework uses signed integers only)
*
* @param string $query The SQL query
* @return string Transformed query
*/
private static function __transform_integer_types(string $query): string
{
// INT, INTEGER, MEDIUMINT, SMALLINT → BIGINT
// But NOT TINYINT(1) which is for booleans
// Match: INT, INT(11), INT UNSIGNED, INTEGER, etc.
// Don't match: BIGINT, TINYINT, or words containing these (like HINT, POINT)
// Convert INT variants (but not BIGINT or TINYINT)
$query = preg_replace(
'/\b(?:INT|INTEGER|MEDIUMINT|SMALLINT)(?:\(\d+\))?(?:\s+UNSIGNED)?(?!\w)/i',
'BIGINT',
$query
);
// Convert TINYINT(n) where n != 1 to BIGINT
$query = preg_replace(
'/\bTINYINT\s*\(\s*([2-9]|[1-9]\d+)\s*\)(?:\s+UNSIGNED)?/i',
'BIGINT',
$query
);
// Remove UNSIGNED from BIGINT columns (normalize to signed)
$query = preg_replace(
'/\bBIGINT(?:\(\d+\))?\s+UNSIGNED/i',
'BIGINT',
$query
);
return $query;
}
/**
* Transform floating point types to DOUBLE
*
* Converts: FLOAT, REAL DOUBLE
* Preserves: DECIMAL (for exact precision like money)
*
* @param string $query The SQL query
* @return string Transformed query
*/
private static function __transform_float_types(string $query): string
{
// FLOAT, REAL → DOUBLE
$query = preg_replace(
'/\b(?:FLOAT|REAL)(?:\(\d+(?:,\d+)?\))?(?:\s+UNSIGNED)?/i',
'DOUBLE',
$query
);
return $query;
}
/**
* Transform text column types
*
* Converts: TEXT, MEDIUMTEXT LONGTEXT
* Converts: CHAR(n) VARCHAR(n)
*
* @param string $query The SQL query
* @return string Transformed query
*/
private static function __transform_text_types(string $query): string
{
// TEXT, MEDIUMTEXT, TINYTEXT → LONGTEXT
$query = preg_replace(
'/\b(?:TINY|MEDIUM)?TEXT\b/i',
'LONGTEXT',
$query
);
// CHAR(n) → VARCHAR(n)
$query = preg_replace(
'/\bCHAR\s*\((\d+)\)/i',
'VARCHAR($1)',
$query
);
return $query;
}
/**
* Transform datetime column types to include millisecond precision
*
* Converts: TIMESTAMP TIMESTAMP(3)
* Converts: DATETIME DATETIME(3)
*
* This ensures datetime columns have millisecond precision from the start,
* preventing FK type mismatches when normalize_schema runs later.
*
* @param string $query The SQL query
* @return string Transformed query
*/
private static function __transform_datetime_types(string $query): string
{
// TIMESTAMP without precision → TIMESTAMP(3)
// Don't match if already has precision: TIMESTAMP(3), TIMESTAMP(6)
$query = preg_replace(
'/\bTIMESTAMP\b(?!\s*\()/i',
'TIMESTAMP(3)',
$query
);
// DATETIME without precision → DATETIME(3)
// Don't match if already has precision: DATETIME(3), DATETIME(6)
$query = preg_replace(
'/\bDATETIME\b(?!\s*\()/i',
'DATETIME(3)',
$query
);
return $query;
}
/**
* Enforce utf8mb4 character set on all string columns
*
* 1. Replaces existing latin1/utf8/utf8mb3 with utf8mb4
* 2. Adds CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci to columns without charset
* 3. Replaces table-level DEFAULT CHARSET with utf8mb4
*
* @param string $query The SQL query
* @return string Transformed query
*/
private static function __enforce_utf8mb4(string $query): string
{
// Replace existing CHARACTER SET declarations with utf8mb4
// Match: CHARACTER SET latin1, CHARACTER SET utf8, CHARACTER SET utf8mb3
$query = preg_replace(
'/CHARACTER\s+SET\s+(?:latin1|utf8mb3|utf8)\b/i',
'CHARACTER SET utf8mb4',
$query
);
// Replace existing COLLATE declarations with utf8mb4_unicode_ci
// Match: COLLATE latin1_swedish_ci, COLLATE utf8_general_ci, etc.
$query = preg_replace(
'/COLLATE\s+(?:latin1|utf8mb3|utf8)_\w+/i',
'COLLATE utf8mb4_unicode_ci',
$query
);
// Replace table-level DEFAULT CHARSET
$query = preg_replace(
'/DEFAULT\s+CHARSET\s*=\s*(?:latin1|utf8mb3|utf8)\b/i',
'DEFAULT CHARSET=utf8mb4',
$query
);
// Add charset to VARCHAR(n) that doesn't already have CHARACTER SET
// Negative lookahead allows whitespace and comments before CHARACTER SET check
$query = preg_replace(
'/\bVARCHAR\s*\(\s*\d+\s*\)(?!(?:\s|\/\*.*?\*\/|--[^\n]*)*\s*CHARACTER\s+SET)/i',
'$0 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
$query
);
// Add charset to LONGTEXT that doesn't already have CHARACTER SET
// Negative lookahead allows whitespace and comments before CHARACTER SET check
$query = preg_replace(
'/\bLONGTEXT\b(?!(?:\s|\/\*.*?\*\/|--[^\n]*)*\s*CHARACTER\s+SET)/i',
'$0 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
$query
);
return $query;
}
}