class: title # Atlas ORM ## Doing The Heavy Lifting For Your Persistence Layer ![Atlas Logo](./images/atlas-128.png) ### Paul M. Jones ###
--- # About Me .right-column[![MLAPHP](./images/mlaphp.png)] - 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](http://pmjones.io/adr) - [MLAPHP](https://leanpub.com/mlaphp) --- # 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 --- # Data Source Architecture .right-column[![DDD](./images/poeaa.jpg)] - [Row Data Gateway](https://martinfowler.com/eaaCatalog/rowDataGateway.html): one table row, and persistence logic - [Active Record](https://martinfowler.com/eaaCatalog/activeRecord.html): one table row, persistence logic, and domain logic - [Table Data Gateway](https://martinfowler.com/eaaCatalog/tableDataGateway.html): all table rows, and persistence logic - [Data Mapper](https://martinfowler.com/eaaCatalog/dataMapper.html): persistence logic for a disconnected in-memory object --- # Domain Logic .right-column[![DDD](./images/ddd.jpg)] - 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"](https://en.wikipedia.org/wiki/Object-relational_impedance_mismatch) --- # Plain Old SQL? - Write queries by hand - Map to object by hand - Fine control, time consuming - Relationships difficult --- # 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) --- # 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 --- # 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 --- # 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 --- # 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 --- # Persistence Model, not Domain Model - Thanks, [Mehdi Khalili](https://www.mehdi-khalili.com/orm-anti-patterns-part-4-persistence-domain-model) - 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 --- # 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 --- class: center middle # Atlas From The Ground Up --- # Installation All-purpose: ```bash $ composer require atlas/orm ~3.0 $ composer require --dev atlas/cli ~2.0 ``` Symfony: ```bash $ composer require atlas/symfony ~1.0 ``` Slim: [cookbook](https://www.slimframework.com/docs/v3/cookbook/database-atlas.html) --- # Tables ```sql 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 ); ``` --- ```sql 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 Generator Skeleton Config: ```php [ 'mysql:dbname=testdb;host=localhost', 'username', 'password', ], 'namespace' => 'App\\DataSource', 'directory' => './src/App/DataSource', ]; ``` Skeleton Command: ```bash $ php ./vendor/bin/atlas-skeleton.php /path/to/skeleton-config.php ``` --- # 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 ``` --- # Table Class ```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'; ``` --- ```php 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, ], ]; ``` --- ```php 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; } ``` --- # Mapper Class ```php 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 { } ``` --- # Relationship Class ```php 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'); } } ``` --- # Relationship Control ```php 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) } } ``` --- # Atlas: Fetch, Update, Delete ```php 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); ``` --- # Create and Insert ```php /* 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 ``` --- # Full Record Persistence ```php $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); ``` --- # Fetch Sets With Relationships ```php $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) --- # Powerful SELECT Functionality ```php $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(); ``` --- # Direct SELECT Queries ... ```php $select = $atlas ->select(Foo::CLASS) // "from" ->columns(...) ->join(...) ->joinWith(...) ->where(...) ->groupBy(...) ->having(...) ->orderBy(...) ->limit(...) ->offset(...); ``` --- # ... And Non-Record Fetching ```php $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() ``` --- # Access to Every Layer ```php $atlas ->mapper(Foo::CLASS) // Atlas\Mapper\Mapper ->getTable() // Atlas\Table\Table ->getReadConnection() // Atlas\Pdo\Connection ->getPdo(); // PDO ``` --- class: middle center # Persistence Behaviors --- # Lower-Level Table Events - SELECT - `modifySelect()` - `modifySelectedRow()` - INSERT/UPDATE/DELETE - `modifyInsert()` - `beforeInsertRow()` - `modifyInsertRow()` - `afterInsertRow()` --- # Modifying Rows Before Persistence ```php 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'); } } ``` --- # Modifying Queries During Persistence ```php 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)); } } ``` --- # Transaction Strategies - AutoCommit (manual BEGIN/COMMIT/ROLLBACK) - AutoTransact (each Atlas write; fully automatic) - BeginOnWrite (manual COMMIT/ROLLBACK) - BeginOnRead (manual COMMIT/ROLLBACK) --- class: center middle # Domain Modeling --- # Adding Domain Behaviors - Directly in persistence model - Compose persistence into domain - Map between persistence and domain --- # Adding Record Methods ```php namespace App\DataSource\Author; use Atlas\Mapper\Record; class AuthorRecord extends Record { public function getFullName() { return $this->first_name . ' ' . $this->last_name; } } ``` --- # Adding RecordSet Methods ```php 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; } } ``` --- # Richer Domain Models ```php 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 { /* ??? */ } } ``` --- # Conversation ```php 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; } ``` --- # Compose Persistence Into Domain ```php 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; } ``` --- ```php 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(); } } ``` --- ```php class ConversationRepository { // ... protected function newConversation(ThreadRecord $record) : ConversationInterface { return new Conversation($record); } } ``` --- # Map From Persistence To Domain ```php 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; ``` --- ```php 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; } ``` --- ```php public function getBody() { return $this->body; } public function getDatePublished() { return $this->datePublished; } public function getAuthorName() { return $this->authorName; } public function getReplies() { return $this->replies; } } ``` --- ```php 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 ); } } ``` --- # 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 --- class: middle # Thanks! - Repo: https://github.com/atlasphp/Atlas.Orm - Docs: https://atlasphp.io - http://paul-m-jones.com