4.2. Writing a Package

The framework of Cradle is all about packages and there are three types of packages (but they basically can do the same thing, just located in different places).

  • Root packages - are found in your project root directory. All applications as in /app/admin, /app/www and modules as in /module/utility are all kinds of root packages.
  • Vendor packages - are found in the /vendor/ folder of your project directory. These packages are from third party vendors. /vendor/cradlephp/cradle-system, /vendor/cradlephp/cradle-profile, /vendor/cradlephp/cradle-auth are all kinds of vendor packages.
  • Pseudo packages - are located in memory. It does not have any files or folders. The global package is an example of a pseudo package.

4.2.1. Setting up the Package

This chapter continues from the last chapter, 4.1. Developing the Front End and in this chapter we will create an Article Package from the ground up. Create a file called /module/article/.cradle.php, where the /module should already exist and add the following PHP code. This will be called the Article Bootstrap.

Figure 4.2.1.A. Setting up the bootstrap
require_once __DIR__ . '/src/events.php';
WARNING: Don't forget the `<?php` tag

Next, create the Article Events at /module/article/src/events.php and add the following PHP code.

Figure 4.2.1.B. Writing an Event
use Cradle\Storm\SqlFactory;

$this->on('article-detail', function($req, $res) {
    //get the article ID
    $articleId = $req->getStage('article_id');

    //if it is not a number
    if (!is_numeric($articleId)) {
        //set an error and return
        return $res->setError(true, 'Invalid ID');
    }

    //get the PDO object from services
    $pdo = $this->package('global')->service('sql-main');
    //load up Storm ORM
    $database = SqlFactory::load($pdo);

    //get the article
    $article = $database
        ->search('article')
        ->innerJoinUsing('article_profile', 'article_id')
        ->innerJoinUsing('profile', 'profile_id')
        ->filterByArticleId($articleId)
        ->getRow();

    //if no article found
    if (!$article) {
        //set an error and return
        return $res->setError(true, 'Not Found');
    }

    //set the results
    $res->setResults($article);
});

The goal of this event article-detail is to produce the information of an Article Object, given its article_id. If the ID is invalid or the database can’t find it, the event should set an error. There are a few concepts in the above example that need to be explained.

First if there is no article_id, we can set an error via $res->setError(true, 'Invalid ID'). Since we should not do anything on an invalid ID we should simply return.

We can access a service in our config via $this->package('global')->service('sql-main');, which we cover in 3.1. Services & Settings. In this case sql-main returns a PDO object. We pass this PDO object to SqlFactory::load($pdo) to load up the Storm ORM, and then from here get the article from the database.

Before we can test this event, we need to register this package in the configuration. Open /config/packages.php and add the Article Package to the bottom of the list just like the following example.

Figure 4.2.1.C. Registering the Package
return array (
  ...
  '/module/article' =>
  array (
    'active' => true,
  ),
);

To test this event in terminal, go to the root directory of your project and execute the following bash command.

Figure 4.2.1.D. Testing the Package
$ bin/cradle article-detail article_id=1
{"results":{"profile_id":"1","article_id":"1","article_title":"What is the Fate of the Furious?","article_detail":"I don't understand if <b>Jason Stathom<\/b> killed <b>Sung Kang<\/b>, <i>(<b>Vin Desiel's<\/b> asian friend).&nbsp;<\/i>How could they be cool with each other in future \"<b>Fast and Furious\"<\/b> films?","article_status":"published","article_published":"2019-01-30 14:00:00","article_active":"1","article_created":"2019-01-25 13:27:18","article_updated":"2019-01-28 07:11:57","article_references":"{\"reference_link\": \"https:\/\/ew.com\/movies\/2017\/04\/15\/fate-furious-han-shaw-chris-morgan\/\", \"reference_quote\": \"Statham actually joined the franchise at the end of Fast & Furious 6, when it was revealed that he killed longtime Toretto crew member Han (Sung Kang) as the first step of his vengeance mission.\", \"reference_title\": \"The Fate of the Furious: Screenwriter Chris Morgan talks Shaw... and Han\", \"reference_publication\": \"Entertainment Weekly\"}","profile_name":"John Doe","profile_active":"1","profile_created":"2019-01-20 06:43:42","profile_updated":"2019-01-20 06:43:42"}}

The next thing we probably want to do is add the Article Object’s comments to the result set. Lets go back to the Article Events (/module/article/src/events.php) and add another SQL query like the following code snippet.

Figure 4.2.1.E. Adding Comments
...
$this->on('article-detail', function($req, $res) {
    //get the article ID
    $articleId = $req->getStage('article_id');

    ...

    //get all the article comments
    $comments = $database
        ->search('comment')
        ->innerJoinUsing('comment_profile', 'comment_id')
        ->innerJoinUsing('profile', 'profile_id')
        ->innerJoinUsing('article_comment', 'comment_id')
        ->filterByArticleId($articleId)
        ->getRows();

    //if there are comments
    if (!empty($comments)) {
        //add the comments
        $article['comment'] = $comments;
    }

    //set the results
    $res->setResults($article);
});

If you run the command line test again bin/cradle article-detail article_id=1, you will see the comments have been added. The above example is kind of how the event system-model-detail works and we could just call system-model-detail instead of writing this.

While the system can deal with basic relations, the limitations is that it only cases for one level deep. What that means is, the system can auto-determine article->comment and comment->comment but it doesn’t case for article->comment->comment. In the case of our example, it would make sense to write a custom event like article-detail to achieve the article->comment->comment result set.

In the Article Events (/module/article/src/events.php) let’s change the code inside of if (!empty($comments)) with the following code.

Figure 4.2.1.F. Adding More Comments
...
$this->on('article-detail', function($req, $res) {
    ...
    //if there are comments
    if (!empty($comments)) {
        $commentIds = [];
        $commentsWithChildren = [];

        //loop through the comments
        foreach($comments as $comment) {
            $commentId = $comment['comment_id'];
            //add to the ID set
            $commentIds[] = $commentId;
            //organize the comments by ID
            $commentsWithChildren[$commentId] = $comment;
        }

        //set up the SQL IN filter
        $filter = sprintf(
            'comment_id_1 IN (%s)',
            implode(',', $commentIds)
        );

        //get all the sub comments in 1 query
        $subcomments = $database
            ->search('comment')
            ->innerJoinOn('comment_comment', 'comment_id_2=comment_id')
            ->innerJoinUsing('comment_profile', 'comment_id')
            ->innerJoinUsing('profile', 'profile_id')
            ->addFilter($filter)
            ->getRows();

        //loop through each sub comment
        foreach ($subcomments as $comment) {
            $primaryCommentId = $comment['comment_id_1'];
            $secondaryCommentId = $comment['comment_id_2'];

            //attach it to the existing comments as children
            $primaryComment = $commentsWithChildren[$primaryCommentId];
            $primaryComment['children'][$secondaryCommentId] = $comment;
            $commentsWithChildren[$primaryCommentId] = $primaryComment;
        }

        //add the comments
        $article['comment'] = $commentsWithChildren;
    }

    //set the results
    $res->setResults($article);
});

Our article-detail event in all of its glory should look like the following code.

$this->on('article-detail', function($req, $res) {
    $articleId = $req->getStage('article_id');

    if (!is_numeric($articleId)) {
        return $res->setError(true, 'Invalid ID');
    }

    $pdo = $this->package('global')->service('sql-main');
    $database = SqlFactory::load($pdo);

    $article = $database
        ->search('article')
        ->innerJoinUsing('article_profile', 'article_id')
        ->innerJoinUsing('profile', 'profile_id')
        ->filterByArticleId($articleId)
        ->getRow();

    if (!$article) {
        return $res->setError(true, 'Not Found');
    }

    $comments = $database
        ->search('comment')
        ->innerJoinUsing('comment_profile', 'comment_id')
        ->innerJoinUsing('profile', 'profile_id')
        ->innerJoinUsing('article_comment', 'comment_id')
        ->filterByArticleId($articleId)
        ->getRows();

    if (empty($comments)) {
        return $res->setResults($article);
    }

    $commentIds = [];
    $commentsWithChildren = [];

    foreach($comments as $comment) {
        $commentId = $comment['comment_id'];
        $commentIds[] = $commentId;
        $commentsWithChildren[$commentId] = $comment;
    }

    $filter = sprintf(
        'comment_id_1 IN (%s)',
        implode(',', $commentIds)
    );

    $subcomments = $database
        ->search('comment')
        ->innerJoinOn('comment_comment', 'comment_id_2=comment_id')
        ->innerJoinUsing('comment_profile', 'comment_id')
        ->innerJoinUsing('profile', 'profile_id')
        ->addFilter($filter)
        ->getRows();

    foreach ($subcomments as $comment) {
        $primaryCommentId = $comment['comment_id_1'];
        $secondaryCommentId = $comment['comment_id_2'];

        $primaryComment = $commentsWithChildren[$primaryCommentId];
        $primaryComment['children'][$secondaryCommentId] = $comment;
        $commentsWithChildren[$primaryCommentId] = $primaryComment;
    }

    $article['comment'] = $commentsWithChildren;

    $res->setResults($article);
});

While this would probably work in all cases, as programmers we should also be thinking about abstraction. In the Article Package we are working on, let’s add a class called SqlService to abstract out the SQL calls.

4.2.2. Adding a Class

Before we can do that, we need to tell Composer about the new Article Name Space by registering it in /composer.json like the following json code.

Figure 4.2.2.A. composer.json
{
    ...
    "autoload": {
        "psr-4": {
            "Cradle\\Module\\Utility\\": "module/utility/src/",
            "Cradle\\Module\\Article\\": "module/article/src/"
        }
    },
    ...
}

Next in terminal, we need to issue the following Composer command to rebuild its list of name space locations.

Figure 4.2.2.B. Dump Autoload
$ composer dump-autoload

Let’s next create the SqlService at /module/article/src/SqlService.php and paste the following code.

Figure 4.2.2.B SqlService
namespace Cradle\Module\Article;

use Cradle\Storm\SqlFactory;
use PDO as SqlResource;

class SqlService
{
    protected $resource;

    public function __construct(SqlResource $resource)
    {
        //load up Storm ORM
        $this->resource = SqlFactory::load($resource);
    }

    public function addChildrenComments(array $comments): array
    {
        //if no comments
        if (empty($comments)) {
            //return the comments as is
            return $comments;
        }

        $commentIds = [];
        $commentsWithChildren = [];

        //loop through the comments
        foreach($comments as $comment) {
            $commentId = $comment['comment_id'];
            //add to the ID set
            $commentIds[] = $commentId;
            //organize the comments by ID
            $commentsWithChildren[$commentId] = $comment;
        }

        //set up the SQL IN filter
        $filter = sprintf(
            'comment_id_1 IN (%s)',
            implode(',', $commentIds)
        );

        //get all the sub comments in 1 query
        $subcomments = $this->resource
            ->search('comment')
            ->innerJoinOn('comment_comment', 'comment_id_2=comment_id')
            ->innerJoinUsing('comment_profile', 'comment_id')
            ->innerJoinUsing('profile', 'profile_id')
            ->addFilter($filter)
            ->getRows();

        //loop through each sub comment
        foreach ($subcomments as $comment) {
            $primaryCommentId = $comment['comment_id_1'];
            $secondaryCommentId = $comment['comment_id_2'];

            //attach it to the existing comments as children
            $primaryComment = $commentsWithChildren[$primaryCommentId];
            $primaryComment['children'][$secondaryCommentId] = $comment;
            $commentsWithChildren[$primaryCommentId] = $primaryComment;
        }

        //return the new array
        return $commentsWithChildren;
    }
}

So first, we created a namespace called Cradle\Module\Article in both the /composer.json and in /module/article/src/SqlService.php. We then copied the namespace use Cradle\Storm\SqlFactory from the Article Events (/module/article/src/events.php) as well as added an alias for PDO described via use PDO as SqlResource. We did this in order to require the PDO object in our construct that looks like public function __construct(SqlResource $resource). Then we created a public method called addChildrenComments() where the body of that method, we copied from the Article Events.

We can now abstract parts out from the article-detail like the following code.

Figure 4.2.2.B Event Abstraction
use Cradle\Module\Article\SqlService;

$this->on('article-detail', function($req, $res) {
    //call the model detail event
    $req->setStage('schema', 'article');
    $this->trigger('system-model-detail', $req, $res);

    //if there is an error
    if ($res->isError()) {
        //there's nothing else to do
        return;
    }

    //get the articles
    $article = $res->getResults();

    //load up the SqlService Class
    $pdo = $this->package('global')->service('sql-main');
    $database = new SqlService($pdo);

    //add the comment children
    $article['comment'] = $database->addChildrenComments($article['comment']);

    //set the results
    $res->setResults($article);
});

As you noticed, we re-used system-model-detail instead of our original code to get the Article Object and Comment Objects. We finally called our new SqlService->addChildrenComments() to add the additional comment->comment result set. Let’s test this one more time on our command line to make sure everything still works.

Figure 4.2.2.C. Testing the Package
$ bin/cradle article-detail article_id=1

So now that we have the capabilities to retrieve an article->comment->comment result set, let’s use our new Article Package in the front end. Go to the Article Controller (/app/www/src/controller/article.php) and replace the code calling system-model-detail with our new article-detail instead. The following shows how it should have been done.

4.2.3. Connecting with the Controller

Figure 4.2.3.A. Article Controller
$this->get('/article/:article_id', function($req, $res) {
    $data = [];
    //if this is a return back from processing the comment form
    if ($req->hasPost()) {
        $data['form'] = $res->getPost();
    }

    //and it's has of an error
    if ($res->isError()) {
        //pass the error messages to the template
        $res->setFlash($res->getMessage(), 'error');
        $data['errors'] = $res->getValidation();
    }

    //get the article
    $this->trigger('article-detail', $req, $res);

    //if there is no data
    if (!$res->hasResults()) {
        //let the 404 catch this
        return;
    }
    ...
});

Lastly, let’s go in to the Article Detail Template (/app/www/src/template/article/detail.html) and add the following {{#if children}} case.

Figure 4.2.3.B. Article Detail Template
<div class="container">
    ...
    <h3 class="mt-5">Comments</h3>
    {{#each item.comment}}
        ...
        {{#if children}}
            {{#each children}}
                <div class="ml-5 mt-3">
                    <p>{{profile_name}} {{relative comment_created}}:</p>
                    {{{comment_detail}}}
                </div>
            {{/each}}
        {{/if}}
    {{/each}}
    ...
</div>

4.2.4. Conclusion

So we created a package from the ground up and linked it with a controller. A package can be accessed programmaticall, by browser, or command line. In the next chapter we will be covering the Storm ORM in 4.3 Intro to Storm.