Modeling
For the actual tutorial process, we will use a blog system as practice.
To have test data first, we start by following the steps below to create the database structure.
Create Entity
We will first create a basic Article table, starting from Entity. Every table must have a corresponding entity.
Run:
php windwalker g entity Article
This will create src/Entity/Article.php
, with content similar to this:
<?php
declare(strict_types=1);
namespace App\Entity;
// ...
#[Table('articles', 'article')]
#[\AllowDynamicProperties]
class Article implements EntityInterface
{
use EntityTrait;
#[EntitySetup]
public static function setup(EntityMetadata $metadata): void
{
//
}
}
The #[Table]
attribute above the class declares the table name.
Currently, this entity has no any properties. We will add them later and then create the migration.
Create Migration
Input:
php windwalker mig:create ArticleInit
This will generate a xxxxxxxxxxxxxx_ArticleInit.php
file under resources/migrations
. The xxxxxxxxxxxxxx
is migration ID, which is formatted by YmdHi
and suffix by a 4 digit serial number.
Open it and input the table column creation information for the article.
<?php
declare(strict_types=1);
namespace App\Migration;
use App\Entity\Article;
use Windwalker\Core\Console\ConsoleApplication;
use Windwalker\Core\Migration\Migration;
use Windwalker\Database\Schema\Schema;
/**
* Migration UP: 2024060909320001_ArticleInit.
*
* @var Migration $mig
* @var ConsoleApplication $app
*/
$mig->up(
static function () use ($mig) {
$mig->createTable(
Article::class,
function (Schema $schema) {
$schema->primary('id');
$schema->integer('category_id');
$schema->varchar('title');
$schema->varchar('image');
$schema->longtext('content');
$schema->datetime('created');
$schema->integer('created_by');
$schema->json('params');
$schema->addIndex('category_id');
$schema->addIndex('created_by');
}
);
}
);
/**
* Migration DOWN.
*/
$mig->down(
static function () use ($mig) {
$mig->dropTable(Article::class);
}
);
We use the App\Entity\Article::class
as the table name and create some common columns for article. The down()
function below sets up the downgrade logic to remove this table.
Next, run the following command to upgrade the migration version and create the table.
php windwalker mig:go -f
If success, will show the following message:
Backing up SQL...
SQL backup to: ...
Migration start...
==================
2024060909320001 ArticleInit UP... Success
We can run the following command to check the migration status:
$ php windwalker mig:status
+--------+---- Migration Status -------------+
| Status | Version | Migration Name |
+--------+------------------+----------------+
| up | 2021061915530001 | AcmeInit |
| up | 2024060909320001 | ArticleInit |
+--------+------------------+----------------+
Create Entity Properties
Since all future database auto-mapping will be handled by Entity, we need to set up the Entity first.
While the table is already created, we can run the following command to generate Entity properties:
php windwalker build:entity Article
NOTE
If you have set up the auto-completion of console, you may type first some letters and press tab to auto-complete entity name.
Result:
Handling: App\Entity\Article
Added columns:
- id (id)
- categoryId (category_id)
- title (title)
- image (image)
- state (state)
- content (content)
- created (created)
- createdBy (created_by)
- params (params)
The Windwalker will generate the corresponding properties based on the table. Let's open the file and take a look, by default, the auto-generated code may be stuck together and not fit the PSR-12
or any common code styles, you can re-format it by your IDE before next action.
<?php
// ...
#[Table('articles', 'article')]
#[\AllowDynamicProperties]
class Article implements EntityInterface
{
use EntityTrait;
#[Column('id'), PK, AutoIncrement]
protected ?int $id = null;
#[Column('category_id')]
protected int $categoryId = 0;
#[Column('title')]
protected string $title = '';
#[Column('image')]
protected string $image = '';
#[Column('state')]
protected int $state = 0;
#[Column('content')]
protected string $content = '';
#[Column('created')]
#[CastNullable(ServerTimeCast::class)]
protected ?Chronos $created = null;
#[Column('created_by')]
protected int $createdBy = 0;
#[Column('params')]
#[Cast(JsonCast::class)]
protected array $params = [];
#[EntitySetup]
public static function setup(EntityMetadata $metadata): void
{
//
}
}
Each column has a corresponding property with default values, and some columns have Cast to help convert types.
Here, we need to pre-set all possible type adjustments. For example, the params column stores JSON. We can set it to automatically convert to an array when retrieved and back to JSON when saved.
#[Column('params')]
#[Cast(JsonCast::class)]
protected array $params = [];
Introduction to Entity Casting
#[Cast]
is used to convert types when fetching data from the database into objects and when saving data from objects back into the database.
The interface is as follows:
Cast([hydrate], [extract = null], [options = 0])
If only the first parameter (hydrate) is provided, it will convert data according to this parameter when fetched from the DB. #[Cast]
can use the following formats:
- Predefined
CastInterface
objects, such asJsonCast
,DateTimeCast
,TimestampCast
, etc. - Direct input types like
int
,string
, etc. - Input filter chains, such as
int|range(min=1, max=5)|length(max=1)
- Any object class name
- It will convert using two modes, defaulting to the object's constructor. The other mode uses hydrator, controlled by the third parameter:
Cast::USE_HYDRATOR
orCast::USE_CONSTRUCTOR
.
The second parameter (extract) is used when fetching data back into the DB. If not provided, it will automatically guess the method needed to convert back. You can also provide custom cast parameters for reverse conversion.
Multiple Cast settings can be stacked, as shown below:
#[Column('state')]
#[Cast('int')]
#[Cast(BasicState::class)]
protected BasicState $state;
When data is put into the entity, the order is from top to bottom. When extracting, the order is from bottom to top, restoring it to the initial value.
CastNullable
By default, #[Cast]
converts all null
value to empty string. If you are using a DB column which is accepts NULL
value, there is also a #[CastNullable]
that can accept NULL
value. This is same as #[Cast('...', options: Cast::NULLABLE)]
Create Getters/Setters
Once all Casts are set up, we can add accessors to the entity. Run the following command.
Same command as before, but with --methods
or -m
added:
Handling: App\Entity\Article
Added methods:
- getCategoryId
- setCategoryId
- getTitle
- setTitle
- getImage
- setImage
- getState
- setState
- getContent
- setContent
- getCreated
- setCreated
- getCreatedBy
- setCreatedBy
- getParams
- setParams
The Entity now has methods to access its properties. After creating properties and methods, they will be stuck together. You can reformat them by your IDE.
Create Seeder
With Entity and its methods, it is more convenient to create seeders to populate test data.
First, input:
php windwalker seed:create article
This will create:
[CREATE] resources/seeders/article-seeder.php
Due to the order of seeders, we need to manually register it in resources/seeders/main.php
. Open and edit:
<?php
return [
__DIR__ . '/acme-seeder.php',
__DIR__ . '/article-seeder.php',
];
Register article-seeder.php
in the list. In the future, as seeders increase, this list will control the execution order.
Next, we can open article-seeder.php
and edit the content to populate fake data.
<?php
declare(strict_types=1);
namespace App\Seeder;
use App\Entity\Article;
use Windwalker\Core\Seed\Seeder;
use Windwalker\Database\DatabaseAdapter;
use Windwalker\ORM\EntityMapper;
use Windwalker\ORM\ORM;
/**
* Article Seeder
*
* @var Seeder $seeder
* @var ORM $orm
* @var DatabaseAdapter $db
*/
$seeder->import(
static function () use ($seeder, $orm, $db) {
$faker = $seeder->faker('en_US');
/** @var EntityMapper<Article> $mapper */
$mapper = $orm->mapper(Article::class);
foreach (range(1, 50) as $i) {
$item = $mapper->createEntity();
$item->setTitle($faker->sentence(2));
$item->setImage($faker->imageUrl(800, 600));
$item->setState(random_int(0, 1));
$item->setContent($faker->paragraph(40));
$item->setState(random_int(0, 1));
$item->setCreatedBy(1); // Currently no User, set to 1
$item->setCategoryId(1); // Currently no User, set to 1
$item->setCreated($faker->dateTimeThisYear());
$item->setImage($faker->imageUrl());
$item->setParams(
[
'show_date' => true,
'show_author' => false,
]
);
$article = $mapper->createOne($item);
$seeder->outCounting();
}
}
);
$seeder->clear(
static function () use ($seeder, $orm, $db) {
$seeder->truncate(Article::class);
}
);
With the help of Entity Methods, we can be reminded of what content needs to be input and can check the input types.
Among them, setCreated()
was automatically generated earlier because the setter can accept multiple types.
public function setCreated(\DateTimeInterface|string|null $created) : static
{
$this->created = Chronos::tryWrap($created);
return $this;
}
So it can convert various contents into Chronos
objects. If needed, you can modify any property or method at any time to perform various type checks or conversions. The ultimate goal is to make database access more convenient and strictly check type issues.
Now, run again:
php windwalker mig:reset -fs
to refresh all migrations/seeders.
You will see:
Backing up SQL...
SQL backup to: ...
Rollback to 0 version...
========================
2024060909320001 ArticleInit DOWN... Success
2021061915530001 AcmeInit DOWN... Success
Migrating to latest version...
==============================
2021061915530001 AcmeInit UP... Success
2024060909320001 ArticleInit UP... Success
Seeding...
==========
Import seeder: Acme Seeder (/acme-seeder.php)
(15) ◒
Import completed...
Import seeder: Article Seeder (/article-seeder.php)
(50) ◑
Import completed...
Completed.