+ - 0:00:00
Notes for current slide
Notes for next slide

Atlas ORM

Doing The Heavy Lifting For Your Persistence Layer

Atlas Logo

Paul M. Jones

http://atlasphp.io

1 / 53

About Me

MLAPHP

  • 8 years USAF Intelligence
  • BASIC in 1983, PHP since 1999
  • Jr. Developer, VP Engineering
  • Aura, ZF, Relay, Radar
  • PSR-1, PSR-2, PSR-4
  • Action-Domain-Responder
  • MLAPHP
2 / 53

Objects and Tables

  • Moving complex data from SQL to objects and back is tough

  • OOP fundamentally different from relational algebra

  • Evolved data-source patterns to deal with this

3 / 53

Data Source Architecture

DDD

4 / 53

Domain Logic

DDD

  • Domain "should not" be modeled on database structure

  • Keep domain objects separated from database connections

  • Converting between database and domain is very difficult

  • "Object-Relational Impedance Mismatch"

5 / 53

Plain Old SQL?

  • Write queries by hand

  • Map to object by hand

  • Fine control, time consuming

  • Relationships difficult

6 / 53

Object-Relational Mappers (ORMs)

  • Ease conversion of relational rows to domain objects

  • Generate SQL, retrieve/save data, map data to objects

  • Active Record (persistence combined with domain logic)

  • Data Mapper (persistence separated from domain objects)

7 / 53

Tradeoffs (Active Record)

  • Easy to start with for CRUD/BREAD

  • Easy to add simple domain logic

  • As complexity increases, need separate domain layer

  • Hard to extract domain behavior from perisistence

  • Harder to maintain and refactor

8 / 53

Tradeoffs (Data Mapper)

  • Clear separation between persistence and domain

  • Easier to maintain as complexity increases

  • Harder to get started with

  • Presumes rich domain model and mapping expertise

  • Too much for early CRUD/BREAD operations

9 / 53

The Underlying Problem

  • All systems start simple

  • Some systems become complex

  • Can't tell in advance

  • Want a low-cost path in case of complexity

10 / 53

Desiderata

  • Easy to get started with

  • Clear refactoring path if complexity increases

  • Amenable to CRUD/BREAD in early stages

  • Ability to add simple behaviors

  • Strategy to convert from ORM to Domain Model proper

  • Maintain separation of persistence from data

11 / 53

Persistence Model, not Domain Model

  • Thanks, Mehdi Khalili

  • Use the Data Mapper approach for separation

  • Instead of mapping to domain model Entities & Aggregates ...

  • ... map to the persistence model rows & relationships (Records)

  • Then build domain objects from persistence objects, when needed

12 / 53

Atlas

  • A data mapper for your persistence model

  • Retrieve and save Row objects through Table Data Gateways

  • Identity map for Rows on primary keys (incl. composite keys)

  • Define relationships between tables (Row + Related = Record)

  • Retrieve and save Record objects through the Mappers

13 / 53

Atlas From The Ground Up

14 / 53

Installation

All-purpose:

$ composer require atlas/orm ~3.0
$ composer require --dev atlas/cli ~2.0

Symfony:

$ composer require atlas/symfony ~1.0

Slim: cookbook

15 / 53

Tables

CREATE TABLE authors (
author_id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name VARCHAR(50),
last_name VARCHAR(50)
);
CREATE TABLE threads (
thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
author_id INTEGER NOT NULL,
created_at DATETIME,
updated_at DATETIME,
title VARCHAR(100),
body TEXT
);
CREATE TABLE summaries (
thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
reply_count INTEGER,
view_count INTEGER
);
16 / 53
CREATE TABLE replies (
reply_id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL,
author_id INTEGER NOT NULL,
body TEXT
);
CREATE TABLE taggings (
tagging_id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER,
tag_id INTEGER
);
CREATE TABLE tags (
tagging_id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(20)
);
17 / 53

Skeleton Generator

Skeleton Config:

<?php
return [
'pdo' => [
'mysql:dbname=testdb;host=localhost',
'username',
'password',
],
'namespace' => 'App\\DataSource',
'directory' => './src/App/DataSource',
];

Skeleton Command:

$ php ./vendor/bin/atlas-skeleton.php /path/to/skeleton-config.php
18 / 53

Generated Files

└── src
└── App
└── DataSource
└── Thread
├── Thread.php # mapper
├── ThreadEvents.php
├── ThreadFields.php
├── ThreadRecord.php
├── ThreadRecordSet.php
├── ThreadRelationships.php # relationships
├── ThreadRow.php
├── ThreadSelect.php
├── ThreadTable.php # table
├── ThreadTableEvents.php
└── ThreadTableSelect.php
19 / 53

Table Class

namespace App\DataSource\Thread;
use Atlas\Table\Table;
/**
* @method ThreadRow|null fetchRow($primaryVal)
* @method ThreadRow[] fetchRows(array $primaryVals)
* @method ThreadTableSelect select(array $whereEquals = [])
* @method ThreadRow newRow(array $cols = [])
* @method ThreadRow newSelectedRow(array $cols)
*/
class ThreadTable extends Table
{
const DRIVER = 'mysql';
const NAME = 'threads';
20 / 53
const COLUMNS = [
'thread_id' => [
'name' => 'thread_id',
'type' => 'INTEGER',
'size' => null,
'scale' => null,
'notnull' => false,
'default' => null,
'autoinc' => true,
'primary' => true,
'options' => null,
],
// ...
'body' => [
'name' => 'body',
'type' => 'TEXT',
'size' => null,
'scale' => null,
'notnull' => true,
'default' => null,
'autoinc' => false,
'primary' => false,
'options' => null,
],
];
21 / 53
const COLUMN_NAMES = [
'thread_id',
'author_id',
'subject',
'body',
];
const COLUMN_DEFAULTS = [
'thread_id' => null,
'author_id' => null,
'subject' => null,
'body' => null,
];
const PRIMARY_KEY = [
'thread_id',
];
const AUTOINC_COLUMN = 'thread_id';
const AUTOINC_SEQUENCE = null;
}
22 / 53

Mapper Class

namespace App\DataSource\Thread;
use Atlas\Mapper\Mapper;
/**
* @method ThreadTable getTable()
* @method ThreadRelationships getRelationships()
* @method ThreadRecord|null fetchRecord($primaryVal, array $with = [])
* @method ThreadRecord|null fetchRecordBy(array $whereEquals, array $with = [])
* @method ThreadRecord[] fetchRecords(array $primaryVals, array $with = [])
* @method ThreadRecord[] fetchRecordsBy(array $whereEquals, array $with = [])
* @method ThreadRecordSet fetchRecordSet(array $primaryVals, array $with = [])
* @method ThreadRecordSet fetchRecordSetBy(array $whereEquals, array $with = [])
* @method ThreadSelect select(array $whereEquals = [])
* @method ThreadRecord newRecord(array $fields = [])
* @method ThreadRecord[] newRecords(array $fieldSets)
* @method ThreadRecordSet newRecordSet(array $records = [])
* @method ThreadRecord turnRowIntoRecord(Row $row, array $with = [])
* @method ThreadRecord[] turnRowsIntoRecords(array $rows, array $with = [])
*/
class Thread extends Mapper
{
}
23 / 53

Relationship Class

namespace App\DataSource\Thread;
use App\DataSource\Author\Author;
use App\DataSource\Reply\Reply;
use App\DataSource\Summary\Summary;
use App\DataSource\Tag\Tag;
use App\DataSource\Tagging\Tagging;
use Atlas\Mapper\MapperRelationships;
class ThreadRelationships extends MapperRelationships
{
protected function define()
{
$this->manyToOne('author', Author::CLASS);
$this->oneToOne('summary', Summary::CLASS);
$this->oneToMany('replies', Reply::CLASS);
$this->oneToMany('taggings', Tagging::CLASS);
$this->manyToMany('tags', Tag::CLASS, 'taggings');
}
}
24 / 53

Relationship Control

class FooRelationships extends MapperRelationships
{
protected function define()
{
// ON and WHERE
$this->oneToMany('bars', Bar::CLASS, [
'foo_col_1' => 'bar_col_1',
'foo_col_2' => 'bar_col_2',
])
->where('bar_col = ', 'baz');
// "polymorphic"
$this->manyToOneVariant('commentable', 'commentable_type')
->type('page', Page::CLASS, ['commentable_id' => 'page_id'])
->type('post', Post::CLASS, ['commentable_id' => 'post_id'])
->type('video', Video::CLASS, ['commentable_id' => 'video_id']);
// oneToOneBidi (bidirectional)
}
}
25 / 53

Atlas: Fetch, Update, Delete

use Atlas\Orm\Atlas;
/* instantiate */
$atlas = Atlas::new(
'mysql:dbname=testdb;host=localhost',
'username',
'password'
);
/* fetch */
$thread = $atlas->fetchRecord(Thread::CLASS, 1, [
'author',
'tags'
]);
/* update */
$thread->title = 'New Title';
$atlas->update($thread);
/* delete */
$atlas->delete($thread);
26 / 53

Create and Insert

/* a new author */
$author = $atlas->newRecord(Author::CLASS);
$author->first_name = 'Bolivar';
$author->last_name = 'Shagnasty';
echo $author->author_id; // null
$atlas->insert($author);
echo $author->author_id; // 1
/* a new thread by an existing author */
$author = $atlas->fetchRecord(Author::CLASS, 1);
$thread = $atlas->newRecord(Thread::CLASS);
$thread->title = 'My Frist Thraed';
$thread->author = $author; // related field object
echo $thread->author_id; // null
$atlas->insert($thread);
echo $thread->author_id; // 1
27 / 53

Full Record Persistence

$thread = $atlas->fetchRecord(
Thread::CLASS,
$thread_id,
[
'author',
'replies' => [
'author',
],
'tags',
]
);
$reply_author = $atlas->fetchRecord(
Author::CLASS,
$reply_author_id
);
$reply = $thread->replies->appendNew([
'body' => $reply_body,
'thread' => $thread,
'author' => $reply_author,
]);
$atlas->persist($thread);
28 / 53

Fetch Sets With Relationships

$threads = $atlas->fetchRecordSet(
Thread::CLASS,
[1, 2, 3, 4, 5]
[
'author',
'replies' => [
'author',
]
]
);
foreach ($threads as $thread) {
echo "{$thread->title} by {$thread->author->first_name} "
. "has " count($thread->replies) . " replies.";
}

Total of 4 queries (no N+1 trouble)

29 / 53

Powerful SELECT Functionality

$threads = $atlas
->select(Thread::CLASS)
->orderBy('date DESC')
->page(1)
->paging(10)
->with([
'author',
'replies' => function ($replies) {
$replies
->orderBy('date ASC')
->with(['author'])
->limit(10);
},
'tags'
])
->fetchRecordSet();
30 / 53

Direct SELECT Queries ...

$select = $atlas
->select(Foo::CLASS) // "from"
->columns(...)
->join(...)
->joinWith(...)
->where(...)
->groupBy(...)
->having(...)
->orderBy(...)
->limit(...)
->offset(...);
31 / 53

... And Non-Record Fetching

$select->fetchRow(); // Row object
$select->fetchRows(); // Array of Row objects
$select->fetchAll(); // Seq array of rows as assoc arrays
$select->fetchAssoc(); // Assoc array of rows as assoc arrays
$select->fetchColumn(); // Column as seq array
$select->fetchKeyPair(); // First 2 columns as key-value pairs
$select->fetchOne(); // First row as assoc array
$select->fetchValue(); // First col of first row
$select->yieldAll(); // Generator over fetchAll()
$select->yieldAssoc(); // Generator over fetchAssoc()
$select->yieldColumn(); // Generator over fetchColumn()
$select->yieldKeyPair(); // Generator over fetchKeyPair()
32 / 53

Access to Every Layer

$atlas
->mapper(Foo::CLASS) // Atlas\Mapper\Mapper
->getTable() // Atlas\Table\Table
->getReadConnection() // Atlas\Pdo\Connection
->getPdo(); // PDO
33 / 53

Persistence Behaviors

34 / 53

Lower-Level Table Events

  • SELECT

    • modifySelect()
    • modifySelectedRow()
  • INSERT/UPDATE/DELETE

    • modifyInsert()
    • beforeInsertRow()
    • modifyInsertRow()
    • afterInsertRow()
35 / 53

Modifying Rows Before Persistence

namespace App\DataSource\Thread;
use Atlas\Table\Row;
use Atlas\Table\TableEvents;
class ThreadTableEvents extends TableEvents
{
public function beforeInsertRow(Table $table, Row $row)
{
$row->created_at = date('Y-m-d H:i:s');
}
public function beforeUpdateRow(Table $table, Row $row)
{
$row->updated_at = date('Y-m-d H:i:s');
}
}
36 / 53

Modifying Queries During Persistence

namespace App\DataSource\Foo;
use Atlas\Query\Insert;
use Atlas\Query\Update;
use Atlas\Table\Row;
use Atlas\Table\TableEvents;
use Cryptor;
class FooTableEvents extends TableEvents
{
public function modifySelectedRow(Row $row)
{
$row->sensitive = Cryptor::decrypt($row->sensitive);
}
public function modifyInsertRow(Table $table, Row $row, Insert $insert)
{
$insert->column('sensitive', Cryptor::encrypt($row->sensitive));
}
public function modifyUpdateRow(Table $table, Row $row, Update $update)
{
$update->column('sensitive', Cryptor::encrypt($row->sensitive));
}
}
37 / 53

Transaction Strategies

  • AutoCommit (manual BEGIN/COMMIT/ROLLBACK)
  • AutoTransact (each Atlas write; fully automatic)
  • BeginOnWrite (manual COMMIT/ROLLBACK)
  • BeginOnRead (manual COMMIT/ROLLBACK)
38 / 53

Domain Modeling

39 / 53

Adding Domain Behaviors

  • Directly in persistence model
  • Compose persistence into domain
  • Map between persistence and domain
40 / 53

Adding Record Methods

namespace App\DataSource\Author;
use Atlas\Mapper\Record;
class AuthorRecord extends Record
{
public function getFullName()
{
return $this->first_name . ' ' . $this->last_name;
}
}
41 / 53

Adding RecordSet Methods

namespace App\DataSource\Author;
use Atlas\Mapper\RecordSet;
class AuthorRecordSet extends RecordSet
{
public function getAllNames()
{
$result = [];
foreach ($this as $author) {
$result[] = $author->getFullName();
}
return $result;
}
}
42 / 53

Richer Domain Models

namespace App\Domain\Conversation;
use Atlas\Orm\Atlas;
use App\DataSource\Thread\Thread;
use App\DataSource\Thread\ThreadRecord;
class ConversationRepository
{
protected $atlas;
public function __construct(Atlas $atlas)
{
$this->atlas = $atlas;
}
public function fetchConversation($id) : ConversationInterface
{
$record = $this->atlas->fetchRecord(Thread::CLASS, $id);
return $this->newConversation($record);
}
protected function newConversation(ThreadRecord $record) : ConversationInterface
{ /* ??? */ }
}
43 / 53

Conversation

namespace App\Domain\Conversation;
use DateTimeImmutable;
class ConversationInterface
{
public function getId() : int;
public function getTitle() : string;
public function getBody() : string;
public function getDatePublished() : DateTimeImmutable;
public function getAuthorName() : string;
public function getReplies() : array;
}
44 / 53

Compose Persistence Into Domain

namespace App\Domain\Conversation;
use App\DataSource\Thread\ThreadRecord;
class Conversation implements ConversationInterface
{
protected $record;
public function __construct(ThreadRecord $record)
{
$this->record = $record;
}
public function getId() : int
{
return $this->record->thread_id;
}
public function getTitle() : string
{
return $this->record->title;
}
45 / 53
public function getBody() : string
{
return $this->record->body;
}
public function getDatePublished() : DateTimeImmutable
{
return new DateTimeImmutable($this->record->created_at);
}
public function getAuthorName() : string
{
return $this->record->author->getFullName();
}
public function getReplies() : array
{
return $this->record->replies->getArrayCopy();
}
}
46 / 53
class ConversationRepository
{
// ...
protected function newConversation(ThreadRecord $record) : ConversationInterface
{
return new Conversation($record);
}
}
47 / 53

Map From Persistence To Domain

namespace App\Domain\Conversation;
use App\DataSource\Thread\ThreadRecord;
class Conversation implements ConversationInterface
{
protected $id;
protected $title;
protected $body;
protected $datePublished;
protected $authorName;
protected $replies;
48 / 53
public function __construct(
string $title,
string $body,
DateTimeImmutable $datePublished,
string $authorName,
array $replies,
int $id = null
) {
$this->title = $title;
$this->body = $body;
$this->datePublished = $datePublished;
$this->authorName = $authorName;
$this->replies = $replies;
$this->id = $id;
}
public function getId()
{
return $this->id;
}
public function getTitle()
{
return $this->title;
}
49 / 53
public function getBody()
{
return $this->body;
}
public function getDatePublished()
{
return $this->datePublished;
}
public function getAuthorName()
{
return $this->authorName;
}
public function getReplies()
{
return $this->replies;
}
}
50 / 53
class ConversationRepository
{
// ...
protected function newConversation(ThreadRecord $record) : ConversationInterface
{
return new Conversation(
$record->title,
$record->body,
new DateTimeImmutable($record->date_published),
$record->author->getFullName(),
$record->replies->getArrayCopy(),
$record->thread_id
);
}
}
51 / 53

Conclusion

  • Tabular data is hard to represent in OOP
  • Different data-source architectures and their tradeoffs
  • How Atlas meets in the middle as a persistence data mapper
  • Functionality available through persistence modeling
  • Refactoring from persistence model toward domain model
52 / 53

About Me

MLAPHP

  • 8 years USAF Intelligence
  • BASIC in 1983, PHP since 1999
  • Jr. Developer, VP Engineering
  • Aura, ZF, Relay, Radar
  • PSR-1, PSR-2, PSR-4
  • Action-Domain-Responder
  • MLAPHP
2 / 53
Paused

Help

Keyboard shortcuts

, , Pg Up, k Go to previous slide
, , Pg Dn, Space, j Go to next slide
Home Go to first slide
End Go to last slide
Number + Return Go to specific slide
b / m / f Toggle blackout / mirrored / fullscreen mode
c Clone slideshow
p Toggle presenter mode
t Restart the presentation timer
?, h Toggle this help
Esc Back to slideshow