Giter Club home page Giter Club logo

ray.mediaquery's Introduction

Ray.MediaQuery

Media access mapping framework

codecov Type Coverage Continuous Integration

日本語 (Japanese)

Overview

Ray.QueryModule makes a query to an external media such as a database or Web API with a function object to be injected.

Motivation

  • You can have a clear boundary between domain layer (usage code) and infrastructure layer (injected function) in code.
  • Execution objects are generated automatically so you do not need to write procedural code for execution.
  • Since usage codes are indifferent to the actual state of external media, storage can be changed later. Easy parallel development and stabbing.

Composer install

$ composer require ray/media-query

Getting Started

Define the interface for media access.

DB

Specify the SQL ID with the attribute DbQuery.

interface TodoAddInterface
{
    #[DbQuery('user_add')]
    public function add(string $id, string $title): void;
}

Web API

Specify the Web request ID with the attribute WebQuery.

interface PostItemInterface
{
    #[WebQuery('user_item')]
    public function get(string $id): array;
}

Create the web api path list file as web_query.json.

{
    "$schema": "https://ray-di.github.io/Ray.MediaQuery/schema/web_query.json",
    "webQuery": [
        {"id": "user_item", "method": "GET", "path": "https://{domain}/users/{id}"}
    ]
}

Module

MediaQueryModule binds the execution of SQL and Web API requests to an interface by setting DbQueryConfig or WebQueryConfig or both.

use Ray\AuraSqlModule\AuraSqlModule;
use Ray\MediaQuery\ApiDomainModule;
use Ray\MediaQuery\DbQueryConfig;
use Ray\MediaQuery\MediaQueryModule;
use Ray\MediaQuery\Queries;
use Ray\MediaQuery\WebQueryConfig;

protected function configure(): void
{
    $this->install(
        new MediaQueryModule(
            Queries::fromDir('/path/to/queryInterface'),[
                new DbQueryConfig('/path/to/sql'),
                new WebQueryConfig('/path/to/web_query.json', ['domain' => 'api.example.com'])
            ],
        ),
    );
    $this->install(new AuraSqlModule('mysql:host=localhost;dbname=test', 'username', 'password'));
}

Note: MediaQueryModule requires AuraSqlModule to be installed.

Request object injection

You do not need to prepare an implementation class. It is generated and injected from the interface.

class Todo
{
    public function __construct(
        private TodoAddInterface $todoAdd
    ) {}

    public function add(string $id, string $title): void
    {
        $this->todoAdd->add($id, $title);
    }
}

DbQuery

When the method is called, the SQL specified by the ID is bound with the method argument and executed. For example, if the ID is todo_item, the todo_item.sql SQL statement is bound with ['id => $id] and executed.

interface TodoItemInterface
{
    #[DbQuery('todo_item', type: 'row')]
    public function item(string $id): array;

    #[DbQuery('todo_list')]
    /** @return array<Todo> */
    public function list(string $id): array;
}
  • If the result is a row(array<string, scalar>), specify type:'row'. The type is not necessary for row_list(array<int, array<string, scalar>>).
  • SQL files can contain multiple SQL statements. In that case, the return value is the last line of the SELECT.

Entity

When the return value of a method is an entity class, the result of the SQL execution is hydrated.

interface TodoItemInterface
{
    #[DbQuery('todo_item')]
    public function item(string $id): Todo;

    #[DbQuery('todo_list')]
    /** @return array<Todo> */
    public function list(string $id): array;
}
final class Todo
{
    public readonly string $id;
    public readonly string $title;
}

Use CameCaseTrait to convert a property to camelCase.

use Ray\MediaQuery\CamelCaseTrait;

class Invoice
{
    use CamelCaseTrait;

    public $userName;
}

If the entity has a constructor, the constructor will be called with the fetched data.

final class Todo
{
    public function __construct(
        public readonly string $id,
        public readonly string $title
    ) {}
}

Entity factory

To create an entity with a factory class, specify the factory class in the factory attribute.

interface TodoItemInterface
{
    #[DbQuery('todo_item', factory: TodoEntityFactory::class)]
    public function item(string $id): Todo;

    #[DbQuery('todo_list', factory: TodoEntityFactory::class)]
    /** @return array<Todo> */
    public function list(string $id): array;
}

The factory method of the factory class is called with the fetched data. You can also change the entity depending on the data.

final class TodoEntityFactory
{
    public static function factory(string $id, string $name): Todo
    {
        return new Todo($id, $name);
    }
}

If the factory method is not static, the factory class dependency resolution is performed.

final class TodoEntityFactory
{
    public function __construct(
        private HelperInterface $helper
    ){}
    
    public function factory(string $id, string $name): Todo
    {
        return new Todo($id, $this->helper($name));
    }
}

Web API

  • Customization such as header for authentication is done by binding Guzzle's ClinetInterface.
$this->bind(ClientInterface::class)->toProvider(YourGuzzleClientProvicer::class);

Parameters

DateTime

You can pass a value object as a parameter. For example, you can specify a DateTimeInterface object like this.

interface TaskAddInterface
{
    #[DbQuery('task_add')]
    public function __invoke(string $title, DateTimeInterface $cratedAt = null): void;
}

The value will be converted to a date formatted string at SQL execution time or Web API request time.

INSERT INTO task (title, created_at) VALUES (:title, :createdAt); # 2021-2-14 00:00:00

If no value is passed, the bound current time will be injected. This eliminates the need to hard-code NOW() inside SQL and pass the current time every time.

Test clock

When testing, you can also use a single time binding for the DateTimeInterface, as shown below.

$this->bind(DateTimeInterface::class)->to(UnixEpochTime::class);

VO

If a value object other than DateTime is passed, the return value of the toScalar() method that implements the ToScalar interface or the __toString() method will be the argument.

interface MemoAddInterface
{
    #[DbQuery('memo_add')]
    public function __invoke(string $memo, UserId $userId = null): void;
}
class UserId implements ToScalarInterface
{
    public function __construct(
        private LoginUser $user;
    ){}
    
    public function toScalar(): int
    {
        return $this->user->id;
    }
}
INSERT INTO memo (user_id, memo) VALUES (:userId, :memo);

Parameter Injection

Note that the default value of null for the value object argument is never used in SQL. If no value is passed, the scalar value of the value object injected with the parameter type will be used instead of null.

public function __invoke(Uuid $uuid = null): void; // UUID is generated and passed.

Pagenation

The #[Pager] annotation allows paging of SELECT queries.

use Ray\MediaQuery\PagesInterface;

interface TodoList
{
    #[DbQuery('todo_list'), Pager(perPage: 10, template: '/{?page}')]
    public function __invoke(): PagesInterface;
}

You can get the number of pages with count(), and you can get the page object with array access by page number. Pages is a SQL lazy execution object.

The number of items per page is specified by perPage, but for dynamic values, specify a string with the name of the argument representing the number of pages as follows

    #[DbQuery('todo_list'), Pager(perPage: 'pageNum', template: '/{?page}')]
    public function __invoke($pageNum): Pages;
$pages = ($todoList)();
$cnt = count($page); // When count() is called, the count SQL is generated and queried.
$page = $pages[2]; // A page query is executed when an array access is made.

// $page->data // sliced data
// $page->current;
// $page->total
// $page->hasNext
// $page->hasPrevious
// $page->maxPerPage;
// (string) $page // pager html

Use @return to specify hydration to the entity class.

    #[DbQuery('todo_list'), Pager(perPage: 'pageNum', template: '/{?page}')]
    /** @return array<Todo> */
    public function __invoke($pageNum): Pages;

SqlQuery

SqlQuery executes SQL by specifying the ID of the SQL file. It is used when detailed implementations with an implementation class.

class TodoItem implements TodoItemInterface
{
    public function __construct(
        private SqlQueryInterface $sqlQuery
    ){}

    public function __invoke(string $id) : array
    {
        return $this->sqlQuery->getRow('todo_item', ['id' => $id]);
    }
}

Get* Method

To get the SELECT result, use get* method depending on the result you want to get.

$sqlQuery->getRow($queryId, $params); // Result is a single row
$sqlQuery->getRowList($queryId, $params); // result is multiple rows
$statement = $sqlQuery->getStatement(); // Retrieve the PDO Statement
$pages = $sqlQuery->getPages(); // Get the pager

Ray.MediaQuery contains the Ray.AuraSqlModule. If you need more lower layer operations, you can use Aura.Sql's Query Builder or Aura.Sql which extends PDO. doctrine/dbal is also available.

Profiler

Media accesses are logged by a logger. By default, a memory logger is bound to be used for testing.

public function testAdd(): void
{
    $this->sqlQuery->exec('todo_add', $todoRun);
    $this->assertStringContainsString('query: todo_add({"id": "1", "title": "run"})', (string) $this->log);
}

Implement your own MediaQueryLoggerInterface and run You can also implement your own MediaQueryLoggerInterface to benchmark each media query and log it with the injected PSR logger.

Annotations / Attributes

You can use either doctrine annotations or PHP8 attributes can both be used. The next two are the same.

use Ray\MediaQuery\Annotation\DbQuery;

#[DbQuery('user_add')]
public function add1(string $id, string $title): void;

/** @DbQuery("user_add") */
public function add2(string $id, string $title): void;

Testing Ray.MediaQuery

Here's how to install Ray.MediaQuery from the source and run the unit tests and demos.

$ git clone https://github.com/ray-di/Ray.MediaQuery.git
$ cd Ray.MediaQuery
$ composer tests
$ php demo/run.php

ray.mediaquery's People

Contributors

apple-x-co avatar jingu avatar koriym avatar mstysk avatar naokitsuchiya avatar shotanue avatar yuki777 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

ray.mediaquery's Issues

戻り値の型宣言でnullable な型を渡すことが出来なくなっている

Bug Report

戻り値の型宣言でnullable な型を渡すことが出来なくなっている

How to reproduce

テストコードの TotoEntityInterface::getItem の返り値を nullable にすると再現します。

  • Ray.MediaQuery: 0.10.0
  • PHP: 8.1.14
Ray.MediaQuery on  HEAD (ef3dda2) via 🐘 v8.1.14
❯ git diff
error: cannot run delta: No such file or directory
diff --git a/tests/Fake/Queries/TodoEntityInterface.php b/tests/Fake/Queries/TodoEntityInterface.php
index 23a5bf3..1f5e349 100644
--- a/tests/Fake/Queries/TodoEntityInterface.php
+++ b/tests/Fake/Queries/TodoEntityInterface.php
@@ -10,7 +10,7 @@
 interface TodoEntityInterface
 {
     #[DbQuery('todo_item')]
-    public function getItem(string $id): Todo;
+    public function getItem(string $id): ?Todo;

     #[DbQuery('todo_list')]
     /**

Ray.MediaQuery on  HEAD (ef3dda2) via 🐘 v8.1.14
❯ ./vendor/bin/phpunit
PHPUnit 9.6.3 by Sebastian Bergmann and contributors.

......E.............................................              52 / 52 (100%)

Time: 00:01.916, Memory: 32.00 MB

There was 1 error:

1) Ray\MediaQuery\DbQueryModuleTest::testEntity
TypeError: Ray\MediaQuery\Queries\TodoEntityInterfaceNull_1464564696::getItem(): Return value must be of type ?Ray\MediaQuery\Entity\Todo, array returned

/pato/to/Ray.MediaQuery/tests/tmp/Ray_MediaQuery_Queries_TodoEntityInterfaceNull_1464564696.php:15
/path/to/Ray.MediaQuery/tests/DbQueryModuleTest.php:144

ERRORS!
Tests: 52, Assertions: 78, Errors: 1.

Error message improvement

現状:

SQLのエラーが発生するとPDOExceceptionが発生する (ネイティブのPHP振る舞い)

PDOException : SQLSTATE[HY000]: General error: 1 near "@SELECT": syntax error

このエラーメッセージを改善。

Ray\MediaQuery\Exception\PdoPerformException : SQLSTATE[HY000]: General error: 1 near "@SELECT": syntax error in todo_item.sql with values{"id":"1"}

SQLファイル名とバインドした値をメッセージに加えています。

Multi-row SQL error with pager

Bug Report

複数行のSQLがpagerでエラー

How to reproduce

SELECT *
  FROM todo;

上記のSQLでページングをする

Entity's factory support

エンティティのファクトリーを指定可能にします。

例)
$idに応じてTeacherクラスとStudentクラスの生成を行います。

ファクトトリーを用意します。

<?php

final class UserEntityFactory
{
    public static function factory(string $id, string $name): Todo
    {
        if (str_starts_with($id, 't') {
            return new Teacher($id, $name);
        }

        return new Student($id, $name);
    }
}

factoryアトリビュートでエンティティのファクトリーを指定します。

<?php

declare(strict_types=1);

namespace Ray\MediaQuery\Fake\Queries;

use Ray\MediaQuery\Annotation\DbQuery;
use Ray\MediaQuery\Entity\Todo;
use Ray\MediaQuery\Factory\TodoEntityFactory;

interface UserInterface
{
    #[DbQuery('todo_item', factory: UserEntityFactory::class)]
    public function getItem(string $id): Teacher|Student;

    #[DbQuery('todo_list', factory: UserEntityFactory::class)]
    /** @return array<Teacher|Student> */
    public function getList(): array;
}

\Ray\MediaQuery\MediaQueryLogger causing problems with Uuids

I've juststarted playing with BEAR.Sunday, so please excude my lack of knowledge & possibly understanding.

Bug Report

I am using (binary) Uuids for Ids in my new app, and whilst I have this working OK for saving the values to the d/b, an error is being throw because the MediaQueryLogger cannot json_encode the binary string.

I thought I could use AOP to intercept the Uuid values being passed to logger, however there are 2 problems with this…

  1. The values being passed are the converted ones, not the original ones, so they'll need 'unconverting' (which I'm doing in a decorator of the MediaQueryLogger);
  2. The MediaQueryLogger class is final, so it's not possible to intercept the log() method call.

How to reproduce

  1. Create the following files & classes…

    # asset_create.sql
    INSERT INTO assets (id, name, createdAt, updatedAt)
    VALUES (:id, :name, :createdAt, :updatedAt);
    <?php
    // src/Resource/App/Assets.php
    
    declare(strict_types=1);
    
    namespace TobyGriffiths\NetWorth\Resource\App;
    
    use BEAR\Package\Annotation\ReturnCreatedResource;
    use BEAR\Resource\ResourceObject;
    use DateTimeInterface;
    use Koriym\HttpConstants\ResponseHeader;
    use Koriym\HttpConstants\StatusCode;
    use TobyGriffiths\NetWorth\Query\AssetCommandInterface;
    use TobyGriffiths\NetWorth\Uuid\UuidFactory;
    
    use function uri_template;
    
    class Assets extends ResourceObject
    {
        public function __construct(
            private UuidFactory $uuidFactory,
            private readonly AssetCommandInterface $command,
            private readonly DateTimeInterface $now,
        ) {
        }
    
        public function onPost(string $name): static
        {
            $id = $this->uuidFactory->uuid7($this->now);
            $this->command->create($id, $name);
    
            $this->code = StatusCode::CREATED;
            $this->headers[ResponseHeader::LOCATION] = uri_template('/assets{?id}', ['id' => $id->toString()]);
    
            return $this;
        }
    }
    <?php
    // src/Uuid/UuidFactory.php
    
    declare(strict_types=1);
    
    namespace TobyGriffiths\NetWorth\Uuid;
    
    use DateTimeImmutable;
    use Ramsey\Uuid\UuidFactory as RamseyUuidFactory;
    
    final readonly class UuidFactory implements UuidFactoryInterface
    {
        public function __construct(
            private RamseyUuidFactory $innerFactory,
        ) {
        }
    
        public function uuid7(DateTimeImmutable|null $dateTime = null): Uuid
        {
            return new Uuid($this->innerFactory->uuid7($dateTime));
        }
    }
    // src/Uuid/Uuid.php
    <?php
    
    declare(strict_types=1);
    
    namespace TobyGriffiths\NetWorth\Uuid;
    
    use Ramsey\Uuid\UuidInterface as RamseyUuidInterface;
    
    final readonly class Uuid
    {
        public function __construct(
            private RamseyUuidInterface $innerUuid,
        ) {
        }
    
        public function toString(): string
        {
            return $this->innerUuid->toString();
        }
    
        public function toScalar(): string
        {
            return $this->innerUuid->getBytes();
        }
    }
  2. Bind the necessary services…

    <?php
    
    declare(strict_types=1);
    
    namespace TobyGriffiths\NetWorth\Module;
    
    use BEAR\Dotenv\Dotenv;
    use BEAR\Package\AbstractAppModule;
    use BEAR\Package\PackageModule;
    use BEAR\Resource\Module\JsonSchemaModule;
    use Ramsey\Uuid\UuidFactory as RamseyUuidFactory;
    use Ramsey\Uuid\UuidFactoryInterface as RamseyUuidFactoryInterface;
    use Ray\AuraSqlModule\AuraSqlModule;
    use Ray\Di\ProviderInterface;
    use Ray\Di\Scope;
    use Ray\IdentityValueModule\IdentityValueModule;
    use Ray\MediaQuery\DbQueryConfig;
    use Ray\MediaQuery\MediaQueryLogger;
    use Ray\MediaQuery\MediaQueryLoggerInterface;
    use Ray\MediaQuery\MediaQueryModule;
    use Ray\MediaQuery\Queries;
    use TobyGriffiths\NetWorth\MediaQuery\BinaryDataMediaQueryLoggerDecorator;
    use TobyGriffiths\NetWorth\Uuid\UuidFactory;
    use TobyGriffiths\NetWorth\Uuid\UuidFactoryInterface;
    
    use function dirname;
    use function getenv;
    
    class AppModule extends AbstractAppModule
    {
        protected function configure(): void
        {
            (new Dotenv())->load(dirname(__DIR__, 2));
    
            $this->overrideMediaQueryLogger();
    
            $this->installDbModules();
    
            $this->bind(UuidFactoryInterface::class)->to(UuidFactory::class)->in(Scope::SINGLETON);
            $this->bind(RamseyUuidFactoryInterface::class)->to(RamseyUuidFactory::class)->in(Scope::SINGLETON);
    
            $this->install(new PackageModule());
        }
    
        private function installDbModules(): void
        {
            $this->install(
                new AuraSqlModule(
                    (string) getenv('DB_DSN'),
                    (string) getenv('DB_USER'),
                    (string) getenv('DB_PW'),
                    (string) getenv('DB_SLAVE'),
                ),
            );
    
            $this->install(
                new MediaQueryModule(
                    Queries::fromDir($this->appMeta->appDir . '/src/Query'),
                    [
                        new DbQueryConfig($this->appMeta->appDir . '/var/sql'),
                    ],
                ),
            );
    
            $this->install(new IdentityValueModule());
    
            $this->install(
                new JsonSchemaModule(
                    $this->appMeta->appDir . '/var/schema/response',
                    $this->appMeta->appDir . '/var/schema/request',
                ),
            );
        }
    
        private function overrideMediaQueryLogger(): void
        {
        // Commenting this fix out to demo the problem
        //
        //    $this
        //        ->bind(MediaQueryLoggerInterface::class)
        //        ->to(BinaryDataMediaQueryLoggerDecorator::class)
        //        ->in(Scope::SINGLETON);
        //    $this->bind(MediaQueryLogger::class);
        }
    }
  3. Call the endpoint…

$ php ./bin/app/php post '/assets?name=wibble'

You can see I've commented out a call to overrideMediaQueryLogger(), which binds the following class to fix the issue, for now…

<?php

declare(strict_types=1);

namespace TobyGriffiths\NetWorth\MediaQuery;

use InvalidArgumentException;
use Ramsey\Uuid\UuidFactory;
use Ray\MediaQuery\MediaQueryLogger as RayMediaQueryLogger;
use Ray\MediaQuery\MediaQueryLoggerInterface;

use function mb_check_encoding;

/**
 * Drop in replacement of Ray\MediaQuery\MediaQueryLogger that can handle binary Uuid values.
 */
final class BinaryDataMediaQueryLoggerDecorator implements MediaQueryLoggerInterface
{
    public function __construct(
        private readonly RayMediaQueryLogger $decorated,
        // We use the concrete class here because that has needed to be bound
        private readonly UuidFactory $uuidFactory,
    ) {
    }

    public function start(): void
    {
        $this->decorated->start();
    }

    /**
     * {@inheritDoc}
     */
    public function log(string $queryId, array $values): void
    {
        foreach ($values as &$value) {
            if (mb_check_encoding($value, 'UTF-8')) {
                continue;
            }

            // Replace this with injected handlers for different types of binary data?
            try {
                $value = $this->uuidFactory
                    ->fromBytes($value)
                    ->toString();
            } catch (InvalidArgumentException) {
                // Don't convert the value, we'll just fail for now.
            }
        }
    }

    public function __toString(): string
    {
        return $this->decorated->__toString();
    }
}

So I have a proposed solution, which I'm happy to PR…

  1. Remove the final from MediaQueryLogger;

  2. Include the original values in the call to MediaQueryLoggerInterface::log(), optional, so as not to break anyone's
    overrides…

    public function log(string $queryId, array $values, array $originalValues = []): void;

I also found it difficult to work out how to decorate the original MediaQueryLogger instance, which is straight pretty
forward in Symfony, so if anyone can enlighten me on that, I can improve my workaround until this has a better fix?

If I can provide any more context info, or test some things, please let me know.

getRowから呼ばれるクエリ結果が空の場合にundefined offsetが発生する

sqlファイル名のポストフィックスがitemなど、SqlQuery::getRowが呼ばれるケースで、結果セットが空の時にundefined offsetが発生してしまいます。

public function getRow(string $sqlId, array $values = [], int $fetchMode = PDO::FETCH_ASSOC, $fetchArg = '')
{
$rowList = $this->perform($sqlId, $values, $fetchMode, $fetchArg);
$item = $rowList[0];

結果が取得できなかった際の戻り値としては、空配列またはnullを期待していたのですが、意図した動作でしょうか?

Bind entity

下記のようにエンティティクラスを指定すると、DBクエリーをfetchobjectで行い結果をエンティティにハイドレートする。

#[DbQuery('user_list', entity: User::class)]

pros

  • 列を表すのにarray-shape表記をしなくて良い。
  • エンティティクラスではmagicメソッドを使い値を変換することができる。

PHP 7.3 support

現在Ray.Queryをプロジェクトで使用していますが、Ray.MediaQueryの利用、変更を検討しています。

しかしながらRay.MediaQueryのminバージョンが7.4以上になっており、この点が導入のボトルネックになっています。
minバージョン指定が7.3になるとプロジェクトに導入しやすいため、検討いただけると嬉しいです。

"php": "^7.4 || ^8.0",

@Pager利用時にも@DbQueryで指定したentityでhydrateしたい

@DbQueryにentityを指定した場合、hydrateできますが、@Pagerを組み合わせるとhydrateできない状況です。

具体的には、以下のコードだとRay\MediaQuery\PagesInterfaceが戻り値になりますが、PagesInterfaceに内包しているレコードは@DbQueryに渡されているentityでのhydrateされず、連想配列になります。

    /**
     * @DbQuery("query", entity="Foo::class")
     * @Pager(perPage="50", template="?page={?page}")
     */
    public function query(): PagesInterface;

呼び出し側で連想配列からマップもできますが、MediaQuery側でそこまで出来ていると記述量が減り助かるなと思った次第です。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.