2019年9月21日 星期六

Symfony4.3 基本應用【第二篇】Doctrine ORM



你更傾向影片教學?查看教學影片

Symfony並未提供元件來與資料庫互動,但它確實與第三方套件Doctrine有緊密的結合。

這篇文章主要關於使用Doctrine ORM,如果你比較喜歡使用資料庫的query指令,建議看"如何使用Doctrine DBAL"。

若你堅持使用Doctrine ODM套件將資料放在MongoDB,請參考文件"DoctrineMongoDBBundle"。


安裝 Doctrine

首先,透過ORMpack以及MakeBundle安裝Doctrine,以利於產生一些代碼:
composer require symfony/orm-pack
composer require --dev symfony/maker-bundle


設定資料庫

資料庫的連線資料會當作環境變數儲存,稱為 DATABASE_URL ,開發時,你可以找到檔案 .env 並自訂。
# .env (or override DATABASE_URL in .env.local to avoid committing your changes)

# customize this line!
DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name"

# to use sqlite:
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/app.db"
如果username、password、host或是database name含有被URI視為特殊的字元(像是+, @, $, #, /, :, *, !),你必須對它進行編碼。請參考RFC 3986所列出的保留字元或urlencode函式來對它們編碼,在這種情況下,你需要移除檔案config/packages/doctrine.yaml裡的前綴resolve:,以避免錯誤url: '%env(resolve:DATABASE_URL)%'

現在你的連線參數已經設定好,Doctrine可以為你建立 db_name 資料庫。
php bin/console doctrine:database:create
config/packages/doctrine.yaml有更多選項可以設定,包括你的 server_version (e.g. 假設你使用MySQL5.7),會影響到Dontrine如何操作。

Doctrine有很多指令,執行php bin/console list doctrine可以知道所有指令。


建立一個實體類 Creating an Entity Class

假設你一個需要顯示商品的應用程式,就算不考慮Doctrine或資料庫,你也知道需要一個 Product 物件來代表那些產品。

你可以使用指令 make:entity 建立這個class和需要的欄位,這個指令會詢問你一些問題 - 請這樣回答它:
php bin/console make:entity

Class name of the entity to create or update:
> Product

 to stop adding fields):
> name

Field type (enter ? to see all types) [string]:
> string

Field length [255]:
> 255

Can this field be null in the database (nullable) (yes/no) [no]:
> no

 to stop adding fields):
> price

Field type (enter ? to see all types) [string]:
> integer

Can this field be null in the database (nullable) (yes/no) [no]:
> no

 to stop adding fields):
>
(press enter again to finish)
New in version 1.3: 指令 make:entity 互動式的行為在MakerBundle 1.3開始導入。

Woh!現在你有一個新檔案src/Entity/Product.php
// src/Entity/Product.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="App\Repository\ProductRepository")
 */
class Product
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $name;

    /**
     * @ORM\Column(type="integer")
     */
    private $price;

    public function getId()
    {
        return $this->id;
    }

    // ... getter and setter methods
}
你對價格是整數感到困惑嗎?別擔心,這只是一個範例,但是將價格當作整數(e.g. 100 = $1USD)可以避免四捨五入的問題。

如果使用SQLite資料庫,你會看到底下的錯訊息:PDOException: SQLSTATE[HY000]: General error: 1 Cannot add a NOT NULL column with default value NULL,請新增設定屬性nullable=true來修正這個問題。

MySQL5.6使用InnDB table有limit of 767 bytes for the index key prefix,255字元的字串欄位和utf8mb4編碼會壓縮這個限制,這代表任何類型stringunique=true必須將最大值設為190,否則你將看到錯誤訊息:"[PDOException] SQLSTATE[42000]: Syntax error or access violation: 1071 Specified key was too long; max key length is 767 bytes"

這個類稱為"實體entity",待會兒你就可以儲存、從資料表 product 取得Product物件,每一個實體 Product 的屬性可以對應到一個資料表欄位,通常由註釋(annotations)完成:請看下面每一個屬性的註解@ORM\...


指令make:entity雖然可以讓事情變得簡單的工具,但新增移除欄位、新增移除方法或是更新設定都要自己寫。

Doctrine支援多樣的欄位種類,每種都有自己的選項,若要知道完整的清單,請看文件Doctrine's Mapping Types,如果你想要使用XML而非annotations,新增 type: xmldir:

請小心不要在資料表或是欄位名稱使用SQL的保留字(e.g. GROUP 或 USER),請看Doctrine Reserved SQL keywords documentation了解詳細如何跳脫這些,或者將資料表名稱改成@ORM\Table(name="groups"),或將欄位名稱設定name="group_name


Migrations: Creating the Database Tables/Schema

Product已經完全設定好,並且準備存到資料表product,如果你剛定義好這個class,資料庫實際上並沒有product這個資料表,如果要新增,你需要藉由剛剛已經安裝的DoctrineMigrationsBundle來完成:
php bin/console make:migration
如果成功,你將會看到下列的訊息像是:

SUCCESS!

Next: Review the new migration "src/Migrations/Version20180207231217.php" Then: Run the migration with php bin/console doctrine:migrations:migrate


如果你打開這個檔案,裡面包含更新資料庫的SQL,如果實行migrations執行這些SQL,指令:
php bin/console doctrine:migrations:migrate
與你的資料庫現況不衝突為原則,這個指令將執行所有migration檔案,在正式環境需要下指令來部屬,以確保正式環境的資料庫已經更新到最新版。


Migrations 和增加更多欄位

但如果你要在Product新增新的欄位,像是description,你可以編輯這個class、加入新的屬性。但是你也可以再次使用make:entity
php bin/console make:entity

Class name of the entity to create or update
> Product

 to stop adding fields):
> description

Field type (enter ? to see all types) [string]:
> text

Can this field be null in the database (nullable) (yes/no) [no]:
> no

 to stop adding fields):
>
(press enter again to finish)
多了新的屬性description,以及getDescription()setDescription()
// src/Entity/Product.php
// ...

class Product
{
    // ...

+     /**
+      * @ORM\Column(type="text")
+      */
+     private $description;

    // getDescription() & setDescription() were also added
}
此時新屬性仍不存在於資料表product,沒錯!產生新的migration吧
php bin/console make:migration
這次,產生的SQL看起來會像是這樣 :
ALTER TABLE product ADD description LONGTEXT NOT NULL
migration系統很聰明,它會自動比對所有實體在資料庫的現在的狀態,並產生需要的SQL來同步,如果先前的步驟,執行指令來觸發migrations:
php bin/console doctrine:migrations:migrate
這只會執行一個新的migration檔案,因為DoctrineMigrationBundle知道第一個migration已經在先前執行過,背後使用資料表 migration_versions 來追蹤這些紀錄。

每次修改你的schema,執行兩個指令來產生migration和實行migration,請確保commit這些migration檔案並且在部屬時執行。

如果你比較喜歡手動增加屬性,指令 make:entit 可以幫你產生getter和setter方法:
php bin/console make:entity --regenerate
如果你做了一些修正,並且想要重新產生所有的getter/setter方法,要加上--overwrite


將物件保存到資料庫 Persisting Objects to the Database

是時候把Product物件存到資料庫了!先建立一個新的控制器來試驗:
php bin/console make:controller ProductController
在控制器中,你可以建立新的Product物件,設定資料並保存。
// src/Controller/ProductController.php
namespace App\Controller;

// ...
use App\Entity\Product;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Response;

class ProductController extends AbstractController
{
    /**
     * @Route("/product", name="create_product")
     */
    public function createProduct(): Response
    {
        // you can fetch the EntityManager via $this->getDoctrine()
        // or you can add an argument to the action: createProduct(EntityManagerInterface $entityManager)
        $entityManager = $this->getDoctrine()->getManager();

        $product = new Product();
        $product->setName('Keyboard');
        $product->setPrice(1999);
        $product->setDescription('Ergonomic and stylish!');

        // tell Doctrine you want to (eventually) save the Product (no queries yet)
        $entityManager->persist($product);

        // actually executes the queries (i.e. the INSERT query)
        $entityManager->flush();

        return new Response('Saved new product with id '.$product->getId());
    }
}
試試看!


恭喜你!你剛剛已經建立了第一行資料到product,為了證明成功建立,你可以直接從資料庫取出:
php bin/console doctrine:query:sql 'SELECT * FROM product'

# on Windows systems not using Powershell, run this command instead:
# php bin/console doctrine:query:sql "SELECT * FROM product"
請仔細看一下剛剛的範例:
  • line 18 方法$this->getDoctrine()->getManager()取得Doctrine實體管理員(entity manager)物件,也是Dontrine中最重要的物件,負責儲存物件到資料庫及從資料庫取得物件。
  • line 20-23 這個部分,$product物件如同其他一般的PHP物件使用。
  • line 26 persist($product) 呼叫Doctrine"管理"" $product 物件,這並不會對資料庫實際執行任何指令。
  • line 29 當方法 flush() 被呼叫時,Doctrine查看所有管理的物件,並判斷是否需要保存到資料庫。在這個範例中,$product物件在這個資料庫中不存在,所以entity manager會執行 INSERT ,新增一筆紀錄到資料表 product

如果呼叫flush()失敗,會拋出錯誤訊息Doctrine\ORM\ORMException,請參閱Transactions and Concurrency

不論是新增或更新物件,流程都是相同的:Doctrine能聰明地判斷要執行INSERT或是UPDATE你的實體。


驗證物件

The Symfony validator重複利用Doctrine metadata實現一些基礎的驗證任務:
namespace App\Controller;

use App\Entity\Product;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Validator\Validator\ValidatorInterface;
// ...

class ProductController extends AbstractController
{
    /**
     * @Route("/product", name="create_product")
     */
    public function createProduct(ValidatorInterface $validator): Response
    {
        $product = new Product();
        // This will trigger an error: the column isn't nullable in the database
        $product->setName(null);
        // This will trigger a type mismatch error: an integer is expected
        $product->setPrice('1999');

        // ...

        $errors = $validator->validate($product);
        if (count($errors) > 0) {
            return new Response((string) $errors, 400);
        }

        // ...
    }
}
雖然 Product 並未定義任何明確的驗證設定(validation configuration),Symfony針對Dontrice對應設置進行審視,進而推斷一些驗證規則。例如,屬性name在資料庫不能為null,因為這個屬性已經自動加入NotNull constraint這個限制(若此屬性尚未有此限制)。

下面的表格統整了Symfony會自動加入的Doctrine metadata和驗證限制的應對關係:

Dontrine屬性(attribute) 驗證限制 備註
nullable=false NotNull 需要安裝PropertyInfo component
type Type 需要安裝PropertyInfo component
unique=true UniqueEntity
length Length

因為表單元件和API平台內部使用驗證元件,因此所有的表單和Web API都會因為自動化的驗證限制受惠。

自動驗證能夠讓寫程式更加有效率,是一個很棒的特色,但這無法完全取代驗證設定,你依然需要加入驗證限制(validation constraints)來保證提供給使用者的資料都是正確的。

New in version 4.3: Symfony 4.3開始使用自動化驗證。


從資料庫取得物件 Fetching Objects from the Database

從資料庫取得物件很簡單,假設你可以到 /product/1 看你的新商品:
// src/Controller/ProductController.php
// ...

/**
 * @Route("/product/{id}", name="product_show")
 */
public function show($id)
{
    $product = $this->getDoctrine()
        ->getRepository(Product::class)
        ->find($id);

    if (!$product) {
        throw $this->createNotFoundException(
            'No product found for id '.$id
        );
    }

    return new Response('Check out this great product: '.$product->getName());

    // or render a template
    // in the template, print things with {{ product.name }}
    // return $this->render('product/show.html.twig', ['product' => $product]);
}
打開瀏覽器看看!


當你要取得特定物件,你就會用到"知識庫(repository)",你可以想成repository就像是一個PHP class,它唯一個工作就是幫你找出某一類(class)的實體(entity)。

一旦有repository物件,你有需要好用的方法:
$repository = $this->getDoctrine()->getRepository(Product::class);

// look for a single Product by its primary key (usually "id")
$product = $repository->find($id);

// look for a single Product by name
$product = $repository->findOneBy(['name' => 'Keyboard']);
// or find by name and price
$product = $repository->findOneBy([
    'name' => 'Keyboard',
    'price' => 1999,
]);

// look for multiple Product objects matching the name, ordered by price
$products = $repository->findBy(
    ['name' => 'Keyboard'],
    ['price' => 'ASC']
);

// look for *all* Product objects
$products = $repository->findAll();
你可以客製方法來執行更複雜的queries,更多請看章節Querying for Objects: The Repository

在HTML頁面的正下方,除錯工具列(debug toolbar)會顯示query執行的數量和時間。


若query數量過多,符號顯示黃色來表示可能有些錯誤發生,點選符號打開Symfony Profiler去看實際執行的queries,如果你沒看到除錯工具列,請執行指令composer require --dev symfony/profiler-pack進行安裝。


自動取得物件 Automatially Fetching Objects (ParamConverter)

在很多狀況時,你可以使用SensioFrameworkExtraBundle來幫你自動執行query!首先,安裝bundle以防你沒有:
composer require sensio/framework-extra-bundle
現在,簡化你的控制器:
// src/Controller/ProductController.php
use App\Entity\Product;

/**
 * @Route("/product/{id}", name="product_show")
 */
public function show(Product $product)
{
    // use the Product!
    // ...
}
就是這樣!這個套件(bundle)使用路由(route)中的 {id} 來query Productid 欄位,如果查無資訊,則顯示404頁面。

還有很多選擇可以使用,請參閱ParamConverter了解更多。


更新物件

一旦你從Doctrine獲得一個物件,你可以如同PHP model一樣與其互動:
/**
 * @Route("/product/edit/{id}")
 */
public function update($id)
{
    $entityManager = $this->getDoctrine()->getManager();
    $product = $entityManager->getRepository(Product::class)->find($id);

    if (!$product) {
        throw $this->createNotFoundException(
            'No product found for id '.$id
        );
    }

    $product->setName('New product name!');
    $entityManager->flush();

    return $this->redirectToRoute('product_show', [
        'id' => $product->getId()
    ]);
}
使用Doctrine來編輯既有的product共有三個步驟:
  1. 從Donctrine獲取物件
  2. 修改物件
  3. 呼叫實體管理者(entity manager)的flush()
你可以呼叫 $entityManager->persist($product) ,但這不重要,因為Doctrine已經知道你物件的改變。


刪除物件

刪除物件的方法也非常相似,但是需要呼叫方法 remove() 通知Doctrine你想要從資料庫刪除這個給定的物件,DELETE指令不會真正被執行,直到呼叫方法 flush()


Querying for Objects: The Repository

你已經見識過repository物件如何不費工夫地讓你執行一些基礎queries:
// from inside a controller
$repository = $this->getDoctrine()->getRepository(Product::class);

$product = $repository->find($id);
但是如果你需要更複雜的query呢?當你執行 make:entity 來產生你的實體(entity),同時會產生類(class) ProductRepository
// src/Repository/ProductRepository.php
namespace App\Repository;

use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Common\Persistence\ManagerRegistry;

class ProductRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Product::class);
    }
}
當你取得repository(i.e. ->getRepository(Product::class)),它是真正的物件實體!這是因為 repositoryClass 設定已經在 entity class的最上面產生。

假設你要query所有大於某個價格的商品物件,在你的repository新增新的方法:
// src/Repository/ProductRepository.php

// ...
class ProductRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Product::class);
    }

    /**
     * @param $price
     * @return Product[]
     */
    public function findAllGreaterThanPrice($price): array
    {
        // automatically knows to select Products
        // the "p" is an alias you'll use in the rest of the query
        $qb = $this->createQueryBuilder('p')
            ->andWhere('p.price > :price')
            ->setParameter('price', $price)
            ->orderBy('p.price', 'ASC')
            ->getQuery();

        return $qb->execute();

        // to get just one result:
        // $product = $qb->setMaxResults(1)->getOneOrNullResult();
    }
}
這使用到Doctrine的Query Builder,它是非常強大且有用戶友善的方法客製queries,現在你可以呼叫repository的這個方法:
// from inside a controller
$minPrice = 1000;

$products = $this->getDoctrine()
    ->getRepository(Product::class)
    ->findAllGreaterThanPrice($minPrice);

// ...
如果你需要將Service/Config加入(inject)服務中(Injecting Services/Config into a Service),則可以輸入提示class ProductRepository,並像平常一樣將其注入(inject)。

若要瞭解更多細節,請看Doctrine文件Query Builder


Querying with DQL or SQL

除了query產生器之外,你還可以使用Doctrine Query Language進行query:
// src/Repository/ProductRepository.php
// ...

public function findAllGreaterThanPrice($price): array
{
    $entityManager = $this->getEntityManager();

    $query = $entityManager->createQuery(
        'SELECT p
        FROM App\Entity\Product p
        WHERE p.price > :price
        ORDER BY p.price ASC'
    )->setParameter('price', $price);

    // returns an array of Product objects
    return $query->execute();
}
如果有需要也可以直接使用SQL:
// src/Repository/ProductRepository.php
// ...

public function findAllGreaterThanPrice($price): array
{
    $conn = $this->getEntityManager()->getConnection();

    $sql = '
        SELECT * FROM product p
        WHERE p.price > :price
        ORDER BY p.price ASC
        ';
    $stmt = $conn->prepare($sql);
    $stmt->execute(['price' => $price]);

    // returns an array of arrays (i.e. a raw data set)
    return $stmt->fetchAll();
}
若使用SQL,你將取得資料列(raw data);而非物件(除非你使用NativeQuery)。


設定

請看Doctrine config reference


關聯性(Relationships and Associations)

Doctrine提供管理資料庫關聯性的所有功能(被稱謂associations),包含ManyToOne、OneToMany、OneToOne和ManyToMany關係。


虛擬資料裝置(Dummy Data Fixtures)

Doctrine提供函式庫(library)讓你可以載入測試資料道專案裡(i.e. "fixture data"),安裝方式:
composer require --dev doctrine/doctrine-fixtures-bundle
接者使用指令make:fixtures來產生空的fixture class:
php bin/console make:fixtures

The class name of the fixtures to create (e.g. AppFixtures):
ProductFixture
客製化新的class來載入物件 Product 到Doctrine:
// src/DataFixtures/ProductFixture.php
namespace App\DataFixtures;

use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;

class ProductFixture extends Fixture
{
    public function load(ObjectManager $manager)
    {
        $product = new Product();
        $product->setName('Priceless widget!');
        $product->setPrice(14.50);
        $product->setDescription('Ok, I guess it *does* have a price');
        $manager->persist($product);

        // add more products

        $manager->flush();
    }
}
清空資料庫並重新仔入所有的fixure classes:
php bin/console doctrine:fixtures:load
若要取得更多資料,請看文件DoctrineFixturesBundle



資料翻譯自:
https://symfony.com/doc/4.3/doctrine.html

系列文章:


沒有留言:

張貼留言