Maintain local ⭤ remote in sync with automatic AT Protocol parity for Laravel (alpha & unstable)
at main 375 lines 8.9 kB view raw view rendered
1# Record Mappers 2 3Mappers are the core of atp-parity. They define bidirectional transformations between AT Protocol record DTOs and Eloquent models. 4 5## Creating a Mapper 6 7Extend the `RecordMapper` abstract class and implement the required methods: 8 9```php 10<?php 11 12namespace App\AtpMappers; 13 14use App\Models\Post; 15use Illuminate\Database\Eloquent\Model; 16use SocialDept\AtpParity\RecordMapper; 17use SocialDept\AtpSchema\Data\Data; 18use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post as PostRecord; 19 20/** 21 * @extends RecordMapper<PostRecord, Post> 22 */ 23class PostMapper extends RecordMapper 24{ 25 /** 26 * The AT Protocol record class this mapper handles. 27 */ 28 public function recordClass(): string 29 { 30 return PostRecord::class; 31 } 32 33 /** 34 * The Eloquent model class this mapper handles. 35 */ 36 public function modelClass(): string 37 { 38 return Post::class; 39 } 40 41 /** 42 * Transform a record DTO into model attributes. 43 */ 44 protected function recordToAttributes(Data $record): array 45 { 46 /** @var PostRecord $record */ 47 return [ 48 'content' => $record->text, 49 'published_at' => $record->createdAt, 50 'langs' => $record->langs, 51 'facets' => $record->facets, 52 ]; 53 } 54 55 /** 56 * Transform a model into record data for creating/updating. 57 */ 58 protected function modelToRecordData(Model $model): array 59 { 60 /** @var Post $model */ 61 return [ 62 'text' => $model->content, 63 'createdAt' => $model->published_at->toIso8601String(), 64 'langs' => $model->langs ?? ['en'], 65 ]; 66 } 67} 68``` 69 70## Required Methods 71 72### `recordClass(): string` 73 74Returns the fully qualified class name of the AT Protocol record DTO. This can be: 75 76- A generated class from atp-schema (e.g., `SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post`) 77- A custom class extending `SocialDept\AtpParity\Data\Record` 78 79### `modelClass(): string` 80 81Returns the fully qualified class name of the Eloquent model. 82 83### `recordToAttributes(Data $record): array` 84 85Transforms an AT Protocol record into an array of Eloquent model attributes. This is used when: 86 87- Creating a new model from a remote record 88- Updating an existing model from a remote record 89 90### `modelToRecordData(Model $model): array` 91 92Transforms an Eloquent model into an array suitable for creating an AT Protocol record. This is used when: 93 94- Publishing a local model to the AT Protocol network 95- Comparing local and remote state 96 97## Inherited Methods 98 99The abstract `RecordMapper` class provides these methods: 100 101### `lexicon(): string` 102 103Returns the lexicon NSID (e.g., `app.bsky.feed.post`). Automatically derived from the record class's `getLexicon()` method. 104 105### `toModel(Data $record, array $meta = []): Model` 106 107Creates a new (unsaved) model instance from a record DTO. 108 109```php 110$record = PostRecord::fromArray($data); 111$model = $mapper->toModel($record, [ 112 'uri' => 'at://did:plc:xxx/app.bsky.feed.post/abc123', 113 'cid' => 'bafyre...', 114]); 115``` 116 117### `toRecord(Model $model): Data` 118 119Converts a model back to a record DTO. 120 121```php 122$record = $mapper->toRecord($post); 123// Use $record->toArray() to get data for API calls 124``` 125 126### `updateModel(Model $model, Data $record, array $meta = []): Model` 127 128Updates an existing model with data from a record. Does not save the model. 129 130```php 131$mapper->updateModel($existingPost, $record, ['cid' => $newCid]); 132$existingPost->save(); 133``` 134 135### `findByUri(string $uri): ?Model` 136 137Finds a model by its AT Protocol URI. 138 139```php 140$post = $mapper->findByUri('at://did:plc:xxx/app.bsky.feed.post/abc123'); 141``` 142 143### `upsert(Data $record, array $meta = []): Model` 144 145Creates or updates a model based on the URI. This is the primary method used for syncing. 146 147```php 148$post = $mapper->upsert($record, [ 149 'uri' => $uri, 150 'cid' => $cid, 151]); 152``` 153 154### `deleteByUri(string $uri): bool` 155 156Deletes a model by its AT Protocol URI. 157 158```php 159$deleted = $mapper->deleteByUri('at://did:plc:xxx/app.bsky.feed.post/abc123'); 160``` 161 162## Meta Fields 163 164The `$meta` array passed to `toModel`, `updateModel`, and `upsert` can contain: 165 166| Key | Description | 167|-----|-------------| 168| `uri` | The AT Protocol URI (e.g., `at://did:plc:xxx/app.bsky.feed.post/abc123`) | 169| `cid` | The content identifier hash | 170 171These are automatically mapped to your configured column names (default: `atp_uri`, `atp_cid`). 172 173## Customizing Column Names 174 175Override the column methods to use different database columns: 176 177```php 178class PostMapper extends RecordMapper 179{ 180 protected function uriColumn(): string 181 { 182 return 'at_uri'; // Instead of default 'atp_uri' 183 } 184 185 protected function cidColumn(): string 186 { 187 return 'at_cid'; // Instead of default 'atp_cid' 188 } 189 190 // ... other methods 191} 192``` 193 194Or configure globally in `config/parity.php`: 195 196```php 197'columns' => [ 198 'uri' => 'at_uri', 199 'cid' => 'at_cid', 200], 201``` 202 203## Registering Mappers 204 205### Via Configuration 206 207Add your mapper classes to `config/parity.php`: 208 209```php 210return [ 211 'mappers' => [ 212 App\AtpMappers\PostMapper::class, 213 App\AtpMappers\ProfileMapper::class, 214 App\AtpMappers\LikeMapper::class, 215 ], 216]; 217``` 218 219### Programmatically 220 221Register mappers at runtime via the `MapperRegistry`: 222 223```php 224use SocialDept\AtpParity\MapperRegistry; 225 226$registry = app(MapperRegistry::class); 227$registry->register(new PostMapper()); 228``` 229 230## Using the Registry 231 232The `MapperRegistry` provides lookup methods: 233 234```php 235use SocialDept\AtpParity\MapperRegistry; 236 237$registry = app(MapperRegistry::class); 238 239// Find mapper by record class 240$mapper = $registry->forRecord(PostRecord::class); 241 242// Find mapper by model class 243$mapper = $registry->forModel(Post::class); 244 245// Find mapper by lexicon NSID 246$mapper = $registry->forLexicon('app.bsky.feed.post'); 247 248// Get all registered lexicons 249$lexicons = $registry->lexicons(); 250// ['app.bsky.feed.post', 'app.bsky.actor.profile', ...] 251``` 252 253## SchemaMapper for Quick Setup 254 255For simple mappings, use `SchemaMapper` instead of creating a full class: 256 257```php 258use SocialDept\AtpParity\Support\SchemaMapper; 259use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Like; 260 261$mapper = new SchemaMapper( 262 schemaClass: Like::class, 263 modelClass: \App\Models\Like::class, 264 toAttributes: fn(Like $like) => [ 265 'subject_uri' => $like->subject->uri, 266 'subject_cid' => $like->subject->cid, 267 'liked_at' => $like->createdAt, 268 ], 269 toRecordData: fn($model) => [ 270 'subject' => [ 271 'uri' => $model->subject_uri, 272 'cid' => $model->subject_cid, 273 ], 274 'createdAt' => $model->liked_at->toIso8601String(), 275 ], 276); 277 278$registry->register($mapper); 279``` 280 281## Handling Complex Records 282 283### Embedded Objects 284 285AT Protocol records often contain embedded objects. Handle them in your mapping: 286 287```php 288protected function recordToAttributes(Data $record): array 289{ 290 /** @var PostRecord $record */ 291 $attributes = [ 292 'content' => $record->text, 293 'published_at' => $record->createdAt, 294 ]; 295 296 // Handle reply reference 297 if ($record->reply) { 298 $attributes['reply_to_uri'] = $record->reply->parent->uri; 299 $attributes['thread_root_uri'] = $record->reply->root->uri; 300 } 301 302 // Handle embed 303 if ($record->embed) { 304 $attributes['embed_type'] = $record->embed->getType(); 305 $attributes['embed_data'] = $record->embed->toArray(); 306 } 307 308 return $attributes; 309} 310``` 311 312### Facets (Rich Text) 313 314Posts with mentions, links, and hashtags have facets: 315 316```php 317protected function recordToAttributes(Data $record): array 318{ 319 /** @var PostRecord $record */ 320 return [ 321 'content' => $record->text, 322 'facets' => $record->facets, // Store as JSON 323 'published_at' => $record->createdAt, 324 ]; 325} 326 327protected function modelToRecordData(Model $model): array 328{ 329 /** @var Post $model */ 330 return [ 331 'text' => $model->content, 332 'facets' => $model->facets, // Restore from JSON 333 'createdAt' => $model->published_at->toIso8601String(), 334 ]; 335} 336``` 337 338## Multiple Mappers per Lexicon 339 340You can register multiple mappers for different model types: 341 342```php 343// Map posts to different models based on criteria 344class UserPostMapper extends RecordMapper 345{ 346 public function recordClass(): string 347 { 348 return PostRecord::class; 349 } 350 351 public function modelClass(): string 352 { 353 return UserPost::class; 354 } 355 356 // ... mapping logic for user's own posts 357} 358 359class FeedPostMapper extends RecordMapper 360{ 361 public function recordClass(): string 362 { 363 return PostRecord::class; 364 } 365 366 public function modelClass(): string 367 { 368 return FeedPost::class; 369 } 370 371 // ... mapping logic for feed posts 372} 373``` 374 375Note: The registry will return the first registered mapper for a given lexicon. Use explicit mapper instances when you need specific behavior.