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
Row Data Gateway: one table row, and persistence logic
Active Record: one table row, persistence logic, and domain logic
Table Data Gateway: all table rows, and persistence logic
Data Mapper: persistence logic for a disconnected in-memory object
Domain "should not" be modeled on database structure
Keep domain objects separated from database connections
Converting between database and domain is very difficult
Write queries by hand
Map to object by hand
Fine control, time consuming
Relationships difficult
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)
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
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
All systems start simple
Some systems become complex
Can't tell in advance
Want a low-cost path in case of complexity
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
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
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
All-purpose:
$ composer require atlas/orm ~3.0$ composer require --dev atlas/cli ~2.0
Symfony:
$ composer require atlas/symfony ~1.0
Slim: cookbook
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);
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));
Skeleton Config:
<?phpreturn [ '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
└── 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
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';
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, ], ];
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;}
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{}
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'); }}
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) }}
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);
/* 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 objectecho $thread->author_id; // null$atlas->insert($thread);echo $thread->author_id; // 1
$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);
$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)
$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();
$select = $atlas ->select(Foo::CLASS) // "from" ->columns(...) ->join(...) ->joinWith(...) ->where(...) ->groupBy(...) ->having(...) ->orderBy(...) ->limit(...) ->offset(...);
$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()
$atlas ->mapper(Foo::CLASS) // Atlas\Mapper\Mapper ->getTable() // Atlas\Table\Table ->getReadConnection() // Atlas\Pdo\Connection ->getPdo(); // PDO
SELECT
modifySelect()
modifySelectedRow()
INSERT/UPDATE/DELETE
modifyInsert()
beforeInsertRow()
modifyInsertRow()
afterInsertRow()
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'); }}
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)); }}
namespace App\DataSource\Author;use Atlas\Mapper\Record;class AuthorRecord extends Record{ public function getFullName() { return $this->first_name . ' ' . $this->last_name; }}
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; }}
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 { /* ??? */ }}
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;}
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; }
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(); }}
class ConversationRepository{ // ... protected function newConversation(ThreadRecord $record) : ConversationInterface { return new Conversation($record); }}
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;
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; }
public function getBody() { return $this->body; } public function getDatePublished() { return $this->datePublished; } public function getAuthorName() { return $this->authorName; } public function getReplies() { return $this->replies; }}
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 ); }}
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 |