ascetic-soft/rowcast
Rowcast is a lightweight PDO DataMapper for PHP 8.4+. It maps DB rows to DTOs via reflection with auto/explicit mapping and type conversion, plus a fluent query builder with dialect-aware UPSERT.
## Getting Started
### Minimal Setup
1. **Install the package**:
```bash
composer require ascetic-soft/rowcast
Create a connection (e.g., SQLite in-memory for testing):
use AsceticSoft\Rowcast\Connection;
$connection = Connection::create('sqlite::memory:');
Define a DTO (Data Transfer Object):
class UserDto {
public int $id;
public string $email;
public bool $isActive;
}
Initialize DataMapper:
use AsceticSoft\Rowcast\DataMapper;
$mapper = new DataMapper($connection);
First CRUD operation (insert and fetch):
$user = new UserDto();
$user->email = 'alice@example.com';
$user->isActive = true;
$mapper->insert('users', $user); // Auto-maps to `users` table
$foundUser = $mapper->findOne(UserDto::class, ['email' => $user->email]);
UserDto → users table, created_at → createdAt).Mapping for custom table/column names or ignored properties.int, bool, DateTime).When: Using standard table/column naming conventions. Pattern:
// Insert
$mapper->insert(UserDto::class, $dto);
// Find
$users = $mapper->findAll(UserDto::class, ['isActive' => true]);
Pros: Zero boilerplate for simple cases.
Cons: Limited to default naming rules (e.g., snake_case → camelCase).
When: Custom table/column names, ignored properties, or partial mapping. Pattern:
use AsceticSoft\Rowcast\Mapping;
$mapping = Mapping::auto(UserDto::class, 'custom_users')
->column('usr_email', 'email') // Override column name
->ignore('internalNote'); // Skip property
$mapper->findOne($mapping, ['id' => 1]);
Best Practice:
Mapping instances (e.g., as class constants) to avoid recreation overhead.Mapping::explicit() for strict column whitelisting.When: Bulk inserts/updates (e.g., importing data). Pattern:
$users = [$dto1, $dto2, $dto3];
$mapper->batchInsert('users', $users); // Chunks automatically for large datasets
$mapper->batchUpsert('users', $users, ['email'], 500); // 500 params per chunk
Tip: Use maxBindParameters to control chunk size (critical for SQLite’s 999-param limit).
When: Custom SQL logic or advanced filtering. Pattern:
$qb = $connection->createQueryBuilder();
$qb->select('u.*')
->from('users', 'u')
->where(['isActive' => true, 'age >=' => 18])
->orWhere(['role' => 'admin'])
->orderBy('u.id', 'DESC');
$users = $qb->fetchAllAssociative();
Key Features:
ILIKE (PostgreSQL), BETWEEN, and IN/NOT IN.:w_age, :w_age_1).When: Custom data types (e.g., UUIDs, JSON). Pattern:
use AsceticSoft\Rowcast\TypeConverter\TypeConverterRegistry;
$converters = TypeConverterRegistry::defaults()
->add(new UuidConverter());
$mapper = new DataMapper($connection, typeConverter: $converters);
Built-in Converters:
DateTimeInterface → ISO string.BackedEnum → backing value.array → JSON string.When: Atomic operations or nested transactions. Pattern:
$connection->transactional(function (Connection $conn) {
$conn->executeStatement('INSERT INTO users (...) VALUES (...)');
// Nested transactions use savepoints if `nestTransactions: true`.
});
Use Case: Rollback partial failures (e.g., multi-step imports).
When: Insert-or-update logic without duplicate checks. Pattern:
$mapper->upsert('users', $dto, 'email'); // Conflict on `email`
Dialect Note: Uses ON CONFLICT (PostgreSQL) or INSERT ... ON DUPLICATE KEY (MySQL).
UserDto → user_dtos (pluralized snake_case).
Fix: Use Mapping::auto(UserDto::class, 'users') to override.created_at → createdAt (default).
Fix: Customize with NameConverterInterface or explicit mapping.maxBindParameters for large batches:
$mapper->batchInsert('users', $dtos, 500);
$mapper->batchInsert(..., null, false).UnsupportedTypeException for unsupported PHP types.
Fix: Register a custom TypeConverterInterface.BackedEnum values are normalized to their backing type (e.g., UserStatus::Active → 'active').
Tip: Use explicit converters for complex enums.IN Clauses: ['status' => []] → 1 = 0 (always false).
Workaround: Skip the condition or use ['status !=' => []] (always true).ILIKE fails on MySQL/SQLite.
Fix: Use LIKE or implement a custom dialect.:w_age_1) may collide.
Tip: Use explicit params for clarity:
$qb->where('age > :min_age')->setParameter('min_age', 18);
Mapping: Avoid recreating Mapping instances for repeated operations.iterateAll() for large datasets to avoid memory spikes:
foreach ($mapper->iterateAll(UserDto::class, []) as $user) {
// Process one row at a time
}
findOne() over findAll() + array search for lookups.$connection->onBeforeQuery(function (string $sql, array $params) {
error_log("SQL: $sql | Params: " . json_encode($params));
});
TypeConverterRegistry.nestTransactions: true to isolate failures:
$connection = Connection::create('mysql:...', nestTransactions: true);
DialectInterface for unsupported databases (e.g., Oracle).NameConverterInterface for non-snake_case schemas.onBeforeQuery/onAfterQuery for logging/auditing.Connection and DataMapper as singletons:
$this->app->singleton(Connection::class, function () {
return Connection::create(env('DB_DSN'), env('DB_USER'), env('DB_PASS'));
});
DataMapper for:
How can I help you explore Laravel packages today?