Use string-based date/datetime casts instead of Carbon objects
Add TODO expectations and comments documentation to expect_files man page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -430,9 +430,12 @@ abstract class Rsx_Model_Abstract extends Model
|
||||
*
|
||||
* Auto-detected casts:
|
||||
* - TINYINT(1) columns → 'boolean'
|
||||
* - DATETIME/TIMESTAMP columns → 'datetime'
|
||||
* - DATE columns → 'date'
|
||||
* - TIME columns → 'time'
|
||||
* - DATETIME/TIMESTAMP columns → Rsx_DateTime_Cast (returns ISO 8601 UTC strings)
|
||||
* - DATE columns → Rsx_Date_Cast (returns YYYY-MM-DD strings)
|
||||
* - TIME columns → 'time' (string)
|
||||
*
|
||||
* IMPORTANT: Date and datetime columns return STRINGS, not Carbon objects.
|
||||
* This prevents timezone bugs and keeps PHP/JS in sync with identical formats.
|
||||
*
|
||||
* Users can still override by defining their own $casts property.
|
||||
*
|
||||
@@ -455,26 +458,29 @@ abstract class Rsx_Model_Abstract extends Model
|
||||
}
|
||||
|
||||
// Auto-detect datetime columns (DATETIME, TIMESTAMP)
|
||||
// Returns ISO 8601 UTC strings, NOT Carbon objects
|
||||
$datetime_columns = \App\RSpade\Core\Manifest\Manifest::db_get_columns_by_type($table_name, 'datetime');
|
||||
foreach ($datetime_columns as $column) {
|
||||
if (!isset($casts[$column])) {
|
||||
$casts[$column] = 'datetime';
|
||||
$casts[$column] = \App\RSpade\Core\Time\Rsx_DateTime_Cast::class;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect date columns (DATE)
|
||||
// Returns YYYY-MM-DD strings, NOT Carbon objects
|
||||
$date_columns = \App\RSpade\Core\Manifest\Manifest::db_get_columns_by_type($table_name, 'date');
|
||||
foreach ($date_columns as $column) {
|
||||
if (!isset($casts[$column])) {
|
||||
$casts[$column] = 'date';
|
||||
$casts[$column] = \App\RSpade\Core\Time\Rsx_Date_Cast::class;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect time columns (TIME)
|
||||
// Laravel's 'time' cast returns strings, which is what we want
|
||||
$time_columns = \App\RSpade\Core\Manifest\Manifest::db_get_columns_by_type($table_name, 'time');
|
||||
foreach ($time_columns as $column) {
|
||||
if (!isset($casts[$column])) {
|
||||
$casts[$column] = 'time';
|
||||
$casts[$column] = 'string';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
118
app/RSpade/Core/Time/Rsx_DateTime_Cast.php
Executable file
118
app/RSpade/Core/Time/Rsx_DateTime_Cast.php
Executable file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Time;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\RSpade\Core\Time\Rsx_Time;
|
||||
|
||||
/**
|
||||
* Custom Eloquent cast for DATETIME/TIMESTAMP columns
|
||||
*
|
||||
* Converts between:
|
||||
* - Database: MySQL format "YYYY-MM-DD HH:MM:SS" (stored as UTC)
|
||||
* - PHP/JSON: ISO 8601 format "YYYY-MM-DDTHH:MM:SS.sssZ" (UTC)
|
||||
*
|
||||
* Returns strings, NOT Carbon objects. This keeps PHP/JS in sync
|
||||
* with identical string formats on both sides.
|
||||
*/
|
||||
#[Instantiatable]
|
||||
class Rsx_DateTime_Cast implements CastsAttributes
|
||||
{
|
||||
/**
|
||||
* Cast the value when reading from database
|
||||
*
|
||||
* Converts MySQL format to ISO 8601 UTC string
|
||||
*
|
||||
* @param Model $model
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
* @param array $attributes
|
||||
* @return string|null
|
||||
*/
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Already ISO format (from cached value or manual set)
|
||||
if (is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $value)) {
|
||||
// Normalize to consistent format with milliseconds and Z
|
||||
return Rsx_Time::to_iso(Rsx_Time::parse($value));
|
||||
}
|
||||
|
||||
// MySQL format "YYYY-MM-DD HH:MM:SS" - convert to ISO
|
||||
if (is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', $value)) {
|
||||
// Database stores in UTC, parse as UTC
|
||||
$carbon = Carbon::createFromFormat(
|
||||
strlen($value) > 19 ? 'Y-m-d H:i:s.v' : 'Y-m-d H:i:s',
|
||||
$value,
|
||||
'UTC'
|
||||
);
|
||||
return $carbon->format('Y-m-d\TH:i:s.v\Z');
|
||||
}
|
||||
|
||||
// Carbon object (shouldn't happen but handle gracefully during transition)
|
||||
if ($value instanceof Carbon) {
|
||||
return $value->setTimezone('UTC')->format('Y-m-d\TH:i:s.v\Z');
|
||||
}
|
||||
|
||||
// Unexpected type - fail loud
|
||||
throw new \InvalidArgumentException(
|
||||
"Rsx_DateTime_Cast: Expected datetime string, got " . gettype($value) . ": " . var_export($value, true)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast the value when writing to database
|
||||
*
|
||||
* Converts ISO 8601 format to MySQL format for storage
|
||||
*
|
||||
* @param Model $model
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
* @param array $attributes
|
||||
* @return string|null
|
||||
*/
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ISO 8601 format - convert to MySQL format
|
||||
if (is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $value)) {
|
||||
$carbon = Carbon::parse($value)->setTimezone('UTC');
|
||||
return $carbon->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
// MySQL format already - validate and pass through
|
||||
if (is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', $value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Unix timestamp (seconds or milliseconds)
|
||||
if (is_int($value)) {
|
||||
// Detect milliseconds vs seconds
|
||||
if ($value > 10000000000) {
|
||||
$carbon = Carbon::createFromTimestampMs($value, 'UTC');
|
||||
} else {
|
||||
$carbon = Carbon::createFromTimestamp($value, 'UTC');
|
||||
}
|
||||
return $carbon->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
// Carbon object (shouldn't happen but handle gracefully during transition)
|
||||
if ($value instanceof Carbon) {
|
||||
return $value->setTimezone('UTC')->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
// Reject other types - fail loud
|
||||
throw new \InvalidArgumentException(
|
||||
"Rsx_DateTime_Cast: Invalid datetime format for '$key': " . var_export($value, true) .
|
||||
". Expected ISO 8601 string (YYYY-MM-DDTHH:MM:SS.sssZ) or Unix timestamp."
|
||||
);
|
||||
}
|
||||
}
|
||||
87
app/RSpade/Core/Time/Rsx_Date_Cast.php
Executable file
87
app/RSpade/Core/Time/Rsx_Date_Cast.php
Executable file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\RSpade\Core\Time;
|
||||
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\RSpade\Core\Time\Rsx_Date;
|
||||
|
||||
/**
|
||||
* Custom Eloquent cast for DATE columns
|
||||
*
|
||||
* Returns dates as "YYYY-MM-DD" strings, NOT Carbon objects.
|
||||
* This prevents timezone bugs - a date is just a calendar day, not a moment in time.
|
||||
*
|
||||
* Database: 2025-12-24 → PHP: "2025-12-24" → JSON: "2025-12-24"
|
||||
*/
|
||||
#[Instantiatable]
|
||||
class Rsx_Date_Cast implements CastsAttributes
|
||||
{
|
||||
/**
|
||||
* Cast the value when reading from database
|
||||
*
|
||||
* @param Model $model
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
* @param array $attributes
|
||||
* @return string|null
|
||||
*/
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Already a string in YYYY-MM-DD format from MySQL
|
||||
if (is_string($value)) {
|
||||
// Validate it's a proper date format
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// MySQL might return datetime format for some reason - extract date part
|
||||
if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Unexpected type - fail loud
|
||||
throw new \InvalidArgumentException(
|
||||
"Rsx_Date_Cast: Expected date string, got " . gettype($value) . ": " . var_export($value, true)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast the value when writing to database
|
||||
*
|
||||
* @param Model $model
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
* @param array $attributes
|
||||
* @return string|null
|
||||
*/
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Accept YYYY-MM-DD strings
|
||||
if (is_string($value)) {
|
||||
// Validate format
|
||||
$parsed = Rsx_Date::parse($value);
|
||||
if ($parsed === null) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Rsx_Date_Cast: Invalid date format for '$key': '$value'. Expected YYYY-MM-DD."
|
||||
);
|
||||
}
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
// Reject Carbon and other types - fail loud
|
||||
throw new \InvalidArgumentException(
|
||||
"Rsx_Date_Cast: Expected date string (YYYY-MM-DD), got " . gettype($value) .
|
||||
". Use Rsx_Date::today() or string literals, not Carbon objects."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,16 @@ use App\RSpade\Core\Session\Session;
|
||||
* Datetimes represent specific moments in time. They always have a time component
|
||||
* and are timezone-aware. Stored in UTC, displayed in user's timezone.
|
||||
*
|
||||
* All methods are static. Time values are represented as:
|
||||
* - Carbon objects (internal)
|
||||
* - ISO 8601 strings for serialization: "2024-12-24T15:30:45.123Z"
|
||||
* - Unix timestamps (milliseconds) for JavaScript interop
|
||||
* STRINGS, NOT CARBON: All external-facing methods return ISO 8601 strings
|
||||
* (format: "2024-12-24T15:30:45.123Z"). This keeps PHP and JavaScript in sync
|
||||
* with identical formats on both sides. Carbon is used internally for calculations.
|
||||
*
|
||||
* Model properties ($model->created_at) also return ISO strings via Rsx_DateTime_Cast.
|
||||
*
|
||||
* Core Principles:
|
||||
* - All datetimes stored in database as UTC
|
||||
* - All serialization uses ISO 8601 format
|
||||
* - External APIs return strings, not Carbon
|
||||
* - User timezone stored per user (login_users.timezone)
|
||||
* - Formatting happens on-demand, not on storage
|
||||
* - PHP and JS APIs are parallel (same method names)
|
||||
@@ -372,15 +374,15 @@ class Rsx_Time
|
||||
*
|
||||
* @param mixed $time
|
||||
* @param int $seconds
|
||||
* @return Carbon
|
||||
* @return string ISO 8601 UTC string
|
||||
*/
|
||||
public static function add($time, int $seconds): Carbon
|
||||
public static function add($time, int $seconds): string
|
||||
{
|
||||
$carbon = static::parse($time);
|
||||
if (!$carbon) {
|
||||
throw new \InvalidArgumentException("Cannot parse time");
|
||||
}
|
||||
return $carbon->addSeconds($seconds);
|
||||
return $carbon->addSeconds($seconds)->format('Y-m-d\TH:i:s.v\Z');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -388,9 +390,9 @@ class Rsx_Time
|
||||
*
|
||||
* @param mixed $time
|
||||
* @param int $seconds
|
||||
* @return Carbon
|
||||
* @return string ISO 8601 UTC string
|
||||
*/
|
||||
public static function subtract($time, int $seconds): Carbon
|
||||
public static function subtract($time, int $seconds): string
|
||||
{
|
||||
return static::add($time, -$seconds);
|
||||
}
|
||||
|
||||
@@ -119,6 +119,55 @@ Optional category prefix groups related expectations:
|
||||
EXPECT: Honor user timezone preference
|
||||
...
|
||||
|
||||
TODO EXPECTATIONS
|
||||
|
||||
Use TODO: prefix for planned functionality that doesn't exist yet:
|
||||
|
||||
TODO: Batch timezone conversion
|
||||
GIVEN: Array of timestamps
|
||||
WHEN: Passed to convert_batch()
|
||||
THEN: All timestamps converted in single operation
|
||||
---
|
||||
|
||||
TODO: Timezone autodetection from browser
|
||||
GIVEN: No user timezone preference set
|
||||
WHEN: User visits page for first time
|
||||
THEN: Browser timezone is detected and stored
|
||||
---
|
||||
|
||||
TODO expectations document future requirements without implying the feature
|
||||
exists. The test runner will skip these until converted to EXPECT blocks.
|
||||
|
||||
COMMENTS AND NOTES
|
||||
|
||||
Expect files can include freeform comments and explanations. These help
|
||||
when eventually writing tests by capturing context, edge cases, and
|
||||
implementation hints.
|
||||
|
||||
Use # for inline comments within blocks:
|
||||
|
||||
EXPECT: Parse Unix timestamps
|
||||
GIVEN: Integer 1703432400
|
||||
WHEN: Passed to parse()
|
||||
THEN: Returns correct datetime
|
||||
# Note: Must detect seconds vs milliseconds automatically
|
||||
# Threshold: values > 10 billion are milliseconds
|
||||
---
|
||||
|
||||
Use plain text between blocks for extended notes:
|
||||
|
||||
## Edge Cases
|
||||
|
||||
The following expectations cover boundary conditions. When implementing
|
||||
tests, pay special attention to timezone transitions (DST changes) and
|
||||
leap seconds. The framework deliberately ignores leap seconds.
|
||||
|
||||
EXPECT: Handle DST transition
|
||||
...
|
||||
|
||||
These comments are preserved in the file but ignored by the test runner.
|
||||
They serve as institutional knowledge for future test authors.
|
||||
|
||||
FUTURE: AUTOMATED TEST RUNNER
|
||||
|
||||
The planned test runner will:
|
||||
|
||||
@@ -44,6 +44,24 @@ DESCRIPTION
|
||||
"December 24, 2025" is the same day everywhere.
|
||||
Format: Always "YYYY-MM-DD" (e.g., "2024-12-24")
|
||||
|
||||
STRINGS, NOT OBJECTS
|
||||
All external-facing APIs return STRINGS, not Carbon or Date objects:
|
||||
|
||||
$model->created_at // "2024-12-24T15:30:45.123Z" (string)
|
||||
$model->due_date // "2024-12-24" (string)
|
||||
Rsx_Time::now_iso() // "2024-12-24T15:30:45.123Z" (string)
|
||||
Rsx_Time::to_iso($x) // "2024-12-24T15:30:45.123Z" (string)
|
||||
Rsx_Time::add($x, 60) // "2024-12-24T15:31:45.123Z" (string)
|
||||
|
||||
This design keeps PHP and JavaScript in sync - identical string formats
|
||||
on both sides, no serialization surprises.
|
||||
|
||||
Carbon is used internally for calculations, but never exposed externally.
|
||||
The only method returning Carbon is parse(), for internal calculations:
|
||||
|
||||
$carbon = Rsx_Time::parse($time); // Carbon for calculations
|
||||
$result = Rsx_Time::to_iso($carbon); // Back to string for output
|
||||
|
||||
CRITICAL: Type Separation
|
||||
Date functions THROW if passed a datetime.
|
||||
Datetime functions THROW if passed a date-only string.
|
||||
@@ -111,19 +129,22 @@ RSX_DATE CLASS
|
||||
Returns "YYYY-MM-DD" for database storage (same as ISO format).
|
||||
|
||||
RSX_TIME CLASS
|
||||
All functions work with ISO 8601 datetime strings or Carbon/Date objects.
|
||||
All functions accept ISO 8601 datetime strings or Carbon/Date objects.
|
||||
All functions RETURN strings (except parse, which returns Carbon for
|
||||
internal calculations).
|
||||
|
||||
Parsing & Validation
|
||||
parse($input)
|
||||
Returns Carbon (PHP) or Date (JS) in UTC.
|
||||
Used for internal calculations, not for output.
|
||||
THROWS on date-only string input.
|
||||
|
||||
is_datetime($input)
|
||||
Returns true if input is valid datetime (not date-only).
|
||||
|
||||
Current Time
|
||||
now() Returns current time as Carbon/Date (UTC)
|
||||
now_iso() Returns current time as ISO 8601 string
|
||||
now() Returns current time as Carbon/Date (for calculations)
|
||||
now_iso() Returns current time as ISO 8601 string (preferred)
|
||||
now_ms() Returns current time as Unix milliseconds
|
||||
|
||||
Timezone Handling
|
||||
@@ -175,10 +196,10 @@ RSX_TIME CLASS
|
||||
|
||||
Arithmetic
|
||||
add($time, $seconds)
|
||||
Add seconds to time.
|
||||
Add seconds to time. Returns ISO 8601 string.
|
||||
|
||||
subtract($time, $seconds)
|
||||
Subtract seconds from time.
|
||||
Subtract seconds from time. Returns ISO 8601 string.
|
||||
|
||||
Comparison
|
||||
is_past($time) True if datetime is in the past
|
||||
@@ -267,6 +288,7 @@ DATA FLOW
|
||||
Database: DATE column, value "2025-12-24"
|
||||
|
|
||||
PHP Model: $task->due_date = "2025-12-24" (string)
|
||||
| (Rsx_Date_Cast returns YYYY-MM-DD strings, NOT Carbon)
|
||||
|
|
||||
JSON Response: {"due_date": "2025-12-24"}
|
||||
|
|
||||
@@ -284,7 +306,8 @@ DATA FLOW
|
||||
|
||||
Database: DATETIME(3), value "2025-12-24 15:30:45.123" (UTC)
|
||||
|
|
||||
PHP Model: $event->scheduled_at = Carbon instance (UTC)
|
||||
PHP Model: $event->scheduled_at = "2025-12-24T15:30:45.123Z" (string)
|
||||
| (Rsx_DateTime_Cast converts MySQL format to ISO 8601)
|
||||
|
|
||||
JSON Serialize: {"scheduled_at": "2025-12-24T15:30:45.123Z"}
|
||||
|
|
||||
@@ -298,6 +321,7 @@ DATA FLOW
|
||||
Form Submit: {"scheduled_at": "2025-12-24T16:00:00.000Z"}
|
||||
|
|
||||
PHP Controller: Rsx_Time::parse($params['scheduled_at']) -> Carbon
|
||||
| (only if calculations needed; can also save string directly)
|
||||
|
|
||||
Database: "2025-12-24 16:00:00.000"
|
||||
|
||||
@@ -374,8 +398,38 @@ CONFIGURATION
|
||||
|
||||
User timezone stored in login_users.timezone column.
|
||||
|
||||
MODEL CASTING
|
||||
Model date/datetime columns are automatically cast to strings via custom
|
||||
Eloquent casts. No configuration needed - columns are detected from the
|
||||
database schema.
|
||||
|
||||
Rsx_DateTime_Cast (for DATETIME/TIMESTAMP columns)
|
||||
- Database read: "2024-12-24 15:30:45" -> "2024-12-24T15:30:45.000Z"
|
||||
- Database write: "2024-12-24T15:30:45.000Z" -> "2024-12-24 15:30:45"
|
||||
- Accepts: ISO 8601 strings, MySQL datetime strings, Unix timestamps
|
||||
- Rejects: Carbon objects (use to_iso() to convert first)
|
||||
|
||||
Rsx_Date_Cast (for DATE columns)
|
||||
- Database read: "2024-12-24" -> "2024-12-24"
|
||||
- Database write: "2024-12-24" -> "2024-12-24"
|
||||
- Accepts: YYYY-MM-DD strings only
|
||||
- Rejects: Carbon objects, datetime strings
|
||||
|
||||
Why strings instead of Carbon?
|
||||
1. Prevents timezone bugs - dates have no timezone, Carbon assumes one
|
||||
2. PHP/JS parity - identical formats on both sides
|
||||
3. No serialization surprises - what you see is what you get
|
||||
4. Simpler mental model - just strings everywhere
|
||||
|
||||
Overriding casts:
|
||||
If a model needs different behavior, override $casts in the model:
|
||||
|
||||
protected $casts = [
|
||||
'special_date' => 'date', // Use Laravel's default
|
||||
];
|
||||
|
||||
SEE ALSO
|
||||
Reference document: /var/www/html/date_vs_datetime_refactor.md
|
||||
Rsx_Time, Rsx_Date, Rsx_DateTime_Cast, Rsx_Date_Cast
|
||||
|
||||
AUTHOR
|
||||
RSpade Framework
|
||||
|
||||
Reference in New Issue
Block a user