PHP code example of or81 / eloquent

1. Go to this page and download the library: Download or81/eloquent 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/ */

    

or81 / eloquent example snippets


use Or81\Eloquent\NDB;

NDB::table('users')->get();     // already connected to shop

Dotenv::createImmutable($directory)->safeLoad();

NDB::useEnv(__DIR__ . '/config');

NDB::envConfig();          // the config array, or null when .env says nothing
Or81\Eloquent\Env::loadedFrom();   // which file it came from

NDB::configure(['database' => 'shop_test']);      // same host and credentials, different database

NDB::configure([
    'driver'   => 'mysql',
    'host'     => '127.0.0.1',
    'port'     => 3306,
    'database' => 'shop',
    'username' => 'root',
    'password' => 'secret',
    'charset'  => 'utf8mb4',
]);

// SQLite (the file is created if it does not exist; ':memory:' also works)
NDB::configure([
    'driver'   => 'sqlite',
    'database' => __DIR__ . '/storage/database.sqlite',
]);

NDB::configure([...], 'reporting');    // or leave it to the prefixed .env keys

NDB::connection('reporting')->select('select count(*) from events');
NDB::table('events')->getConnection();

foreach ($students as $student) {
    sleep(60);                                  // the connection times out here

    NDB::table('vadana')->upsert($student, ['national_code']);   // reconnects and goes on
}

NDB::configure(['reconnect' => false]);         // or DB_RECONNECT=false in .env

try {
    NDB::table('vadana')->upsert($rows, ['national_code']);
} catch (QueryException $e) {
    if ($e->isLostConnection()) {
        // the retry did not get through either: the server is down or restarting
    }
}

while ($job = $queue->next()) {          // may block for minutes
    NDB::ping();                         // reconnects if the server dropped us

    NDB::table('jobs')->insert($job);
}

$connection = new \Or81\Eloquent\Connection(['driver' => 'mysql']);
$connection->setPdo($yourPdo);

NDB::setConnection($connection);

NDB::connection();                 // the Connection object
NDB::getPdo();                     // the underlying PDO
NDB::setDefaultConnection('reporting');
NDB::getDefaultConnection();
NDB::purge('reporting');           // drop the connection, keep its config
NDB::forget('reporting');          // drop both, so .env is read again
NDB::reconnect();                  // close it and open a new one now
NDB::ping();                       // is the server still there? reconnects if not

$user = NDB::table('users')->where('active', 1)->first();

$user->name;
$user->id;

class User extends Model
{
    protected array $fillable = ['name', 'email'];
    protected array $casts = ['is_active' => 'bool'];
}

User::where('is_active', 1)->get();
// [User, User, ...]

use Or81\Eloquent\Model;
use Or81\Eloquent\ModelBuilder;
use Or81\Eloquent\Jalali;

class Order extends Model
{
    protected array $fillable = ['customer_id', 'code', 'status', 'total', 'meta', 'issued_at'];

    protected array $casts = [
        'total'     => 'int',
        'meta'      => 'array',
        'is_paid'   => 'bool',
        'issued_at' => 'jalali',
    ];

    protected array $dateStorage = [
        'issued_at' => [Jalali::JALALI, 'Y/m/d'],
    ];

    protected bool $softDeletes = true;

    public function customer(): ModelBuilder
    {
        return $this->belongsTo(Customer::class);
    }

    public function scopePaid(ModelBuilder $query): void
    {
        $query->where('status', 'paid');
    }
}

$order = Order::create([
    'customer_id' => 1,
    'code'        => 'ORD-1001',
    'status'      => 'paid',
    'total'       => '250000',
    'meta'        => ['gift' => true],
    'issued_at'   => '1403/05/26',
]);

$order->total;                       // 250000, an int
$order->meta['gift'];                // true, decoded from JSON
$order->issued_at->format('l j F');  // جمعه 26 مرداد, a Jalali object

Order::paid()->whereJalaliThisMonth('issued_at')->get();

protected array $casts = [
    'total'      => 'int',          // also: integer
    'rate'       => 'float',        // also: double, real
    'price'      => 'decimal:2',    // a string with two places
    'code'       => 'string',
    'is_paid'    => 'bool',         // also: boolean
    'meta'       => 'array',        // JSON in the column, an array in PHP
    'payload'    => 'json',         // the same thing
    'settings'   => 'object',
    'seen_at'    => 'timestamp',    // an int
    'birthday'   => 'date',         // DateTimeImmutable at midnight
    'created_at' => 'datetime',     // DateTimeImmutable
    'issued_at'  => 'jalali',       // Jalali
];

class Order extends Model
{
    protected array $appends = ['summary'];

    public function getSummaryAttribute(): string       // $order->summary
    {
        return $this->code . ' (' . $this->status . ')';
    }

    public function setCodeAttribute($value): void      // $order->code = 'ord-1'
    {
        $this->attributes['code'] = strtoupper((string) $value);
    }
}

public function scopeActive(ModelBuilder $query): void
{
    $query->where('is_active', 1);
}

public function scopeInCity(ModelBuilder $query, string $city): void
{
    $query->where('city', $city);
}

Customer::active()->inCity('تهران')->orderBy('name')->get();

Order::all();
Order::find(1);
Order::findOrFail(1);                  // ModelNotFoundException when missing
Order::findOr(1, fn () => $default);
Order::where('status', 'paid')->first();
Order::where('status', 'paid')->firstOrFail();
Order::where('status', 'paid')->firstOr(fn () => $default);
Order::query()->firstWhere('status', 'paid');
Order::where('code', 'ORD-1')->sole();  // exactly one match, or it is an error
Order::count();
Order::query()->paginate(20, $page);   // 'data' holds models
Order::query()->cursor();              // streams models
Order::query()->chunkById(200, fn (array $orders) => ...);
Order::query()->toBase();              // plain arrays instead

Order::create([...]);
Order::query()->createMany([[...], [...]]);      // one transaction
Order::query()->forceCreate([...]);              // ignores $fillable
Order::query()->firstOrNew(['code' => 'X']);     // not saved
Order::query()->firstOrCreate(['code' => 'X'], ['status' => 'pending']);
Order::query()->updateOrCreate(['code' => 'X'], ['status' => 'paid']);
Order::query()->createOrUpdate(['code' => 'X'], ['status' => 'paid']);

Order::destroy(1);                     // delete by key, without loading first
Order::destroy(1, 2, 3);
Order::destroy([1, 2, 3]);

$order = Order::find(1);

$order->status = 'shipped';
$order->save();                        // writes only what changed

$order->update(['status' => 'shipped']);
$order->delete();
$order->refresh();                     // re-read into this instance
$fresh = $order->fresh();              // re-read into a new one

$order->isDirty();                     // anything changed since it was loaded?
$order->isDirty('status');
$order->getDirty();                    // ['status' => 'shipped']
$order->getOriginal('status');         // what it was when loaded
$order->getAttributes();               // the raw column values

$order->wasChanged();                  // did the last save() write anything?
$order->wasChanged('status');
$order->getChanges();                  // what it wrote

$order->touch();                       // bump updated_at and nothing else
$order->only(['code', 'total']);       // a subset, cast as usual
$order->except(['meta']);
$order->is($other);                    // same row?
$order->isNot($other);
$copy = $order->replicate();           // an unsaved copy, without the key or timestamps

protected bool $softDeletes = true;

$order->delete();          // sets deleted_at
$order->trashed();         // true
Order::find(1);            // null: soft-deleted rows are filtered out
Order::withTrashed()->find(1);
Order::onlyTrashed()->get();
$order->restore();
$order->forceDelete();     // really gone

Order::onlyTrashed()->where('status', 'cancelled')->restore();
Order::withTrashed()->where('total', 0)->forceDelete();

class Customer extends Model
{
    public function orders(): ModelBuilder
    {
        return $this->hasMany(Order::class);            // orders.customer_id
    }
}

class Order extends Model
{
    public function customer(): ModelBuilder
    {
        return $this->belongsTo(Customer::class);       // orders.customer_id
    }
}

$customer->orders()->get();
$customer->orders()->where('status', 'paid')->sum('total');
$order->customer()->first();
$customer->hasOne(Order::class)->first();

$order->toArray();     // casts applied, $hidden removed, $appends added
$order->toJson();      // the same, JSON encoded with readable Unicode
json_encode($order);   // works: the model is JsonSerializable
$order['code'];        // works: the model is ArrayAccess

class BlogPost extends NDB {}          // -> blog_posts
class Person extends NDB {}            // -> people
class Invoice extends NDB {
    protected static string $table = 'legacy_invoices';
}

BlogPost::query()->where('published', 1)->get();

NDB::table('users')->get();
NDB::table('users')->select('id', 'name')->get();
NDB::table('users')->distinct()->select('role')->get();
NDB::table('users', 'u')->select('u.name as author')->get();

NDB::table('users')->selectRaw('count(*) as total, max(votes) as top')->first();

// Correlated sub-select
NDB::table('users')->select('name')->selectSub(
    NDB::table('orders')->selectRaw('count(*)')->whereColumn('orders.user_id', 'users.id'),
    'orders_count'
)->get();

// Sub-query as the FROM clause
NDB::table('x')->fromSub(NDB::table('users')->where('active', 1), 'active_users')->get();

$user = NDB::table('users')->find(3);

$user->name;
$user->email;

foreach (NDB::table('users')->get() as $user) {
    echo $user->name;
}

$user['name'];
foreach ($user as $column => $value) { ... }
count($user);
json_encode($user);

$user->toArray();                  // the plain associative array
$user->toJson();
$user->get('nickname', 'none');    // with a default
$user->has('nickname');            // present, even if null
$user->only(['id', 'name']);
$user->except(['password']);
$user->keys();
$user->values();

NDB::table('users')->toBase();     // [['id' => 1, 'name' => 'Alice'], ...]

NDB::table('users')->first();                    // ?Row
NDB::table('users')->firstOrFail();              // throws RecordNotFoundException
NDB::table('users')->firstOr(fn () => $default);
NDB::table('users')->firstWhere('role', 'admin');
NDB::table('users')->sole();                     // exactly one match, or it is an error
NDB::table('users')->find(3);                    // by primary key
NDB::table('users')->findOrFail(3);
NDB::table('users')->findOr(3, fn () => $default);
NDB::table('users')->where('id', 3)->value('email');
NDB::table('users')->pluck('name');              // ['Alice', 'Bob']
NDB::table('users')->pluck('name', 'id');        // [1 => 'Alice', 2 => 'Bob']
NDB::table('users')->implode('name', ', ');      // 'Alice, Bob'
NDB::table('users')->where('role', 'admin')->exists();
NDB::table('users')->where('role', 'ghost')->doesntExist();

NDB::table('settings')->keyName('setting_key')->find('theme');

->where('votes', '>', 100)
->where('name', 'John')                      // '=' is implied
->where(['name' => 'John', 'active' => 1])   // AND of every pair
->where([['votes', '>', 100], ['name', 'John']])

->orWhere('votes', '>', 50)
->whereNot('role', 'guest')
->orWhereNot('role', 'guest')

->whereIn('id', [1, 2, 3])
->whereNotIn('role', ['guest', 'banned'])
->whereNull('deleted_at')
->whereNotNull('email_verified_at')
->whereBetween('age', [18, 30])
->whereNotBetween('age', [18, 30])
->whereColumn('updated_at', '>', 'created_at')
->whereLike('name', '%jo%')
->whereNotLike('name', '%spam%')
->whereRaw('length(name) > ?', [5])

->whereDate('created_at', '2024-03-05')
->whereYear('created_at', 2024)
->whereMonth('created_at', 3)
->whereDay('created_at', 5)
->whereTime('created_at', '>', '09:00:00')

->whereAny(['name', 'email', 'phone'], 'like', "%{$term}%")   // any column matches
->whereAll(['name', 'email'], 'like', "%{$term}%")            // every column matches
->whereNone(['name', 'email'], 'like', "%{$term}%")           // no column matches

NDB::table('users')
    ->where('role', 'user')
    ->where(function (Builder $query) {
        $query->where('votes', '>', 100)->orWhere('name', 'Bob');
    })
    ->get();
// where "role" = ? and ("votes" > ? or "name" = ?)

->whereIn('id', function (Builder $query) {
    $query->from('orders')->select('user_id')->where('total', '>', 100);
})

->whereExists(function (Builder $query) {
    $query->from('orders')->whereColumn('orders.user_id', 'users.id');
})

->where('votes', '>', function (Builder $query) {
    $query->from('users')->selectRaw('avg(votes)');
})

NDB::table('orders')->whereJalaliDate('created_at', '1403/05/26')->get();
NDB::table('orders')->whereJalaliBetween('created_at', ['1403/01/01', '1403/06/31'])->get();
NDB::table('orders')->whereJalaliThisMonth('created_at')->count();

NDB::table('invoices')
    ->dateStorage('issued_at', Jalali::JALALI)             // 1403/05/26
    ->dateStorage('paid_at', Jalali::JALALI, 'Y-m-d')      // 1403-05-26
    ->dateStorage('logged_at', Jalali::UNIX)
    ->whereJalaliMonth('issued_at', 1403, 5)
    ->get();

class Invoice extends NDB
{
    protected static array $dateStorage = [
        'issued_at' => [Jalali::JALALI, 'Y-m-d'],
        'logged_at' => Jalali::UNIX,
    ];
}

Invoice::query()->whereJalaliThisYear('issued_at')->get();

NDB::configure([...,  'date_storage' => Jalali::JALALI, 'date_format' => 'Y/m/d']);

NDB::table('invoices')->detectDateStorage('issued_at')->whereJalaliToday('issued_at')->get();

->whereJalali('created_at', '1403/05/26')            // that whole day
->whereJalali('created_at', '>=', '1403/05/26')      // also > < <= != <>
->whereJalaliDate('created_at', '1403/05/26')
->whereJalaliBetween('created_at', ['1403/01/01', '1403/06/31'])   // both ends inclusive
->whereJalaliNotBetween('created_at', ['1403/01/01', '1403/06/31'])
->whereJalaliYear('created_at', 1403)
->whereJalaliMonth('created_at', 1403, 5)            // the year is 

NDB::table('orders')
    ->castJalali('created_at', 'l j F Y ساعت H:i')
    ->whereJalaliToday('created_at')
    ->get();
// created_at => 'جمعه 26 مرداد 1403 ساعت 14:30'

use Or81\Eloquent\Jalali;

Jalali::now();
Jalali::today();
Jalali::create(1403, 5, 26);
Jalali::parse('1403/05/26 14:30');       // slashes, dashes, Persian or Arabic digits
Jalali::fromGregorian('2024-08-16');     // string, DateTimeInterface or timestamp
Jalali::fromTimestamp(1723800000);

$date = Jalali::parse('1403/05/26');

$date->year();  $date->month();  $date->day();
$date->dayOfWeek();      // 0 = شنبه
$date->dayOfYear();      // 150
$date->daysInMonth();    // 31
$date->isLeapYear();     // true
$date->monthName();      // مرداد
$date->dayName();        // جمعه

$date->toDateString();               // 1403/05/26
$date->toGregorianDateString();      // 2024-08-16
$date->toGregorian();                // DateTimeImmutable
$date->toTimestamp();
$date->format('l j F Y');            // جمعه 26 مرداد 1403

$date->addDays(10);  $date->subDays(10);
$date->addMonths(2); $date->addYears(1);        // the day is clamped to a shorter month
$date->startOfWeek(); $date->endOfWeek();       // Saturday .. Friday
$date->startOfMonth(); $date->endOfMonth();
$date->startOfYear(); $date->endOfYear();

$date->lessThan($other); $date->greaterThan($other); $date->equalTo($other);

Jalali::toPersianDigits('1403/05/26');   // ۱۴۰۳/۰۵/۲۶
Jalali::toEnglishDigits('۱۴۰۳/۰۵/۲۶');   // 1403/05/26

date_default_timezone_set('Asia/Tehran');

NDB::table('users')
    ->join('posts', 'users.id', '=', 'posts.user_id')
    ->select('users.name', 'posts.title')
    ->get();

NDB::table('users')->join('posts', 'users.id', 'posts.user_id');   // '=' is implied
NDB::table('users')->leftJoin('posts', 'users.id', '=', 'posts.user_id');
NDB::table('users')->rightJoin('posts', 'users.id', '=', 'posts.user_id');
NDB::table('users')->crossJoin('colors');

NDB::table('users')->join('posts', function (JoinClause $join) {
    $join->on('users.id', '=', 'posts.user_id')
         ->where('posts.published', 1);
})->get();

NDB::table('users')->joinSub(
    NDB::table('posts')->select('user_id')->selectRaw('count(*) as total')->groupBy('user_id'),
    'stats',
    'users.id',
    '=',
    'stats.user_id'
)->get();

->groupBy('role')
->groupByRaw('year(created_at)')
->having('total', '>', 2)
->havingBetween('total', [2, 10])
->havingRaw('count(*) > ?', [2])

->orderBy('name')
->orderByDesc('votes')
->orderByRaw('length(name) desc')
->latest()                   // order by created_at desc
->oldest()
->inRandomOrder()
->reorder()                  // drop every order

->limit(10)->offset(20)      // take() / skip() are aliases
->forPage(3, 20)

NDB::table('users')->count();
NDB::table('users')->count('email');       // count of non-null emails
NDB::table('users')->distinct()->count('role');
NDB::table('users')->max('votes');
NDB::table('users')->min('votes');
NDB::table('users')->sum('votes');
NDB::table('users')->avg('votes');

$page = NDB::table('users')->orderBy('id')->paginate(perPage: 20, page: 3);
// ['data' => [...], 'total' => 57, 'per_page' => 20, 'current_page' => 3,
//  'last_page' => 3, 'from' => 41, 'to' => 57]

NDB::table('users')->orderBy('id')->chunk(200, function (array $rows, int $page) {
    // return false to stop early
});

NDB::table('users')->orderBy('id')->each(function (Row $row) {
    // one row at a time, fetched 1000 at a time
});

// Stream without buffering the whole result set
foreach (NDB::table('logs')->cursor() as $row) {
    echo $row->message;
}

// 268,000 rows of one column
NDB::table('vadana')->select('student_code')->get();     // 140 MB
NDB::table('vadana')->pluck('student_code');             //  20 MB

foreach (NDB::table('vadana')->cursor() as $student) {
    // one row in memory, however many the table has
}

$seen = array_flip(
    NDB::table('vadana')
        ->whereNotNull('student_code')
        ->whereBetween('student_code', [$from, $to])
        ->pluck('student_code')
);                                                       // 4 MB

NDB::table('users')->chunkById(200, function (array $rows) {
    foreach ($rows as $row) {
        NDB::table('users')->where('id', $row->id)->update(['migrated' => 1]);
    }
});

NDB::table('users')
    ->when($request['role'] ?? null, fn (Builder $q, $role) => $q->where('role', $role))
    ->unless($

$user = NDB::table('users')->create(['name' => 'John', 'email' => '[email protected]']);

$user->id;          // 4, from the database
$user->role;        // 'user', the column default

// Several at once, in one transaction: if any row fails, none of them land
$users = NDB::table('users')->createMany([
    ['name' => 'Alice', 'email' => '[email protected]'],
    ['name' => 'Bob',   'email' => '[email protected]'],
]);

// Find it or make it
NDB::table('users')->firstOrCreate(
    ['email' => '[email protected]'],     // what to match on
    ['name' => 'John', 'votes' => 0]     // what to add when creating
);

// Update it or make it. Either way the stored row comes back.
NDB::table('users')->updateOrCreate(
    ['email' => '[email protected]'],
    ['votes' => 42]
);

NDB::table('users')->createOrUpdate([...], [...]);   // the same method, other name

NDB::table('users')->insert(['name' => 'John', 'email' => '[email protected]']);

NDB::table('users')->insert([
    ['name' => 'Alice', 'email' => '[email protected]'],
    ['name' => 'Bob',   'email' => '[email protected]'],
]);

$id = NDB::table('users')->insertGetId(['name' => 'Carol']);

// Skip rows that collide with a unique index
NDB::table('users')->insertOrIgnore([['email' => '[email protected]']]);

// Insert, or update the listed columns on collision
NDB::table('users')->upsert(
    [['email' => '[email protected]', 'votes' => 42]],
    ['email'],      // unique columns (

NDB::table('users')->where('id', 1)->update(['name' => 'John Smith']);
NDB::table('users')->where('id', 1)->update(['votes' => NDB::raw('votes + 1')]);

NDB::table('users')->where('id', 1)->increment('votes');
NDB::table('users')->where('id', 1)->increment('votes', 5, ['updated_at' => date('c')]);
NDB::table('users')->where('id', 1)->decrement('votes');

NDB::table('users')->updateOrInsert(
    ['email' => '[email protected]'],
    ['name' => 'John', 'votes' => 0]
);

NDB::table('users')->where('votes', '<', 1)->delete();
NDB::table('users')->delete(1);      // by primary key
NDB::table('users')->truncate();

NDB::transaction(function () {
    NDB::table('users')->where('id', 1)->decrement('balance', 100);
    NDB::table('users')->where('id', 2)->increment('balance', 100);
});

// Retry up to 3 times on failure
NDB::transaction($callback, attempts: 3);

// Manual control
NDB::beginTransaction();
NDB::commit();
NDB::rollBack();

NDB::select('select * from users where votes > ?', [100]);
NDB::selectOne('select * from users where id = ?', [1]);
NDB::insert('insert into users (name) values (?)', ['John']);
NDB::update('update users set votes = ? where id = ?', [10, 1]);
NDB::delete('delete from users where id = ?', [1]);
NDB::statement('alter table users add column age integer');
NDB::unprepared('create table t (id integer)');       // DDL, no bindings

NDB::table('users')->select(NDB::raw('count(*) as total'))->first();

NDB::table('users')->update([
    'nickname' => NDB::raw('coalesce(nullif(trim(nickname), \'\'), ?, nickname)', [$nickname]),
]);
// update "users" set "nickname" = coalesce(nullif(trim(nickname), ''), ?, nickname)
// bindings: [$nickname]

$query = NDB::table('users')->where('votes', '>', 100);

$query->toSql();        // select * from "users" where "votes" > ?
$query->getBindings();  // [100]
$query->toRawSql();     // bindings interpolated — for reading only
$query->dump();         // print and continue
$query->dd();           // print and exit

NDB::enableQueryLog();
NDB::table('users')->get();
NDB::getQueryLog();     // [['query' => ..., 'bindings' => [...], 'time' => 0.42]]

use Or81\Eloquent\QueryException;

try {
    NDB::table('users')->insert(['nope' => 1]);
} catch (QueryException $e) {
    $e->getSql();
    $e->getBindings();
    $e->getPrevious();   // the underlying PDOException
}

try {
    NDB::table('students')->insert($student);
} catch (QueryException $e) {
    if ($e->isUniqueViolation()) {
        // this row is already there
    } elseif ($e->isForeignKeyViolation()) {
        // it points at something that does not exist
    } else {
        throw $e;
    }
}

$e->getSqlState();     // '23000'
$e->getDriverCode();   // 1062 on MySQL, 19 on SQLite
$e->getErrorInfo();    // ['23000', 1062, 'Duplicate entry ...']

function searchOrders(array $filters, int $page = 1): array
{
    return NDB::table('orders')
        ->join('customers', 'orders.customer_id', '=', 'customers.id')
        ->select('orders.code', 'orders.total', 'orders.status', 'customers.name')
        ->when($filters['q'] ?? null, fn (Builder $q, $term) => $q->where(function (Builder $inner) use ($term) {
            $inner->whereLike('customers.name', "%{$term}%")
                  ->orWhereLike('orders.code', "%{$term}%");
        }))
        ->when($filters['status'] ?? null, fn (Builder $q, $status) => $q->whereIn('orders.status', (array) $status))
        ->when($filters['city'] ?? null, fn (Builder $q, $city) => $q->where('customers.city', $city))
        ->when($filters['from'] ?? null, fn (Builder $q, $from) => $q->whereJalali('orders.created_at', '>=', $from))
        ->when($filters['to'] ?? null, fn (Builder $q, $to) => $q->whereJalali('orders.created_at', '<=', $to))
        ->orderByDesc('orders.created_at')
        ->paginate(20, $page);
}

searchOrders([]);                                              // everything, page 1
searchOrders(['status' => 'paid', 'city' => 'تهران']);
searchOrders(['q' => 'مریم']);
searchOrders(['from' => '1403/05/26', 'to' => '1403/06/02', 'status' => ['paid', 'cancelled']]);

$stats = [
    'today' => NDB::table('orders')->whereJalaliToday('created_at')->count(),
    'this_week' => NDB::table('orders')->whereJalaliThisWeek('created_at')->count(),
    'this_month_revenue' => (int) NDB::table('orders')
        ->where('status', 'paid')
        ->whereJalaliThisMonth('created_at')
        ->sum('total'),
    'has_pending' => NDB::table('orders')->where('status', 'pending')->exists(),
];

$daily = NDB::table('orders')
    ->selectRaw('date(created_at) as day, sum(total) as total, count(*) as orders')
    ->where('status', 'paid')
    ->whereJalaliYear('created_at', 1403)
    ->groupByRaw('date(created_at)')
    ->get();

$byMonth = [];

foreach ($daily as $row) {
    $month = Jalali::fromGregorian($row->day)->format('Y/m');

    $byMonth[$month]['total'] = ($byMonth[$month]['total'] ?? 0) + $row->total;
    $byMonth[$month]['orders'] = ($byMonth[$month]['orders'] ?? 0) + $row->orders;
}

// ['1403/05' => ['total' => 1010000, ...], '1403/06' => [...], '1403/07' => [...]]

$top = NDB::table('customers', 'c')
    ->select('c.name')
    ->selectSub(
        NDB::table('orders')
            ->selectRaw('coalesce(sum(total), 0)')
            ->whereColumn('orders.customer_id', 'c.id')
            ->where('status', 'paid'),
        'revenue'
    )
    ->where('c.is_active', 1)
    ->orderByDesc('revenue')
    ->limit(10)
    ->get();

// [['name' => 'زهرا موسوی', 'revenue' => 825000], ...]

$active = NDB::table('customers')
    ->whereExists(function (Builder $query) {
        $query->from('orders')
              ->whereColumn('orders.customer_id', 'customers.id')
              ->where('status', 'paid')
              ->whereJalaliYear('created_at', 1403);
    })
    ->orderBy('id')
    ->pluck('name');

function placeOrder(int $customerId, array $items): int
{
    return NDB::transaction(function () use ($customerId, $items) {
        $total = array_sum(array_map(fn ($item) => $item['quantity'] * $item['price'], $items));

        $orderId = NDB::table('orders')->insertGetId([
            'customer_id' => $customerId,
            'code' => 'ORD-' . str_pad((string) (NDB::table('orders')->max('id') + 1), 4, '0', STR_PAD_LEFT),
            'status' => 'pending',
            'total' => $total,
            'created_at' => Jalali::now()->toGregorianDateTimeString(),
        ]);

        NDB::table('order_items')->insert(array_map(
            fn ($item) => $item + ['order_id' => $orderId],
            $items
        ));

        return $orderId;
    });
}

$orderId = placeOrder(2, [
    ['product' => 'هدفون', 'quantity' => 2, 'price' => 150000],
    ['product' => 'کابل', 'quantity' => 3, 'price' => 20000],
]);

try {
    NDB::transaction(function () {
        NDB::table('orders')->insert([...]);

        throw new RuntimeException('payment gateway refused');
    });
} catch (RuntimeException $e) {
    // nothing was written
}

$invoices = NDB::table('legacy_invoices')
    ->detectDateStorage('issue_date')
    ->whereJalaliBetween('issue_date', ['1403/05/26', '1403/06/02'])
    ->orderBy('issue_date')
    ->pluck('amount', 'number');

// ['F-901' => 500000, 'F-902' => 320000]

NDB::table('legacy_invoices')
    ->dateStorage('issue_date', Jalali::JALALI)
    ->whereJalaliMonth('issue_date', 1403, 6)
    ->pluck('number');

$query = NDB::table('orders')
    ->join('customers', 'orders.customer_id', '=', 'customers.id')
    ->select('orders.code', 'orders.total', 'orders.created_at', 'customers.name')
    ->castJalali('created_at', 'Y/m/d')
    ->where('orders.status', 'paid')
    ->orderBy('orders.id');

$handle = fopen('orders.csv', 'w');

foreach ($query->cursor() as $row) {
    fputcsv($handle, [$row->code, $row->name, $row->created_at, $row->total]);
}

fclose($handle);
// ORD-1001,علی رضایی,1403/05/26,250000

// Cancel everything still pending from before Mehr
NDB::table('orders')
    ->where('status', 'pending')
    ->whereJalali('created_at', '<', '1403/07/01')
    ->update(['status' => 'cancelled', 'archived_at' => Jalali::now()->toGregorianDateTimeString()]);

// Adjust a counter without reading it first
NDB::table('orders')->where('code', 'ORD-1001')->increment('total', 5000);

// Insert a daily rollup, or update it if the day is already there
NDB::table('daily_totals')->upsert(
    [['day' => '1403/05/26', 'total' => 250]],
    ['day'],
    ['total']
);

$blank = fn ($value) => $value === null || trim((string) $value) === '';

NDB::table('vadana')
    ->where('national_code', $studentId)
    ->update([
        'city' => NDB::raw(
            "coalesce(nullif(trim(city), ''), ?, city)",
            [$blank($city) ? null : $city]
        ),
        'code' => NDB::raw(
            "coalesce(nullif(trim(code), ''), ?, code)",
            [$blank($code) ? null : $code]
        ),
        // an empty array and the literal string "null" count as blank too
        'lessons' => NDB::raw(
            "coalesce(nullif(nullif(nullif(trim(lessons), ''), '[]'), 'null'), ?, lessons)",
            [$blank($lessonsJson) ? null : $lessonsJson]
        ),
    ]);

function fillColumn(string $studentId, string $column, ?string $value, array $alsoBlank = []): int
{
    if ($value === null || trim($value) === '') {
        return 0;
    }

    return NDB::table('vadana')
        ->where('national_code', $studentId)
        ->where(function (Builder $query) use ($column, $alsoBlank) {
            $query->whereNull($column)->orWhereRaw("trim({$column}) = ''");

            foreach ($alsoBlank as $blank) {
                $query->orWhereRaw("trim({$column}) = ?", [$blank]);
            }
        })
        ->update([$column => $value]);
}

fillColumn($studentId, 'city', $city);
fillColumn($studentId, 'code', $code);
fillColumn($studentId, 'lessons', $lessonsJson, ['[]', 'null']);

$blank = fn ($value) => $value === null || trim((string) $value) === '';

$student = [
    'name'          => $name,
    'national_code' => $studentId,
    'student_code'  => $studentCode,
    'code'          => $code,
    'city'          => $city,
    'lessons'       => $lessonsJson,
];

NDB::table('vadana')->upsert(
    [$student],
    ['national_code'],          // the unique index that decides "already there"
    [                            // what to do when it is
        'city' => NDB::raw(
            "coalesce(nullif(trim(city), ''), ?, city)",
            [$blank($city) ? null : $city]
        ),
        'code' => NDB::raw(
            "coalesce(nullif(trim(code), ''), ?, code)",
            [$blank($code) ? null : $code]
        ),
        'lessons' => NDB::raw(
            "coalesce(nullif(nullif(nullif(trim(lessons), ''), '[]'), 'null'), ?, lessons)",
            [$blank($lessonsJson) ? null : $lessonsJson]
        ),
    ]
);

try {
    // the upsert above
} catch (QueryException $e) {
    file_put_contents(
        __DIR__ . '/failed_inserts.jsonl',
        json_encode($student + ['error' => $e->getMessage()],
            JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL,
        FILE_APPEND | LOCK_EX
    );
}

use Or81\Eloquent\NDB;

/** What counts as blank for a column. */
function blankChain(string $expression, array $alsoBlank = []): string
{
    $sql = "trim({$expression})";

    foreach (array_merge([''], $alsoBlank) as $blank) {
        $sql = "nullif({$sql}, '" . str_replace("'", "''", $blank) . "')";
    }

    return $sql;
}

/** Keep what is stored, else take what came in, else leave the row alone. */
function fillBlank(string $column, array $alsoBlank = [])
{
    return NDB::raw(sprintf(
        'coalesce(%s, %s, %s)',
        blankChain($column, $alsoBlank),
        blankChain((string) NDB::incoming($column), $alsoBlank),
        $column
    ));
}

function saveStudents(array $students, int $size = 500): int
{
    $written = 0;

    foreach (array_chunk($students, $size) as $batch) {
        $written += NDB::table('vadana')->upsert($batch, ['national_code'], [
            'city'    => fillBlank('city'),
            'code'    => fillBlank('code'),
            'lessons' => fillBlank('lessons', ['[]', 'null']),
        ]);
    }

    return $written;
}

saveStudents([
    ['name' => 'علی رضایی',  'national_code' => '001', 'student_code' => 'S-1', 'code' => '',    'city' => null,     'lessons' => '[]'],
    ['name' => 'مریم احمدی', 'national_code' => '002', 'student_code' => 'S-2', 'code' => 'B-2', 'city' => 'اصفهان', 'lessons' => '["شیمی"]'],
    // ... thousands more
]);

NDB::table('orders')->orderBy('id')->chunk(500, function (array $rows, int $page) {
    foreach ($rows as $row) {
        // ...
    }
});

$row['name'];                    // still fine
foreach ($row as $col => $val)   // still fine
count($row);                     // still fine
json_encode($row);               // still fine
ini
DB_CONNECTION=sqlite
DB_DATABASE=storage/database.sqlite
ini
REPORTING_DB_CONNECTION=mysql
REPORTING_DB_DATABASE=analytics
bash
php tests/run.php
bash
php tests/run.php jalali
bash
php tests/run.php -v
bash
php tests/jalali_calendar_test.php