-
Notifications
You must be signed in to change notification settings - Fork 19
/
Copy pathFMModel.php
509 lines (423 loc) · 15.8 KB
/
FMModel.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
<?php
namespace GearboxSolutions\EloquentFileMaker\Database\Eloquent;
use GearboxSolutions\EloquentFileMaker\Database\Eloquent\Concerns\FMGuardsAttributes;
use GearboxSolutions\EloquentFileMaker\Database\Eloquent\Concerns\FMHasAttributes;
use GearboxSolutions\EloquentFileMaker\Database\Eloquent\Concerns\FMHasRelationships;
use GearboxSolutions\EloquentFileMaker\Database\Query\FMBaseBuilder;
use GearboxSolutions\EloquentFileMaker\Exceptions\FileMakerDataApiException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Concerns\AsPivot;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Http\File;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection as BaseCollection;
use Illuminate\Support\Str;
abstract class FMModel extends Model
{
use FMGuardsAttributes;
use FMHasAttributes;
use FMHasRelationships;
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = false;
/**
* FileMaker fields which should be renamed for the purposes of working in this Laravel app. This is useful when
* FileMaker fields have be inconveniently named.
*
* @var array
*/
protected $fieldMapping = [];
/**
* Fields which should not be attempted to be written back to FileMaker. This might be IDs, timestamps, summaries,
* or calculation fields.
*
* @var string[]
*/
protected $readOnlyFields = [
];
/**
* The layout to be used when retrieving this model. This is equivalent to the standard laravel $table property and
* either one can be used.
*/
protected $layout;
/**
* The internal FileMaker record ID. This is not the primary key of the record used in relationships. This field is
* automatically updated when records are retrieved or saved.
*/
protected $recordId;
/**
* The internal FileMaker ModId which keeps track of the modification number of a particular FileMaker record. This
* value is automatically set when records are retrieved or saved.
*/
protected $modId;
/**
* The "type" of the primary key ID. FileMaker uses UUID strings by default.
*
* @var string
*/
protected $keyType = 'string';
/**
* The date format to use when writing to the database.
*
* @var string
*/
protected $dateFormat = 'm/j/Y H:i:s';
public function __construct(array $attributes = [])
{
// Laravel uses tables normally, but FileMaker users layouts, so we'll let users set either one for clarity
// Set table if the user didn't set it and set $layout instead
if (! $this->table) {
$this->setTable($this->layout);
}
parent::__construct($attributes);
}
public static function all($columns = ['*'])
{
return static::query()->limit(1000000000000000000)->get(
is_array($columns) ? $columns : func_get_args()
);
}
/**
* Create a model object from the returned FileMaker data
*
* @param array $record
* @return FMModel
*/
public static function createFromRecord($record)
{
// create a new static instance for the class or child class
$instance = new static;
// just get the field data to make it easier to work with
$fieldData = $record['fieldData'];
$portalData = $record['portalData'];
$fieldMapping = $instance->getFieldMapping();
// Only do field mapping if one has been defined
if (! empty($fieldMapping)) {
// Fill the attributes from fieldMapping with the fieldData retrieved from FileMaker
$fieldData = collect($fieldData)->mapWithKeys(function ($value, $key) use ($fieldMapping) {
return [$fieldMapping[$key] ?? $key => $value];
})->toArray();
}
// check our config to see if we should map empty strings to null - users may decide they don't want this
$emptyStringToNull = $instance->getConnection()->getConfig()['empty_strings_to_null'] ?? true;
if ($emptyStringToNull) {
// map each value to null if it's an empty string
$fieldData = collect($fieldData)->map(function ($value) {
return $value === '' ? null : $value;
})->toArray();
}
// fill in the field data and portal data we've mapped and retrieved
$combinedAttributes = [...$fieldData, ...$portalData];
$instance->setRawAttributes($combinedAttributes, true);
$recordId = $record['recordId'];
$modId = $record['modId'];
$instance->setRecordId($recordId);
$instance->setModId($modId);
$instance->exists = true;
// Sync the original data array so we know if it's been modified
$instance->syncOriginal();
return $instance;
}
public static function createModelsFromRecordSet(BaseCollection $records): Collection
{
// return an empty Eloquent/Collection (or the custom collection specified in the model) if an empty collection was
// passed in.
if ($records->count() === 0) {
return (new static)->newCollection();
}
// Records passed in weren't empty, so process the records
$mappedRecords = $records->map(function ($record) {
return static::createFromRecord($record);
});
// return the filled Eloquent/Collection (or the custom collection specified in the model)
return (new static)->newCollection($mappedRecords->all());
}
/** Fill in data for this existing model with record data from FileMaker
* @return FMModel
*/
public function fillFromRecord($record)
{
// just get the field data to make it easier to work with
$fieldData = $record['fieldData'];
$portalData = $record['portalData'];
$fieldMapping = $this->getFieldMapping();
// Only do field mapping if one has been defined
if (! empty($fieldMapping)) {
// Fill the attributes from fieldMapping with the fieldData retrieved from FileMaker
$fieldData = collect($fieldData)->mapWithKeys(function ($value, $key) use ($fieldMapping) {
return [$fieldMapping[$key] ?? $key => $value];
})->toArray();
}
// fill in the field data we've mapped and retrieved
tap($this)->forceFill($fieldData);
// fill in the portal data we've retrieved
tap($this)->forceFill($portalData);
// Sync the original data array so we know if it's been modified
$this->syncOriginal();
return $this;
}
public function getRecordId()
{
return $this->recordId;
}
public function setRecordId($recordId)
{
$this->recordId = $recordId;
}
/**
* @return int|null
*/
public function getModId()
{
return $this->modId;
}
/**
* @param int $modId
*/
public function setModId($modId): void
{
$this->modId = $modId;
}
public function getReadOnlyFields()
{
return $this->readOnlyFields;
}
/**
* @return array|null
*/
public function getFieldMapping()
{
return $this->fieldMapping;
}
public function duplicate()
{
// Check to make sure this model exists before attempting to duplicate
if ($this->getRecordId() === null) {
// This doesn't exist yet, so exit here
return false;
}
// model events for duplicating, like create/update
if ($this->fireModelEvent('duplicating') === false) {
return false;
}
$response = $this->newQuery()->duplicate();
// Get the newly created recordId and return it
$newRecordId = $response['response']['recordId'];
$this->fireModelEvent('duplicated', false);
return $newRecordId;
}
/**
* @return string
*/
public function getLayout()
{
return $this->getTable();
}
/**
* @param mixed $layout
*/
public function setLayout($layout): void
{
$this->setTable($layout);
}
/**
* Create a new Eloquent query builder for the model.
*
* @param FMBaseBuilder $query
* @return FMEloquentBuilder
*/
public function newEloquentBuilder($query)
{
return new FMEloquentBuilder($query);
}
protected function performUpdate(Builder $query)
{
// If the updating event returns false, we will cancel the update operation so
// developers can hook Validation systems into their models and cancel this
// operation if the model does not pass validation. Otherwise, we update.
if ($this->fireModelEvent('updating') === false) {
return false;
}
// First we need to create a fresh query instance and touch the creation and
// update timestamp on the model which are maintained by us for developer
// convenience. Then we will just continue saving the model instances.
if ($this->usesTimestamps()) {
$this->updateTimestamps();
}
// Once we have run the update operation, we will fire the "updated" event for
// this model instance. This will allow developers to hook into these after
// models are updated, giving them a chance to do any special processing.
$dirty = $this->getDirty();
if (count($dirty) > 0) {
try {
$query->editRecord();
} catch (FileMakerDataApiException $e) {
// attempting to update and not actually modifying a record just returns a 0 by default to show no records were modified
// If we don't actually modify anything it isn't considered an error in Laravel and we just continue
if ($e->getCode() !== 101) {
// There was some error other than record missing, so throw it
throw $e;
}
}
$this->syncChanges();
$this->fireModelEvent('updated', false);
}
return true;
}
/**
* Set the keys for a save update query.
*
* @param Builder $query
* @return Builder
*/
protected function setKeysForSaveQuery($query)
{
$query->toBase()->recordId($this->recordId);
return $query;
}
/**
* Perform a model insert operation.
*
* @return bool
*/
protected function performInsert(Builder $query)
{
if ($this->fireModelEvent('creating') === false) {
return false;
}
// First we'll need to create a fresh query instance and touch the creation and
// update timestamps on this model, which are maintained by us for developer
// convenience. After, we will just continue saving these model instances.
if ($this->usesTimestamps()) {
$this->updateTimestamps();
}
// If the model has an incrementing key, we can use the "insertGetId" method on
// the query builder, which will give us back the final inserted ID for this
// table from the database. Not all tables have to be incrementing though.
$attributes = $this->getAttributesForInsert();
if ($this->getIncrementing()) {
$query->createRecord();
// perform a refresh after the insert to get the generated primary key / ID and calculated data
$this->setRawAttributes(
$this->findByRecordId($this->recordId)->attributes
);
}
// If the table isn't incrementing we'll simply insert these attributes as they
// are. These attribute arrays must contain an "id" column previously placed
// there by the developer as the manually determined key for these models.
else {
if (empty($attributes)) {
return true;
}
$query->createRecord();
}
// We will go ahead and set the exists property to true, so that it is set when
// the created event is fired, just in case the developer tries to update it
// during the event. This will allow them to do so and run an update here.
$this->exists = true;
$this->wasRecentlyCreated = true;
$this->fireModelEvent('created', false);
return true;
}
/**
* Strip out containers and read-only fields to prepare for a write query
*
* @return BaseCollection
*/
public function getAttributesForFileMakerWrite()
{
$fieldData = collect($this->getAttributes());
$fieldData = $fieldData->intersectByKeys($this->getDirty());
// Remove any fields which have been marked as read-only so we don't try to write and cause an error
$fieldData->forget($this->getReadOnlyFields());
// Remove any fields which have been set to write a file, as they should be handled as containers
foreach ($fieldData as $key => $field) {
// remove any containers to be written.
// users can set the field to be a File, UploadFile, or array [$file, 'MyFile.pdf']
if ($this->isContainer($field)) {
$fieldData->forget($key);
}
}
return $fieldData;
}
public function getContainersToWrite()
{
// get dirty fields
$fieldData = collect($this->getAttributes());
$fieldData = $fieldData->intersectByKeys($this->getDirty());
$containers = collect([]);
// Track any fields which have been set to write a file, as they should be handled as containers
foreach ($fieldData as $key => $field) {
// remove any containers to be written.
if ($this->isContainer($field)) {
$containers->push($key);
}
}
return $containers;
}
protected function isContainer($field)
{
// if this is a file then we know it's a container
if ($this->isFile($field)) {
return true;
}
// if it's an array, it could be a file => filename key-value pair.
// it's a conainer if the first object in the array is a file
if (is_array($field) && count($field) === 2 && $this->isFile($field[0])) {
return true;
}
return false;
}
protected function isFile($object)
{
return is_a($object, File::class) ||
is_a($object, UploadedFile::class);
}
/**
* Get the table associated with the model.
*
* @return string
*/
public function getTable()
{
return $this->table ?? $this->layout ?? Str::snake(Str::pluralStudly(class_basename($this)));
}
/**
* Qualify the given column name by the model's table.
*
* @param string $column
* @return string
*/
public function qualifyColumn($column)
{
// we shouldn't ever qualify columns because they could be related data
// so just return without the table
return $column;
}
/**
* Reload the current model instance with fresh attributes from the database.
*
* @return $this
*/
public function refresh()
{
// make sure we have a FileMaker internal recordId
if ($this->recordId === null) {
return $this;
}
$this->setRawAttributes(
$this->findByRecordId($this->recordId)->attributes
);
$this->load(collect($this->relations)->reject(function ($relation) {
return $relation instanceof Pivot
|| (is_object($relation) && in_array(AsPivot::class, class_uses_recursive($relation), true));
})->keys()->all());
$this->syncOriginal();
return $this;
}
}