PHP code example of nandan108 / attrecord

1. Go to this page and download the library: Download nandan108/attrecord library. Choose the download type require.

2. Extract the ZIP file and open the index.php.

3. Add this code to the index.php.
    
        
<?php
require_once('vendor/autoload.php');

/* Start to develop here. Best regards https://php-download.com/ */

    

nandan108 / attrecord example snippets


use Nandan108\Attrecord\Record;
use Nandan108\Attrecord\Attribute\{Table, Column, Relation};
use Nandan108\Attrecord\Caster\EnumCaster;
use Nandan108\Attrecord\Enum\{ColumnType, RelationType};

enum OrderStatus: string
{
    case Draft = 'draft';
    case Placed = 'placed';
    case Shipped = 'shipped';
}

#[Table(name: 'orders')]
class Order extends Record
{
    #[Column(ColumnType::BigIntUnsigned, autoIncrement: true)]
    public ?int $id = null;

    #[Column(ColumnType::Enum)]                // ENUM(...) value set derived from OrderStatus' cases
    #[EnumCaster(OrderStatus::class)]
    public OrderStatus $status = OrderStatus::Draft;

    #[Column(ColumnType::Decimal, precision: 10, scale: 2, nullable: true)]
    public ?float $total = null;

    #[Column(ColumnType::DateTime, nullable: true)]
    public ?\DateTimeImmutable $placed_at = null;

    /** @var RecordSet<OrderLine>|null */
    #[Relation(RelationType::OneToMany, class: OrderLine::class, foreignKey: 'order_id')]
    public ?\Nandan108\Attrecord\RecordSet $lines = null;
}

#[Table(name: 'order_lines')]
class OrderLine extends Record
{
    #[Column(ColumnType::BigIntUnsigned, autoIncrement: true)]
    public ?int $id = null;

    #[Column(ColumnType::BigIntUnsigned)]
    public int $order_id = 0;

    #[Column(ColumnType::VarChar, length: 200)]
    public string $sku = '';

    #[Column(ColumnType::IntUnsigned)]
    public int $qty = 1;

    #[Relation(RelationType::ManyToOne, class: Order::class, foreignKey: 'order_id')]
    public ?Order $order = null;
}

use Nandan108\Attrecord\Connection;
use Nandan108\Attrecord\Dialect\MysqlDialect;
use Nandan108\Attrecord\Session\PdoDbSession;

$pdo  = new PDO('mysql:host=127.0.0.1;dbname=shop;charset=utf8mb4', 'user', 'pass');
$conn = new Connection(new PdoDbSession($pdo), new MysqlDialect());

Record::setConnection($conn);

Record::setConnection($tenantConn, forClass: Order::class);

Record::setTablePrefix('wp_');   // Order → `wp_orders`, OrderLine → `wp_order_lines`

// INSERT — classic style
$order = new Order();
$order->status = 'pending';
$order->total  = 99.95;
$order->save();               // INSERT INTO `orders` …
echo $order->id;              // auto-assigned PK

// INSERT — fluent factory style
$order = Order::newWith(['status' => 'pending', 'total' => 99.95])->save();
echo $order->id;              // auto-assigned PK

// Bulk-assign on an existing instance
$order->set(['status' => 'confirmed', 'total' => 149.00])->save();

// set() calls validate() by default — pass false to defer validation
// (useful for test fixtures or staged construction across multiple set() calls).
// save() / upsertAll() will still validate at the boundary.
$order->set(['status' => 'confirmed'], validate: false);

// save() always returns $this — check $_saved if you need to know whether a write occurred
$order->save();
$order->_saved;   // true  = INSERT or UPDATE was issued
                  // false = record was clean, nothing sent to DB
                  // null  = save() not yet called on this instance

// SELECT by PK
$order = Order::getOne(42);          // ?Order
$order = Order::getOneOrFail(42);    // Order  (throws RecordNotFoundException if missing)
$order = Order::getOneOrNew(42);     // Order (new, unsaved instance if missing)

// UPDATE — only dirty columns
$order->status = 'confirmed';
$order->save();   // UPDATE `orders` SET `status` = ? WHERE `id` = ?

// DELETE
$order->delete();

// Reload from DB (e.g. after an external update)
$order->reload();

// All records (no WHERE)
$all = Order::find();

// With WHERE clause — positional params
$pending = Order::find('`status` = ?', ['pending']);

// With WHERE clause — named params
$recent  = Order::find('`placed_at` > :since', ['since' => '2024-01-01']);

// ORDER BY / LIMIT
$top10 = Order::find('`total` > ?', [100], 'ORDER BY `total` DESC LIMIT 10');

// First match or null
$draft = Order::findOne('`status` = ?', ['draft']);

// findOne accepts ORDER BY and FOR UPDATE too
$latestPending = Order::findOne(
    '`status` = ?',
    ['pending'],
    orderByLimit: 'ORDER BY `placed_at` DESC',
    forUpdate:    true,    // inside transactional() only
);

// Count
$count = Order::countWhere('`status` = ?', ['pending']);

// Bulk update — column → value map; values are typed via the column's serializer
$updated = Order::updateWhere(
    ['status' => 'archived'],
    '`status` = ? AND `placed_at` < ?',
    ['draft', '2024-01-01'],
);

// Bulk delete
$deleted = Order::deleteWhere('`status` = ? AND `total` IS NULL', ['draft']);

use Nandan108\Attrecord\RawSql;

// Increment a counter — no params needed
Order::updateWhere(
    ['view_count' => new RawSql('`view_count` + 1')],
    '`id` = ?', [$id],
);

// Conditional bulk write
Order::updateWhere(
    ['priority' => new RawSql('CASE WHEN `total` > 500 THEN 1 ELSE 0 END')],
    '`status` = ?', ['pending'],
);

// Parameterised raw expression — RawSql params come BEFORE the WHERE params
Order::updateWhere(
    ['priority' => new RawSql('GREATEST(?, `priority`)', [5])],
    '`status` = ?', ['pending'],
);

$jsonHas = new RawSql('JSON_CONTAINS(`tags`, ?)', ['"featured"']);

Order::find(WhereClause::whereRaw($jsonHas));
// ...
Order::updateWhere(
    ['featured_at' => new RawSql('NOW()')],
    WhereClause::whereRaw($jsonHas),
);

// Single-column equality
$pending = Order::where('status', 'pending');

// Comparison operator
$large = Order::where('total', 100, '>');

// NULL check  (null value → IS NULL / IS NOT NULL)
$unplaced = Order::where('placed_at', null);

// IN list
$active = Order::whereIn('status', ['pending', 'confirmed']);

use Nandan108\Attrecord\WhereClause as WC;

$clause = WC::where('status', 'pending')
    ->andWhere(
        WC::where('total', 100, '>')
            ->orWhere(WC::where('flagged', true))
    );

$orders = Order::find($clause);

use Nandan108\Attrecord\Attribute\{Column, UniqueKey, Index};

#[Table(name: 'inventory_items')]
class InventoryItem extends Record
{
    #[Column(ColumnType::BigIntUnsigned, autoIncrement: true)]
    public ?int $id = null;

    // Single-column unique key
    #[Column(ColumnType::VarChar, length: 64)]
    #[UniqueKey('sku')]
    public string $sku = '';

    // Compound unique key: (location_id, bin) — same name on both columns,
    // composite ordering follows property declaration order
    #[Column(ColumnType::BigIntUnsigned)]
    #[UniqueKey('loc_bin')]
    public int $location_id = 0;

    #[Column(ColumnType::VarChar, length: 32)]
    #[UniqueKey('loc_bin')]
    public string $bin = '';

    // Single-column secondary index
    #[Column(ColumnType::IntUnsigned)]
    #[Index('idx_qty')]
    public int $qty = 0;
}

#[Table(name: 'inventory_items')]
#[UniqueKey('uk_loc_bin',   columns: ['location_id', 'bin'])]
#[Index    ('idx_loc_qty',  columns: ['location_id', 'qty'])]
class InventoryItem extends Record { /* ... */ }

$item = new InventoryItem();
$item->sku = 'WIDGET-1';
$item->location_id = 1;
$item->bin = 'A-01';
$item->qty = 10;

// Insert if new; on SKU conflict, only overwrite qty
$item->upsertByUniqueKey('sku', updateColumns: ['qty']);

// Re-registering the same SKU never advances the auto-increment counter
$item->upsertByUniqueKey('sku', updateColumns: ['qty'], preserveAutoIncrement: true);

$plugin = PluginPolicy::newWith(['plugin_slug' => $slug, 'plugin_name' => $name]);
$pName  = PluginPolicy::upsertCol('plugin_name');   // ->name (raw) / ->incoming / ->stored

$plugin->upsertByUniqueKey('uk_slug', [
    // keep the stored display name unless a non-empty new one arrives
    ...$pName->setRaw(
        "CASE WHEN {$pName->incoming} <> ? THEN {$pName->incoming} ELSE {$pName->stored} END",
        [''],   // the '' comparison, bound not inlined
    ),
    'last_seen_at' => new RawSql('CURRENT_TIMESTAMP(6)'),   // refresh to the DB clock
    'policy',                                               // plain entry — overwrite with the incoming value
]);

$item = new InventoryItem();
$item->sku = 'WIDGET-1';   // matches the 'sku' unique key
$item->qty = 25;

// UPDATE inventory_items SET qty = 25 WHERE sku = 'WIDGET-1'
$affected = $item->updateByUniqueKey();

// Restrict / allow nulls explicitly
$item->updateByUniqueKey(fields: ['qty', 'notes']);

use Nandan108\Attrecord\Dialect\MysqlDialect;
use Nandan108\Attrecord\Schema\TableSchema;

$sql = (new MysqlDialect())->buildCreateTable(
    TableSchema::fromClass(Order::class),
);
// CREATE TABLE `orders` (
//   `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
//   `customer_id` BIGINT UNSIGNED NOT NULL,
//   `status` VARCHAR(20) NOT NULL DEFAULT 'pending',
//   `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
//   `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
//                ON UPDATE CURRENT_TIMESTAMP,
//   PRIMARY KEY (`id`),
//   UNIQUE KEY `uk_external` (`external_ref`),
//   KEY `idx_status_date` (`status`, `created_at`),
//   CONSTRAINT `fk_orders_customer_id` FOREIGN KEY (`customer_id`)
//     REFERENCES `customers` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

use Nandan108\Attrecord\Dialect\PgsqlDialect;

$sql = (new PgsqlDialect())->buildCreateTable(TableSchema::fromClass(Order::class));
// CREATE TABLE "orders" (
//   "id" BIGSERIAL,
//   "customer_id" BIGINT NOT NULL,
//   "status" VARCHAR(20) NOT NULL DEFAULT 'pending',
//   ...
//   PRIMARY KEY ("id"),
//   CONSTRAINT "uk_external" UNIQUE ("external_ref"),
//   CONSTRAINT "fk_orders_customer_id" FOREIGN KEY ("customer_id")
//     REFERENCES "customers" ("id") ON DELETE CASCADE ON UPDATE RESTRICT
// );
// CREATE INDEX "idx_status_date" ON "orders" ("status", "created_at")

use Nandan108\Attrecord\Dialect\SqliteDialect;

$sql = (new SqliteDialect())->buildCreateTable(TableSchema::fromClass(Order::class));
// CREATE TABLE "orders" (
//   "id" INTEGER PRIMARY KEY AUTOINCREMENT,
//   "customer_id" INTEGER NOT NULL,
//   "status" TEXT NOT NULL DEFAULT 'pending',
//   ...
//   CONSTRAINT "uk_external" UNIQUE ("external_ref"),
//   CONSTRAINT "fk_orders_customer_id" FOREIGN KEY ("customer_id")
//     REFERENCES "customers" ("id") ON DELETE CASCADE ON UPDATE RESTRICT
// );
// CREATE INDEX "idx_status_date" ON "orders" ("status", "created_at")

$sql = $dialect->buildCreateTable($schema, omitForeignKeys: ['fk_b_a_id']);
// … create both tables, then:
$sql = "ALTER TABLE `b` ADD ".$dialect->buildForeignKeyLine($fk);

$schema = TableSchema::fromClass(SlotSpace::class)->extendedWith(
    columns: ['dim_loc' => new ColumnDefinition(name: 'dim_loc', propertyName: 'dim_loc', /* … */)],
    indexes: ['idx_active_loc' => ['active', 'dim_loc', 'id']],
);

#[Column(
    type:        ColumnType::DateTime,
    default:     null,                    // literal default (int|float|string|bool|BackedEnum|null)
    defaultExpr: 'CURRENT_TIMESTAMP',     // raw SQL default expression (mutually exclusive with default)
    onUpdate:    'CURRENT_TIMESTAMP',     // raw SQL ON UPDATE clause
    comment:     'When the order was placed',
    enumValues:  null,                    // list<string> — 

#[Column(ColumnType::Enum, enumValues: ['draft', 'active'], default: Status::Active)]
public string $status = 'active';

use Nandan108\Attrecord\Attribute\{Table, MysqlTableOptions};

#[Table(name: 'orders', primaryKey: 'id', comment: 'Customer orders')]
#[MysqlTableOptions(engine: 'Memory')]   // override engine only; charset/collation stay default
final class Order extends Record { /* ... */ }

#[Relation(
    type:       RelationType::ManyToOne,
    class:      Customer::class,
    foreignKey: 'customer_id',
    onDelete:   ForeignKeyAction::Cascade,    // default: Restrict
    onUpdate:   ForeignKeyAction::Restrict,   // default: Restrict
    emitFk:     true,                          // opt-out per-relation
)]

use Nandan108\Attrecord\Attribute\{Table, Column, ForeignKey};
use Nandan108\Attrecord\Enum\ForeignKeyAction;

#[Table(name: 'inventory_ledger')]
#[ForeignKey(column: 'subject_id', references: Subject::class)]                       // → `subjects`(`id`), derived
#[ForeignKey(column: 'from_slot_id', references: 'slotspace', referencesColumn: 'id', // → raw table, no Record
    onDelete: ForeignKeyAction::SetNull)]
final class InventoryLedger extends Record
{
    #[Column(ColumnType::BigIntUnsigned)]
    public int $subject_id = 0;

    #[Column(ColumnType::BigIntUnsigned, nullable: true)]
    public ?int $from_slot_id = null;
    // ...
}

use Nandan108\Attrecord\Attribute\Column;
use Nandan108\Attrecord\Enum\ColumnType;
use Nandan108\Attrecord\Enum\GeneratedColumnMode;

#[Column(
    type:           ColumnType::IntUnsigned,
    generatedAs:    'IFNULL(scope_actor_id, 0)',
    generatedMode:  GeneratedColumnMode::Stored,   // or Virtual; defaults to Stored
)]
public int $scope_actor_key = 0;

$proto = new InventoryItem();
$proto->qty = 0;

// Zero out qty for every item at a given location
$proto->updateByWhere('`location_id` = ?', [$locationId]);

$order = Order::hydrateFromArray(['id' => 1, 'status' => 'draft', 'total' => null, 'placed_at' => null]);

$order->isDirty();              // false — just loaded
$order->status = 'confirmed';
$order->isDirty();              // true
$order->isDirty('status');      // true
$order->isDirty('total');       // false

$order->dirtyFields();
// ['status' => ['draft', 'confirmed']]  (snapshot → current)

// array ⇄ JSON — auto-attached on a Json column typed as array
#[Column(ColumnType::Json, nullable: true)]
public ?array $meta = null;

// value object ⇄ JSON — auto-attached when the type implements JsonCastable
#[Column(ColumnType::Json, nullable: true)]
public ?Money $price = null;

// explicit, parameterized caster
#[Column(ColumnType::Json, nullable: true)]
#[JsonCaster(excludeNullFields: ['note'])]
public ?array $audit = null;

// reshape a native type — e.g. store a timestamp as a unix int
#[Column(ColumnType::BigIntUnsigned, nullable: true)]
#[EpochCaster]
public ?\DateTimeImmutable $logged_at = null;

// flag-set ⇄ integer bitmask — portable across all three backends
#[Column(ColumnType::BigIntUnsigned, default: 0)]
#[BitmaskCaster(StockConcern::class)]   // int-backed, power-of-two enum
public array $concerns = [];            // e.g. [StockConcern::Deficit, StockConcern::NoCost]

use Nandan108\Attrecord\Exception\RecordValidationException;

class Order extends Record
{
    // ... columns ...

    public function validate(): void
    {
        if ($this->total !== null && $this->total < 0) {
            throw new RecordValidationException(
                'Order total cannot be negative.',
                context: ['total' => $this->total],
            );
        }
        if ($this->status === 'shipped' && $this->placed_at === null) {
            throw new RecordValidationException('Shipped order must have a placed_at.');
        }
    }
}

$orders = Order::find('`status` = ?', ['pending']);

count($orders);             // Countable
foreach ($orders as $o) {} // Iterator

$orders->first();           // ?Order
$orders->last();            // ?Order

// Extract one field, keyed by the PK
$idToTotal = $orders->pluck('total');           // [pk => total]

// Extract multiple fields, keyed by the PK
$details = $orders->pluck(['status', 'total']); // [pk => ['status' => …, 'total' => …]]

// Group + extract — extra args are the grouping key(s); leaves are field values
$byStatusTotals = $orders->pluck('total', 'status');
// ['pending' => [10.0, 25.5, …], 'confirmed' => [99.95, …]]

$byStatusDetails = $orders->pluck(['id', 'total'], 'status');
// ['pending' => [['id' => 1, 'total' => 10.0], …], …]

// Index by a unique column
$byId = $orders->recordsByKey('id');  // array<int|string, Order>

// Group by a single column
$byStatus = $orders->recordsGroupedByKey('status');  // array<string, RecordSet<Order>>

// Nested group by multiple columns — leaves are RecordSets, not plain arrays
$byStatusByYear = $orders->recordsGroupedByKeys('status', 'year');
// ['pending' => [2024 => RecordSet<Order>, 2025 => RecordSet<Order>], …]

// Convert to raw arrays (column → scalar) — useful for serialisation
$rows = $orders->toArraySet();   // list<array<string, scalar|null>>

// Stamp a shared field on every record before saving (e.g. updated_at, actor_id)
$set = new RecordSet([$line1, $line2, $line3]);
$set->bulkSet(['updated_by' => $userId]);

// Batch upsert — deadlock-safe 3-step strategy for all dirty records, one atomic transaction
$result = $set->upsertAll();   // ?SaveResult — null when nothing to save

$result->inserted;      // rows newly written
$result->updated;       // rows overwritten
$result->total();       // inserted + updated
$result->insertedIds;   // list<int|string> — PKs of newly inserted auto-increment records

// Force-save (skip dirty filter) — useful in tests and for re-asserting state
$set->upsertAll(force: true);

// Bulk delete
$deleted = $set->deleteAll();  // DELETE FROM … WHERE id IN (…)

// 50k rows written 1000 at a time; each 1000-row chunk commits before the next begins
$set->upsertAll(chunkSize: 1000);

// one statement, no row locks — a PK-keyed coalescing outbox/queue write
(new RecordSet($rows))->upsertAll(strategy: UpsertStrategy::Lockless);

// append 3 ledger rows with client-minted UUIDv7 PKs — one INSERT, no upsert, no locks
$result = (new RecordSet([$e1, $e2, $e3]))->insertAll();

// seed recommended defaults only where no row exists; a prior row is left untouched
(new RecordSet($rows))->insertAll(onConflict: OnConflict::Ignore);

$row->save(onConflict: OnConflict::Ignore);   // single-row sibling

// $rows are PK-less Records whose conflict-key column(s) are set
$result = (new RecordSet($rows))->upsertAllByUniqueKey('uniq_owner_code');

$order->save(readBack: true);              // reload the whole row (fires afterLoad())
$order->save(readBack: ['total', 'tax']);  // patch just these columns
$order->save(readBack: false);             // never
$order->save();                            // readBack: null (default) = auto

// One extra query per level
$orders = Order::find('`status` = ?', ['pending'])
    ->load('lines');          // SELECT … WHERE order_id IN (…)

foreach ($orders as $order) {
    foreach ($order->lines as $line) {
        echo $line->sku;
    }
}

// Dot-notation chains
$orders->load('lines.product');  // loads lines, then products for those lines

// Skip records that already have the relation loaded
$orders->loadMissing('lines.product');

// The same API is on a single record
$order->load('lines', 'customer.billing');

// Parent side — Order has many Tags
#[Table(name: 'orders')]
class Order extends Record
{
    // …

    /** @var RecordSet<Tag>|null */
    #[Relation(RelationType::MorphMany, class: Tag::class,
        morphType: 'tagable_type', morphKey: 'tagable_id',
        morphValue: 'order')]
    public ?RecordSet $tags = null;

    // MorphOne: same as MorphMany but returns a single record or null
    #[Relation(RelationType::MorphOne, class: Tag::class,
        morphType: 'tagable_type', morphKey: 'tagable_id',
        morphValue: 'order')]
    public ?Tag $primaryTag = null;
}

// Child side — Tag belongs to a polymorphic parent
#[Table(name: 'tags')]
class Tag extends Record
{
    #[Column(ColumnType::VarChar, length: 50)]
    public string $tagable_type = '';

    #[Column(ColumnType::BigIntUnsigned)]
    public int $tagable_id = 0;

    #[Relation(RelationType::MorphTo,
        morphType: 'tagable_type',
        morphKey: 'tagable_id',
        morphMap: ['order' => Order::class, 'product' => Product::class])]
    public Order|Product|null $tagable = null;
}

// Load orders with all their tags — one extra query
$orders = Order::find('`status` = ?', ['pending'])->load('tags');

foreach ($orders as $order) {
    foreach ($order->tags as $tag) {
        echo $tag->name;
    }
}

// Load tags with their polymorphic parent — one query per distinct type present
$tags = Tag::find()->load('tagable');

foreach ($tags as $tag) {
    // $tag->tagable is an Order or Product depending on tagable_type
}

// Chains work too: orders → tags → tagable (round-trip)
$orders->load('tags.tagable');

Order::transactional(function (Transaction $tx): void {
    $order = Order::getOne(42, forUpdate: true, tx: $tx);
    $order->status = 'shipped';
    $order->save();

    $line = new OrderLine();
    $line->order_id = $order->id;
    $line->sku = 'WIDGET-1';
    $line->save();
});
// Automatically committed; rolled back on exception

// bind a specific session for the duration of the closure; restored afterward (even on throw)
Outbox::usingSession($engineSession, static fn () =>
    (new RecordSet($rows))->upsertAll(strategy: UpsertStrategy::Lockless),
);

// or bind a full Connection (session + dialect)
Order::usingConnection($conn, static fn () => $order->save());

#[Column(ColumnType::IntUnsigned)]
#[Version]
public int $version = 1;

// two processes both loaded this row at version 4…
$a->qty = 10; $a->save();     // → version 5, OK
$b->qty = 20; $b->save();     // → OptimisticLockException (guard expected 4, row is now 5)

use Nandan108\Attrecord\Attribute\LockTier;

#[Table(name: 'orders')]
#[LockTier(1)]
class Order extends Record { ... }

#[Table(name: 'order_lines')]
#[LockTier(2)]
class OrderLine extends Record { ... }

Order::transactional(function (Transaction $tx): void {
    $order = Order::getOne(1, forUpdate: true, tx: $tx);      // tier 1 ✓
    $line  = OrderLine::getOne(5, forUpdate: true, tx: $tx);  // tier 2 ✓
    // OrderLine::getOne after Order::getOne is safe (2 > 1)

    // Reversed order would throw LockTierConflictException
});

use Nandan108\Attrecord\LockSet;

PurchaseOrder::transactional(function (Transaction $tx) use ($poId, $lineIds, $slotId): void {
    $locks = LockSet::acquire(PurchaseOrder::connection(), [
        PurchaseOrder::class     => [$poId],
        PurchaseOrderLine::class => $lineIds,
        InventorySlot::class     => [$slotId],
    ], $tx);

    // $locks[PurchaseOrder::class]     is RecordSet<PurchaseOrder>
    // $locks[PurchaseOrderLine::class] is RecordSet<PurchaseOrderLine>
    foreach ($locks[PurchaseOrderLine::class] as $line) {
        // … process under lock
    }
});

$conn = Record::connection();

$conn->session->withAdvisoryLock(
    lockName:       'invflux.reconcile.shipment-42',
    timeoutSeconds: 5,          // 0 = fail immediately, -1 = wait indefinitely
    callback:       function () {
        // ... serialise this critical section across all PHP workers
    },
);

use Nandan108\Attrecord\Session\PdoDbSession;
use Nandan108\Attrecord\Dialect\{MysqlDialect, PgsqlDialect, SqliteDialect};

// MySQL / MariaDB
$pdo  = new PDO('mysql:host=127.0.0.1;dbname=shop', 'user', 'pass');
$conn = new Connection(new PdoDbSession($pdo), new MysqlDialect());

// PostgreSQL — same adapter, paired with PgsqlDialect
$pdo  = new PDO('pgsql:host=127.0.0.1;dbname=shop', 'user', 'pass');
$conn = new Connection(new PdoDbSession($pdo), new PgsqlDialect());

// SQLite — same adapter, paired with SqliteDialect (file-based or :memory:)
$pdo  = new PDO('sqlite:/var/data/shop.sqlite');
$conn = new Connection(new PdoDbSession($pdo), new SqliteDialect());

new SqliteDialect(
    journalMode:   'WAL',   // PRAGMA journal_mode (default 'WAL'; pass null to leave the default)
    busyTimeoutMs: 5000,    // PRAGMA busy_timeout in ms (default 5000; null to skip)
    foreignKeys:   true,    // PRAGMA foreign_keys=ON (default true — SQLite defaults it OFF)
);

use Nandan108\Attrecord\Session\MysqliDbSession;

$mysqli = new mysqli('127.0.0.1', 'user', 'pass', 'shop');
$conn   = new Connection(new MysqliDbSession($mysqli), new MysqlDialect());

use Nandan108\Attrecord\Session\WpDbSession;

global $wpdb;
$conn = new Connection(new WpDbSession($wpdb), new MysqlDialect());
Record::setConnection($conn);

use Nandan108\Attrecord\Session\{RetryingDbSession, PdoDbSession};
use Nandan108\Attrecord\Dialect\PgsqlDialect;

$conn = new Connection(new RetryingDbSession(new PdoDbSession($pdo)), new PgsqlDialect());

public function __construct(
    DbSession $inner,             // the session to wrap
    int $maxAttempts = 10,        // total attempts, including the first
    int $baseDelayUs = 5_000,     // base backoff in µs, doubled each attempt
    int $maxDelayUs  = 100_000,   // per-attempt backoff cap in µs
    ?\Closure $retryable = null,  // (\Throwable): bool — overrides the default classification
)

use Nandan108\Attrecord\Test\CapturingDbSession;
use Nandan108\Attrecord\Connection;
use Nandan108\Attrecord\Dialect\MysqlDialect;

$session = new CapturingDbSession();
Record::setConnection(new Connection($session, new MysqlDialect()));

$order = new Order();
$order->status = 'pending';
$order->save();

assertStringContainsString('INSERT INTO `orders`', $session->lastSql());
assertSame(['pending'], $session->lastParams());

// Control the returned PK
$session->setNextInsertId(100);
$order2 = new Order();
$order2->status = 'draft';
$order2->save();
assertSame(100, $order2->id);

// Full call log
$session->allCalls();  // list<array{sql: string, params: list<scalar|null>}>
$session->reset();     // clear log

#[Table(name: 'orders', primaryKey: 'order_id')]
final class Order extends Record
{
    #[Column(ColumnType::BigIntUnsigned, name: 'order_id', autoIncrement: true)]
    public ?int $orderId = null;

    #[Column(ColumnType::BigIntUnsigned, name: 'customer_id')]
    public int $customerId = 0;

    // No `name:` override — column name equals property name
    #[Column(ColumnType::VarChar, length: 20)]
    public string $status = 'pending';
}

#[Column(
    type:          ColumnType::VarChar,
    name:          'col_name',  // SQL column name override (defaults to PHP property name)
    nullable:      true,        // allows NULL; PHP property becomes ?string
    autoIncrement: true,        // skipped in INSERT/UPDATE; PK assigned after INSERT
    trimOnSave:    true,        // trim whitespace on save; also suppresses dirty-detection for whitespace-only changes
    length:        255,         // for VarChar/Char/Binary/VarBinary; also enforced at DDL generation time
    precision:     10,          // Decimal: total digits (

use Nandan108\Attrecord\BinaryParam;
use Nandan108\Attrecord\WhereClause;

Subject::find(WhereClause::where('uuid', new BinaryParam($rawBytes)));

// Standard relation
#[Relation(
    type:       RelationType::OneToMany,
    class:      OrderLine::class,   // target Record subclass
    foreignKey: 'order_id',         // FK column name
    localKey:   'id',               // optional; defaults to this table's PK
)]

// Polymorphic parent
#[Relation(
    type:       RelationType::MorphMany,
    class:      Tag::class,
    morphType:  'tagable_type',     // type-discriminator column on the related table
    morphKey:   'tagable_id',       // FK column on the related table
    morphValue: 'order',            // value stored in morphType for this class (string or int)
)]

// Polymorphic child
#[Relation(
    type:      RelationType::MorphTo,
    morphType: 'tagable_type',      // local type-discriminator column
    morphKey:  'tagable_id',        // local FK column
    morphMap:  ['order' => Order::class, 'product' => Product::class],
)]

// Many-to-many through a pivot table (junction of two FK columns)
#[Relation(
    type:            RelationType::ManyToMany,
    class:           Tag::class,
    pivotTable:      'post_tag',     // junction table
    pivotLocalKey:   'post_id',      // pivot column → this record's PK
    pivotForeignKey: 'tag_id',       // pivot column → the target's PK
)]

// Has-many-through an intermediate Record (reach the far records, skip the middle)
#[Relation(
    type:       RelationType::HasManyThrough,
    class:      Comment::class,      // far records
    through:    Post::class,         // intermediate Record
    foreignKey: 'user_id',           // intermediate column → this record's PK
    secondKey:  'post_id',           // far column → the intermediate's PK
)]