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.
Lightweight DataMapper over PDO for PHP 8.4+.
Rowcast maps database rows to DTOs and back using reflection, supports explicit/auto mapping, type conversion, and includes a fluent query builder with dialect-aware UPSERT.
Documentation: English | Русский | LLM/Context7
ext-pdocomposer require ascetic-soft/rowcast
use AsceticSoft\Rowcast\Connection;
use AsceticSoft\Rowcast\DataMapper;
class UserDto
{
public int $id;
public string $email;
public bool $isActive;
}
$connection = Connection::create('sqlite::memory:');
$mapper = new DataMapper($connection);
$user = new UserDto();
$user->email = 'alice@example.com';
$user->isActive = true;
$mapper->insert('users', $user);
$found = $mapper->findOne(UserDto::class, ['email' => $user->email]);
Rowcast supports two mapping styles:
class-string for reads and table name for writes. Names are converted via NameConverterInterface (default: SnakeCaseToCamelCase).Mapping to control table name, column/property pairs, and ignored properties.Table name is derived from DTO class name for reads:
| Class | Table |
|---|---|
User |
users |
UserProfile |
user_profiles |
Column/property conversion (default):
| Column | Property |
|---|---|
created_at |
createdAt |
is_active |
isActive |
use AsceticSoft\Rowcast\Mapping;
$mapping = Mapping::auto(UserDto::class, 'custom_users')
->column('usr_email', 'email')
->ignore('internalNote');
$user = $mapper->findOne($mapping, ['id' => 1]);
Use Mapping::explicit(...) when only declared columns must be used:
$mapping = Mapping::explicit(UserDto::class, 'custom_users')
->column('id', 'id')
->column('usr_email', 'email');
Notes:
Mapping is currently mutable and configured in place.Mapping instance after it is already shared across multiple operations or services.Connection wraps PDO and provides query helpers, transaction API, nested transaction support (savepoints), and query-builder factory.
use AsceticSoft\Rowcast\Connection;
// From DSN
$connection = Connection::create(
dsn: 'mysql:host=localhost;dbname=app',
username: 'root',
password: 'secret',
nestTransactions: true,
);
// From existing PDO
$pdo = new \PDO('sqlite::memory:');
$connection = new Connection($pdo, nestTransactions: true);
$stmt = $connection->executeQuery('SELECT * FROM users WHERE id = ?', [1]);
$affected = $connection->executeStatement('UPDATE users SET email = ? WHERE id = ?', ['a@x.com', 1]);
$rows = $connection->fetchAllAssociative('SELECT * FROM users');
$row = $connection->fetchAssociative('SELECT * FROM users WHERE id = ?', [1]);
$count = $connection->fetchOne('SELECT COUNT(*) FROM users');
Register lightweight listeners to observe SQL execution.
$connection->onBeforeQuery(function (string $sql, array $params): void {
// log or inspect SQL before execution
});
$connection->onAfterQuery(function (string $sql, array $params, float $duration, ?\Throwable $exception): void {
// $exception is null for successful queries
});
$connection->transactional(function (Connection $conn) {
$conn->executeStatement('INSERT INTO users (email) VALUES (?)', ['alice@example.com']);
});
Nested mode creates savepoints for inner transactions.
Main methods:
insert(string|Mapping $target, object $dto): voidbatchInsert(string|Mapping $target, array $dtos, ?int $maxBindParameters = null): voidupdate(string|Mapping $target, object $dto, array $where): intbatchUpdate(string|Mapping $target, array $dtos, array $identityProperties, ?int $maxBindParameters = null): voiddelete(string|Mapping $target, array $where): intfindAll(string|Mapping $target, array $where = [], array $orderBy = [], ?int $limit = null, ?int $offset = null): arrayiterateAll(string|Mapping $target, array $where = [], array $orderBy = [], ?int $limit = null, ?int $offset = null): iterablefindOne(string|Mapping $target, array $where = []): ?objectsave(string|Mapping $target, object $dto, string ...$identityProperties): voidupsert(string|Mapping $target, object $dto, string ...$conflictProperties): intbatchUpsert(string|Mapping $target, array $dtos, array $conflictProperties, ?int $maxBindParameters = null): voidhydrate(...), hydrateAll(...), extract(...)$dto = new UserDto();
$dto->email = 'alice@example.com';
$dto->isActive = true;
$mapper->insert('users', $dto);
$one = $mapper->findOne(UserDto::class, ['email' => $dto->email]);
$one->isActive = false;
$mapper->update('users', $one, ['id' => $one->id]);
$mapper->delete('users', ['id' => $one->id]);
save(...) Examplesave(...) checks row existence by identity columns, then performs insert or update.
Notes:
save(...) is a convenience API and does a read-before-write flow.upsert(...) when your database and schema support conflict handling and write-path efficiency matters.$mapper->save('users', $dto, 'id');
upsert(...) Example$affected = $mapper->upsert('users', $dto, 'email');
$mapper->batchInsert('users', [$dto1, $dto2, $dto3]);
$mapper->batchUpsert('users', [$dto1, $dto2, $dto3], ['id']);
$mapper->batchUpdate('users', [$dto1, $dto2, $dto3], ['id']);
// Optional override for chunk sizing by bind parameter limit
$mapper->batchInsert('users', [$dto1, $dto2, $dto3], 500);
$mapper->batchUpsert('users', [$dto1, $dto2, $dto3], ['id'], 500);
batchInsert and batchUpsert automatically split large input into chunks based on DB parameter limits (for example, SQLite: 999 bind params), while executing all chunks inside one transaction.
where in DataMapperDataMapper passes where arrays to the same QueryBuilder condition engine, so advanced operators are available there as well:
$users = $mapper->findAll(UserDto::class, where: [
'deleted_at' => null,
'$or' => [
['status' => ['active', 'pending']],
['role' => 'admin'],
],
'age >=' => 18,
]);
Rowcast converts DB values to declared PHP property types on hydrate, and PHP values to DB-safe values on extract/write.
Built-in converters:
ScalarConverter (int, float, string)BoolConverter (bool <-> 0/1)DateTimeConverter (DateTimeInterface <-> formatted UTC string)JsonConverter (array <-> JSON)EnumConverter (BackedEnum <-> backing value)Implement TypeConverterInterface and pass a custom registry to DataMapper:
use AsceticSoft\Rowcast\DataMapper;
use AsceticSoft\Rowcast\TypeConverter\TypeConverterInterface;
use AsceticSoft\Rowcast\TypeConverter\TypeConverterRegistry;
final class UuidConverter implements TypeConverterInterface
{
public function supports(string $phpType): bool
{
return $phpType === Uuid::class;
}
public function toPhp(mixed $value, string $phpType): mixed
{
return new Uuid((string) $value);
}
public function toDb(mixed $value): mixed
{
return (string) $value;
}
}
$converters = TypeConverterRegistry::defaults()->add(new UuidConverter());
$mapper = new DataMapper($connection, typeConverter: $converters);
Implement NameConverterInterface and pass it to DataMapper:
use AsceticSoft\Rowcast\DataMapper;
use AsceticSoft\Rowcast\NameConverter\NameConverterInterface;
final class PrefixedConverter implements NameConverterInterface
{
public function toPropertyName(string $columnName): string
{
return lcfirst(str_replace('usr_', '', $columnName));
}
public function toColumnName(string $propertyName): string
{
return 'usr_' . $propertyName;
}
}
$mapper = new DataMapper($connection, nameConverter: new PrefixedConverter());
Connection::createQueryBuilder() provides a fluent SQL builder.
$rows = $connection->createQueryBuilder()
->select('u.id', 'u.email')
->from('users', 'u')
->where('u.is_active = :active')
->orderBy('u.id', 'DESC')
->setOffset(20)
->setLimit(10)
->setParameter('active', 1)
->fetchAllAssociative();
For pagination, use:
setOffset(int $offset) — start rowsetLimit(int $limit) — max rowsYou can also pass associative arrays to where(), andWhere(), and orWhere():
$rows = $connection->createQueryBuilder()
->select('*')
->from('users')
->where(['email' => 'alice@example.com', 'is_active' => 1])
->fetchAllAssociative();
// SQL: SELECT * FROM users WHERE email = :w_email AND is_active = :w_is_active
array predicates are converted to field = :param expressions joined by AND:
where(['a' => 1, 'b' => 2]) -> a = :w_a AND b = :w_bandWhere(['a' => 1]) appends another AND blockorWhere(['a' => 1]) wraps previous predicate: (prev OR a = :w_a)Parameter names are generated automatically and made unique (:w_id, :w_id_1, ...).
Supported array operators:
// IS NULL / IS NOT NULL
->where(['deleted_at' => null]) // deleted_at IS NULL
->where(['deleted_at !=' => null]) // deleted_at IS NOT NULL
// IN / NOT IN
->where(['status' => ['active', 'pending']]) // status IN (...)
->where(['status !=' => ['banned']]) // status NOT IN (...)
->where(['status IN' => ['active']]) // explicit IN
->where(['status NOT IN' => ['banned']]) // explicit NOT IN
// BackedEnum in WHERE (direct QueryBuilder usage)
->where(['status' => UserStatus::Active]) // status = :w_status, parameter value: 'active'
->where(['status' => [UserStatus::Active, UserStatus::Inactive]]) // status IN (...), parameters: 'active', 'inactive'
// Comparison operators
->where(['age >' => 18, 'age <=' => 65, 'score !=' => 0])
// LIKE / ILIKE / NOT LIKE / NOT ILIKE
->where(['name LIKE' => '%alice%'])
->where(['name ILIKE' => '%alice%']) // useful for PostgreSQL
->where(['name NOT LIKE' => '%bot%'])
->where(['name NOT ILIKE' => '%bot%']) // PostgreSQL only
// BETWEEN
->where(['age BETWEEN' => [18, 65]])
Operator reference:
| Input | Example | SQL fragment (shape) |
|---|---|---|
| Equality | ['id' => 10] |
id = :w_id |
IS NULL |
['deleted_at' => null] |
deleted_at IS NULL |
IS NOT NULL |
['deleted_at !=' => null] |
deleted_at IS NOT NULL |
IN (auto) |
['status' => ['active', 'pending']] |
status IN (:w_status, :w_status_1) |
NOT IN (auto) |
['status !=' => ['banned']] |
status NOT IN (:w_status, ...) |
IN (explicit) |
['status IN' => ['active']] |
status IN (:w_status) |
NOT IN (explicit) |
['status NOT IN' => ['banned']] |
status NOT IN (:w_status) |
| Comparison | ['age >=' => 18] |
age >= :w_age |
LIKE |
['name LIKE' => 'A%'] |
name LIKE :w_name |
ILIKE |
['name ILIKE' => 'a%'] |
name ILIKE :w_name |
NOT LIKE |
['name NOT LIKE' => '%bot%'] |
name NOT LIKE :w_name |
NOT ILIKE |
['name NOT ILIKE' => '%bot%'] |
name NOT ILIKE :w_name |
BETWEEN |
['age BETWEEN' => [18, 65]] |
age BETWEEN :w_age AND :w_age_1 |
Notes:
IN array compiles to 1 = 0 (always false).NOT IN array compiles to 1 = 1 (always true).ILIKE and NOT ILIKE are PostgreSQL-specific.BackedEnum values are normalized to backing scalar values in WHERE parameters (including IN / NOT IN arrays).Dialect-specific operator support:
| Dialect | Extra operators over base set |
|---|---|
PostgreSQL (pgsql) |
ILIKE, NOT ILIKE |
MySQL (mysql) |
none |
SQLite (sqlite) |
none |
| Generic/other drivers | none |
Base set for all dialects: >, >=, <, <=, LIKE, NOT LIKE.
You can compose OR logic in two ways.
Method-based OR groups:
// (status = 'active' AND age > 18) OR (role = 'admin')
$rows = $connection->createQueryBuilder()
->select('*')
->from('users')
->whereOr(
['status' => 'active', 'age >' => 18],
['role' => 'admin'],
)
->fetchAllAssociative();
Combine with existing filters:
// deleted_at IS NULL AND ((status = 'active') OR (role = 'admin'))
$rows = $connection->createQueryBuilder()
->select('*')
->from('users')
->where(['deleted_at' => null])
->andWhereOr(['status' => 'active'], ['role' => 'admin'])
->fetchAllAssociative();
Nested-key style in one array:
$rows = $connection->createQueryBuilder()
->select('*')
->from('users')
->where([
'age >' => 18,
'$or' => [
['status' => 'active'],
['$and' => [
['role' => 'admin'],
['verified' => true],
]],
],
])
->fetchAllAssociative();
OR composition reference:
| Pattern | Example | SQL fragment (shape) |
|---|---|---|
whereOr(...groups) |
->whereOr(['status' => 'active'], ['role' => 'admin']) |
((status = :w_status) OR (role = :w_role)) |
andWhereOr(...groups) |
->where(['deleted_at' => null])->andWhereOr(['status' => 'active'], ['role' => 'admin']) |
deleted_at IS NULL AND ((status = :w_status) OR (role = :w_role)) |
$or inside where([...]) |
->where(['age >' => 18, '$or' => [['status' => 'active'], ['role' => 'admin']]]) |
age > :w_age AND ((status = :w_status) OR (role = :w_role)) |
$and inside $or |
->where(['$or' => [['status' => 'active'], ['$and' => [['role' => 'admin'], ['verified' => true]]]]]) |
((status = :w_status) OR ((role = :w_role) AND (verified = :w_verified))) |
| Mixed operators in OR groups | ->whereOr(['status' => ['active', 'pending'], 'deleted_at' => null], ['name LIKE' => 'A%', 'age BETWEEN' => [18, 65]]) |
((status IN (...) AND deleted_at IS NULL) OR (name LIKE :w_name AND age BETWEEN :w_age AND :w_age_1)) |
$connection->createQueryBuilder()
->insert('users')
->values(['email' => ':email', 'is_active' => ':is_active'])
->setParameter('email', 'alice@example.com')
->setParameter('is_active', 1)
->executeStatement();
$connection->createQueryBuilder()
->update('users')
->values(['is_active' => ':is_active'])
->where('id = :id')
->setParameter('is_active', 0)
->setParameter('id', 1)
->executeStatement();
$connection->createQueryBuilder()
->delete('users')
->where('id = :id')
->setParameter('id', 1)
->executeStatement();
$sql = $connection->createQueryBuilder()
->upsert('users')
->values(['email' => ':email', 'name' => ':name'])
->onConflict('email')
->doUpdateSet(['name'])
->getSQL();
Upsert is compiled via SQL dialects:
MysqlDialectPostgresDialectSqliteDialectGenericDialect (throws for unsupported UPSERT)WHERE array operator support is also dialect-aware (for example, ILIKE/NOT ILIKE only for PostgreSQL).
Use Expression::raw() to embed arbitrary SQL expressions into UPDATE SET or INSERT VALUES:
use AsceticSoft\Rowcast\QueryBuilder\Expression\Expression;
// Decrement balance: balance = balance - :amount
$qb = $connection->createQueryBuilder()
->update('credit_accounts')
->set('balance', Expression::raw('balance - :amount'))
->where(['id' => 1])
->setParameter('amount', 100)
->executeStatement();
// Using NOW() in INSERT
$connection->createQueryBuilder()
->insert('logs')
->values([
'message' => ':msg',
'created_at' => Expression::raw('NOW()'),
])
->setParameter('msg', 'user login')
->executeStatement();
// Column references in expressions
$connection->createQueryBuilder()
->update('items')
->set('quantity', Expression::raw('quantity + :add_qty'))
->where(['id' => 5])
->setParameter('add_qty', 3)
->executeStatement();
Expression SQL is written through verbatim — no parameter binding or quoting happens on the expression itself. You pass placeholders (:param) as part of the raw expression and bind them separately via setParameter().
AsceticSoft\Rowcast\
├── ConnectionInterface
├── Connection
├── DataMapper
├── Hydrator
├── Extractor
├── Mapping
├── TargetResolver
├── QueryHelper
├── NameConverter\
│ ├── NameConverterInterface
│ └── SnakeCaseToCamelCase
├── TypeConverter\
│ ├── TypeConverterInterface
│ ├── TypeConverterRegistry
│ ├── ScalarConverter
│ ├── BoolConverter
│ ├── DateTimeConverter
│ ├── JsonConverter
│ └── EnumConverter
└── QueryBuilder\
├── QueryBuilder
├── QueryType
├── Dialect\
│ ├── DialectInterface
│ ├── DialectFactory
│ ├── AbstractStandardDialect
│ ├── AbstractOnConflictDialect
│ ├── MysqlDialect
│ ├── PostgresDialect
│ ├── SqliteDialect
│ └── GenericDialect
└── Compiler\
├── SqlCompilerInterface
├── SqlFragments
├── SelectCompiler
├── InsertCompiler
├── UpsertCompiler
├── UpdateCompiler
└── DeleteCompiler
composer install
make install-hooks
vendor/bin/phpunit
vendor/bin/phpstan analyse
MIT
How can I help you explore Laravel packages today?