Docs/examples/CI polish

This commit is contained in:
Funky Waddle 2025-12-11 07:37:40 -06:00
parent 693e83625d
commit 7d4aea0a84
9 changed files with 420 additions and 32 deletions

View file

@ -34,6 +34,19 @@ jobs:
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 30 --health-retries 30
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: pairity
POSTGRES_USER: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 20
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -42,7 +55,7 @@ jobs:
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
extensions: pdo, pdo_mysql, pdo_sqlite, mongodb extensions: pdo, pdo_mysql, pdo_sqlite, pdo_pgsql, mongodb
coverage: none coverage: none
- name: Install dependencies - name: Install dependencies
@ -61,6 +74,15 @@ jobs:
done done
mysql -h 127.0.0.1 -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS pairity;' mysql -h 127.0.0.1 -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS pairity;'
- name: Prepare Postgres
run: |
for i in {1..30}; do
if pg_isready -h 127.0.0.1 -p 5432 -U postgres; then
break
fi
sleep 2
done
- name: Run tests - name: Run tests
env: env:
MYSQL_HOST: 127.0.0.1 MYSQL_HOST: 127.0.0.1
@ -70,5 +92,18 @@ jobs:
MYSQL_PASS: root MYSQL_PASS: root
MONGO_HOST: 127.0.0.1 MONGO_HOST: 127.0.0.1
MONGO_PORT: 27017 MONGO_PORT: 27017
POSTGRES_HOST: 127.0.0.1
POSTGRES_PORT: 5432
POSTGRES_DB: pairity
POSTGRES_USER: postgres
POSTGRES_PASS: postgres
run: | run: |
vendor/bin/phpunit --colors=always vendor/bin/phpunit --colors=always
- name: Static analysis (PHPStan)
run: |
if [ -f phpstan.neon.dist ]; then vendor/bin/phpstan analyse --no-progress || true; fi
- name: Style check (PHPCS)
run: |
if [ -f phpcs.xml.dist ]; then vendor/bin/phpcs || true; fi

15
CHANGELOG.md Normal file
View file

@ -0,0 +1,15 @@
### Changelog
All notable changes to this project will be documented in this file.
#### Unreleased
- Core ORM (DAO/DTO) with dynamic finders, `fields()` projection, relations (hasOne/hasMany/belongsTo), nested eager loading, perrelation constraints, and SQL `belongsToMany` with pivot helpers (`attach`, `detach`, `sync`).
- MongoDB production adapter (`ext-mongodb` + `mongodb/mongodb`) and Mongo DAO layer with relations (MVP), projections/sort/limit, pagination, and a small filter builder.
- Pagination helpers for SQL and Mongo: `paginate` and `simplePaginate`.
- Model metadata & schema mapping: column casts (incl. custom casters), timestamps, soft deletes.
- Migrations & Schema Builder (portable): create/drop/alter; CLI (`vendor/bin/pairity`) with migrate/rollback/status/reset/make:migration. Drivers: MySQL/MariaDB, SQLite, PostgreSQL, SQL Server, Oracle.
- Joinbased eager loading (optin, SQL, singlelevel) with safe fallbacks.
- Unit of Work (optin): identity map; deferred updates/deletes; relationaware delete cascades; optimistic locking; snapshot diffing (flagged); identity map controls; coalescing.
- Event system: DAO and UoW events; listeners/subscribers.
- CI: GitHub Actions matrix (PHP 8.18.3) with MySQL + Mongo services; guarded tests.

View file

@ -971,6 +971,28 @@ final class AuditSubscriber implements SubscriberInterface
Events::dispatcher()->subscribe(new AuditSubscriber()); Events::dispatcher()->subscribe(new AuditSubscriber());
``` ```
## Performance knobs (Milestone G)
Pairity includes a few optin performance features. Defaults remain conservative and portable.
- PDO preparedstatement cache (bounded LRU):
- Internals: `Pairity\Database\PdoConnection` caches prepared statements by SQL string.
- API: `$conn->setStatementCacheSize(100);` (0 disables). Default: 100.
- Query timing hook:
- API: `$conn->setQueryLogger(function(string $sql, array $params, float $ms) { /* log */ });`
- Called for both `query()` and `execute()`; zero overhead when unset.
- Eager loader INbatching (SQL + Mongo):
- DAOs chunk large `IN (...)` / `$in` lookups to avoid huge parameter lists.
- API: `$dao->setInBatchSize(1000);` (default 1000) — affects internal relation fetches and `findAllWhereIn()`.
- Metadata memoization:
- DAOs memoize `schema()` and `relations()` per instance to reduce repeated array building.
- No user action required; available automatically.
Example UoW + locking + snapshots demo: see `examples/uow_locking_snapshot.php`.
## Roadmap ## Roadmap
- Relations enhancements: - Relations enhancements:

67
examples/events_audit.php Normal file
View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
use Pairity\Events\Events;
// SQLite demo DB
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => __DIR__ . '/../db.sqlite',
]);
// Ensure table
$conn->execute('CREATE TABLE IF NOT EXISTS audit_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT,
name TEXT,
status TEXT
)');
class UserDto extends AbstractDto {}
class UserDao extends AbstractDao {
public function getTable(): string { return 'audit_users'; }
protected function dtoClass(): string { return UserDto::class; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>[
'id'=>['cast'=>'int'],'email'=>['cast'=>'string'],'name'=>['cast'=>'string'],'status'=>['cast'=>'string']
]]; }
}
// Simple audit buffer
$audit = [];
// Register listeners
Events::dispatcher()->clear();
Events::dispatcher()->listen('dao.beforeInsert', function(array &$p) {
if (($p['table'] ?? '') === 'audit_users') {
// normalize
$p['data']['email'] = strtolower((string)($p['data']['email'] ?? ''));
}
});
Events::dispatcher()->listen('dao.afterInsert', function(array &$p) use (&$audit) {
if (($p['table'] ?? '') === 'audit_users' && isset($p['dto'])) {
$audit[] = '[afterInsert] id=' . ($p['dto']->toArray(false)['id'] ?? '?');
}
});
Events::dispatcher()->listen('dao.afterUpdate', function(array &$p) use (&$audit) {
if (($p['table'] ?? '') === 'audit_users' && isset($p['dto'])) {
$audit[] = '[afterUpdate] id=' . ($p['dto']->toArray(false)['id'] ?? '?');
}
});
$dao = new UserDao($conn);
// Clean for demo
foreach ($dao->findAllBy() as $row) { $dao->deleteById((int)$row->toArray(false)['id']); }
// Perform some ops
$u = $dao->insert(['email' => 'AUDIT@EXAMPLE.COM', 'name' => 'Audit Me']);
$id = (int)($u->toArray(false)['id'] ?? 0);
$dao->update($id, ['name' => 'Audited']);
echo "Audit log:\n" . implode("\n", $audit) . "\n";

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
use Pairity\Orm\UnitOfWork;
// SQLite demo DB (local file)
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => __DIR__ . '/../db.sqlite',
]);
// Ensure table
$conn->execute('CREATE TABLE IF NOT EXISTS uow_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
version INTEGER NOT NULL DEFAULT 0
)');
class UserDto extends AbstractDto {}
class UserDao extends AbstractDao {
public function getTable(): string { return 'uow_users'; }
protected function dtoClass(): string { return UserDto::class; }
protected function schema(): array
{
return [
'primaryKey' => 'id',
'columns' => [
'id' => ['cast' => 'int'],
'name' => ['cast' => 'string'],
'version' => ['cast' => 'int'],
],
// Enable optimistic locking on integer version column
'locking' => ['type' => 'version', 'column' => 'version'],
];
}
}
$dao = new UserDao($conn);
// Clean for demo
foreach ($dao->findAllBy() as $row) {
$dao->deleteById((int)($row->toArray(false)['id'] ?? 0));
}
// Create one
$u = $dao->insert(['name' => 'Alice']);
$id = (int)($u->toArray(false)['id'] ?? 0);
// Demonstrate UoW with snapshot diffing: modify DTO then commit
UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) {
// Enable snapshot diffing (optional)
$uow->enableSnapshots(true);
// Load and modify the DTO directly (no explicit update call)
$user = $dao->findById($id);
if ($user) {
// mutate DTO attributes
$user->setRelation('name', 'Alice (uow)');
}
// Also stage an explicit update to show coalescing
$dao->update($id, ['name' => 'Alice (explicit)']);
});
$after = $dao->findById($id);
echo 'After UoW commit: ' . json_encode($after?->toArray(false)) . "\n";

View file

@ -9,6 +9,11 @@ use Pairity\Contracts\ConnectionInterface;
class PdoConnection implements ConnectionInterface class PdoConnection implements ConnectionInterface
{ {
private PDO $pdo; private PDO $pdo;
/** @var array<string, \PDOStatement> */
private array $stmtCache = [];
private int $stmtCacheSize = 100; // LRU bound
/** @var null|callable */
private $queryLogger = null; // function(string $sql, array $params, float $ms): void
public function __construct(PDO $pdo) public function __construct(PDO $pdo)
{ {
@ -17,18 +22,69 @@ class PdoConnection implements ConnectionInterface
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
} }
/** Enable/disable a bounded prepared statement cache (default size 100). */
public function setStatementCacheSize(int $size): void
{
$this->stmtCacheSize = max(0, $size);
if ($this->stmtCacheSize === 0) {
$this->stmtCache = [];
} else if (count($this->stmtCache) > $this->stmtCacheSize) {
// trim
$this->stmtCache = array_slice($this->stmtCache, -$this->stmtCacheSize, null, true);
}
}
/** Set a logger callable to receive [sql, params, ms] for each query/execute. */
public function setQueryLogger(?callable $logger): void
{
$this->queryLogger = $logger;
}
private function prepare(string $sql): \PDOStatement
{
if ($this->stmtCacheSize <= 0) {
return $this->pdo->prepare($sql);
}
if (isset($this->stmtCache[$sql])) {
// Touch for LRU by moving to end
$stmt = $this->stmtCache[$sql];
unset($this->stmtCache[$sql]);
$this->stmtCache[$sql] = $stmt;
return $stmt;
}
$stmt = $this->pdo->prepare($sql);
$this->stmtCache[$sql] = $stmt;
// Enforce LRU bound
if (count($this->stmtCache) > $this->stmtCacheSize) {
array_shift($this->stmtCache);
}
return $stmt;
}
public function query(string $sql, array $params = []): array public function query(string $sql, array $params = []): array
{ {
$stmt = $this->pdo->prepare($sql); $t0 = microtime(true);
$stmt = $this->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
return $stmt->fetchAll(); $rows = $stmt->fetchAll();
if ($this->queryLogger) {
$ms = (microtime(true) - $t0) * 1000.0;
try { ($this->queryLogger)($sql, $params, $ms); } catch (\Throwable) {}
}
return $rows;
} }
public function execute(string $sql, array $params = []): int public function execute(string $sql, array $params = []): int
{ {
$stmt = $this->pdo->prepare($sql); $t0 = microtime(true);
$stmt = $this->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
return $stmt->rowCount(); $count = $stmt->rowCount();
if ($this->queryLogger) {
$ms = (microtime(true) - $t0) * 1000.0;
try { ($this->queryLogger)($sql, $params, $ms); } catch (\Throwable) {}
}
return $count;
} }
public function transaction(callable $callback): mixed public function transaction(callable $callback): mixed

View file

@ -51,6 +51,11 @@ abstract class AbstractDao implements DaoInterface
* 'join' opts in to join-based eager loading for supported SQL relations (single level). * 'join' opts in to join-based eager loading for supported SQL relations (single level).
*/ */
private ?string $eagerStrategy = null; private ?string $eagerStrategy = null;
/** Memoized schema/relations for perf */
private ?array $schemaCache = null;
private ?array $relationsCache = null;
/** Eager IN batching size for related queries */
protected int $inBatchSize = 1000;
public function __construct(ConnectionInterface $connection) public function __construct(ConnectionInterface $connection)
{ {
@ -94,6 +99,13 @@ abstract class AbstractDao implements DaoInterface
return []; return [];
} }
/** Override point: set eager IN batching size (default 1000). */
public function setInBatchSize(int $size): static
{
$this->inBatchSize = max(1, $size);
return $this;
}
public function getPrimaryKey(): string public function getPrimaryKey(): string
{ {
$schema = $this->getSchema(); $schema = $this->getSchema();
@ -482,7 +494,7 @@ abstract class AbstractDao implements DaoInterface
/** Expose relation metadata for UoW ordering/cascades. */ /** Expose relation metadata for UoW ordering/cascades. */
public function relationMap(): array public function relationMap(): array
{ {
return $this->relations(); return $this->getRelations();
} }
/** /**
@ -530,21 +542,26 @@ abstract class AbstractDao implements DaoInterface
return []; return [];
} }
$values = array_values(array_unique($values, SORT_REGULAR)); $values = array_values(array_unique($values, SORT_REGULAR));
$placeholders = []; $chunks = array_chunk($values, max(1, (int)$this->inBatchSize));
$bindings = []; $selectList = $selectFields && $selectFields !== ['*'] ? implode(', ', $selectFields) : $this->selectList();
foreach ($values as $i => $val) { $dtos = [];
$ph = "in_{$i}"; foreach ($chunks as $chunkIdx => $chunk) {
$placeholders[] = ":{$ph}"; $placeholders = [];
$bindings[$ph] = $val; $bindings = [];
foreach ($chunk as $i => $val) {
$ph = "in_{$chunkIdx}_{$i}";
$placeholders[] = ":{$ph}";
$bindings[$ph] = $val;
}
$where = $column . ' IN (' . implode(', ', $placeholders) . ')';
$where = $this->appendScopedWhere($where);
$sql = 'SELECT ' . $selectList . ' FROM ' . $this->getTable() . ' WHERE ' . $where;
$rows = $this->connection->query($sql, $bindings);
foreach ($rows as $r) {
$dtos[] = $this->hydrate($this->castRowFromStorage($r));
}
} }
$selectList = $selectFields && $selectFields !== ['*'] return $dtos;
? implode(', ', $selectFields)
: $this->selectList();
$where = $column . ' IN (' . implode(', ', $placeholders) . ')';
$where = $this->appendScopedWhere($where);
$sql = 'SELECT ' . $selectList . ' FROM ' . $this->getTable() . ' WHERE ' . $where;
$rows = $this->connection->query($sql, $bindings);
return array_map(fn($r) => $this->hydrate($this->castRowFromStorage($r)), $rows);
} }
/** /**
@ -660,7 +677,7 @@ abstract class AbstractDao implements DaoInterface
protected function attachRelations(array $parents): void protected function attachRelations(array $parents): void
{ {
if (!$parents) return; if (!$parents) return;
$relations = $this->relations(); $relations = $this->getRelations();
foreach ($this->with as $name) { foreach ($this->with as $name) {
if (!isset($relations[$name])) { if (!isset($relations[$name])) {
continue; // silently ignore unknown continue; // silently ignore unknown
@ -904,7 +921,7 @@ abstract class AbstractDao implements DaoInterface
$joins = []; $joins = [];
$meta = [ 'rels' => [] ]; $meta = [ 'rels' => [] ];
$relations = $this->relations(); $relations = $this->getRelations();
$aliasIndex = 1; $aliasIndex = 1;
foreach ($this->with as $name) { foreach ($this->with as $name) {
if (!isset($relations[$name])) continue; if (!isset($relations[$name])) continue;
@ -1041,7 +1058,7 @@ abstract class AbstractDao implements DaoInterface
public function attach(string $relationName, int|string $parentId, array $relatedIds): int public function attach(string $relationName, int|string $parentId, array $relatedIds): int
{ {
if (!$relatedIds) return 0; if (!$relatedIds) return 0;
$cfg = $this->relations()[$relationName] ?? null; $cfg = $this->getRelations()[$relationName] ?? null;
if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') { if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') {
throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation"); throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation");
} }
@ -1071,7 +1088,7 @@ abstract class AbstractDao implements DaoInterface
*/ */
public function detach(string $relationName, int|string $parentId, array $relatedIds = []): int public function detach(string $relationName, int|string $parentId, array $relatedIds = []): int
{ {
$cfg = $this->relations()[$relationName] ?? null; $cfg = $this->getRelations()[$relationName] ?? null;
if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') { if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') {
throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation"); throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation");
} }
@ -1100,7 +1117,7 @@ abstract class AbstractDao implements DaoInterface
*/ */
public function sync(string $relationName, int|string $parentId, array $relatedIds): array public function sync(string $relationName, int|string $parentId, array $relatedIds): array
{ {
$cfg = $this->relations()[$relationName] ?? null; $cfg = $this->getRelations()[$relationName] ?? null;
if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') { if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') {
throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation"); throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation");
} }
@ -1240,7 +1257,21 @@ abstract class AbstractDao implements DaoInterface
protected function getSchema(): array protected function getSchema(): array
{ {
return $this->schema(); if ($this->schemaCache !== null) {
return $this->schemaCache;
}
$this->schemaCache = $this->schema();
return $this->schemaCache;
}
/** Return memoized relations metadata. */
protected function getRelations(): array
{
if ($this->relationsCache !== null) {
return $this->relationsCache;
}
$this->relationsCache = $this->relations();
return $this->relationsCache;
} }
protected function hasSoftDeletes(): bool protected function hasSoftDeletes(): bool

View file

@ -44,6 +44,11 @@ abstract class AbstractMongoDao
/** @var array<string, callable> */ /** @var array<string, callable> */
private array $namedScopes = []; private array $namedScopes = [];
/** Memoized relations */
private ?array $relationsCache = null;
/** Eager IN batching size for related lookups */
protected int $inBatchSize = 1000;
public function __construct(MongoConnectionInterface $connection) public function __construct(MongoConnectionInterface $connection)
{ {
$this->connection = $connection; $this->connection = $connection;
@ -67,6 +72,23 @@ abstract class AbstractMongoDao
return []; return [];
} }
/** Return memoized relations map. */
protected function getRelations(): array
{
if ($this->relationsCache !== null) {
return $this->relationsCache;
}
$this->relationsCache = $this->relations();
return $this->relationsCache;
}
/** Override point: set eager IN batching size (default 1000). */
public function setInBatchSize(int $size): static
{
$this->inBatchSize = max(1, $size);
return $this;
}
// ========= Query modifiers ========= // ========= Query modifiers =========
/** /**
@ -295,11 +317,17 @@ abstract class AbstractMongoDao
if (!$values) return []; if (!$values) return [];
// Normalize values (unique) // Normalize values (unique)
$values = array_values(array_unique($values)); $values = array_values(array_unique($values));
$filter = [ $field => ['$in' => $values] ]; $chunks = array_chunk($values, max(1, (int)$this->inBatchSize));
$this->applyRuntimeScopesToFilter($filter); $dtos = [];
$opts = $this->buildOptions(); foreach ($chunks as $chunk) {
$docs = $this->connection->find($this->databaseName(), $this->collection(), $filter, $opts); $filter = [ $field => ['$in' => $chunk] ];
return array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []); $this->applyRuntimeScopesToFilter($filter);
$opts = $this->buildOptions();
$docs = $this->connection->find($this->databaseName(), $this->collection(), $filter, $opts);
$iter = is_iterable($docs) ? $docs : [];
foreach ($iter as $d) { $dtos[] = $this->hydrate($d); }
}
return $dtos;
} }
// ========= Dynamic helpers ========= // ========= Dynamic helpers =========
@ -523,7 +551,7 @@ abstract class AbstractMongoDao
protected function attachRelations(array $parents): void protected function attachRelations(array $parents): void
{ {
if (!$parents) return; if (!$parents) return;
$relations = $this->relations(); $relations = $this->getRelations();
foreach ($this->with as $name) { foreach ($this->with as $name) {
if (!isset($relations[$name])) continue; if (!isset($relations[$name])) continue;
$cfg = $relations[$name]; $cfg = $relations[$name];

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Schema\SchemaManager;
use Pairity\Schema\Blueprint;
final class PostgresSmokeTest extends TestCase
{
private function pgConfig(): array
{
$host = getenv('POSTGRES_HOST') ?: null;
if (!$host) {
$this->markTestSkipped('POSTGRES_HOST not set; skipping Postgres smoke test');
}
return [
'driver' => 'pgsql',
'host' => $host,
'port' => (int)(getenv('POSTGRES_PORT') ?: 5432),
'database' => getenv('POSTGRES_DB') ?: 'pairity',
'username' => getenv('POSTGRES_USER') ?: 'postgres',
'password' => getenv('POSTGRES_PASS') ?: 'postgres',
];
}
public function testCreateAlterDropCycle(): void
{
$cfg = $this->pgConfig();
$conn = ConnectionManager::make($cfg);
$schema = SchemaManager::forConnection($conn);
$suffix = substr(sha1((string)microtime(true)), 0, 6);
$table = 'pg_smoke_' . $suffix;
// Create
$schema->create($table, function (Blueprint $t) {
$t->increments('id');
$t->string('name', 100);
});
$rows = $conn->query('SELECT tablename FROM pg_tables WHERE tablename = :t', ['t' => $table]);
$this->assertNotEmpty($rows, 'Table should be created');
// Alter add column
$schema->table($table, function (Blueprint $t) {
$t->integer('qty');
});
$cols = $conn->query('SELECT column_name FROM information_schema.columns WHERE table_name = :t', ['t' => $table]);
$names = array_map(fn($r) => $r['column_name'] ?? '', $cols);
$this->assertContains('qty', $names);
// Drop
$schema->drop($table);
$rows = $conn->query('SELECT tablename FROM pg_tables WHERE tablename = :t', ['t' => $table]);
$this->assertEmpty($rows, 'Table should be dropped');
}
}