diff --git a/src/Database/Eloquent/FMEloquentBuilder.php b/src/Database/Eloquent/FMEloquentBuilder.php index 2904977..3fad885 100644 --- a/src/Database/Eloquent/FMEloquentBuilder.php +++ b/src/Database/Eloquent/FMEloquentBuilder.php @@ -7,6 +7,7 @@ use GearboxSolutions\EloquentFileMaker\Exceptions\FileMakerDataApiException; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Scope; use Illuminate\Pagination\Paginator; use Illuminate\Support\Arr; use Illuminate\Support\Collection; @@ -73,6 +74,7 @@ public function exists() throw $e; } } + // It didn't error, so we have something return true; } @@ -262,8 +264,8 @@ public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', /** * Compares a model's modified portal data and original portal data and returns portal data with only modified fields and recordIds * - * @param $array1 array The modified portal data - * @param $array2 array The model's original portal data + * @param $array1 array The modified portal data + * @param $array2 array The model's original portal data */ protected function getOnlyModifiedPortalFields($array1, $array2): array { @@ -288,4 +290,61 @@ protected function getOnlyModifiedPortalFields($array1, $array2): array return $result; } + + public function applyScopes() + { + $builder = parent::applyScopes(); + + $query = $builder->getQuery(); + + foreach ($query->wheres as $index => $find) { + if (! empty($find)) { + continue; + } + + unset($query->wheres[$index]); + } + + return $builder; + } + + /** + * Apply the given scope on the current builder instance. + * + * @return mixed + */ + protected function callScope(callable $scope, array $parameters = []) + { + array_unshift($parameters, $this); + + $query = $this->getQuery(); + + $result = $this; + + $scopeApplied = false; + + foreach ($query->wheres as $index => $find) { + if (($find['omit'] ?? 'false') === 'true') { + continue; + } + + $query->setFindRequestIndex($index); + + $result = $scope(...$parameters) ?? $this; + + $scopeApplied = true; + } + + if (! $scopeApplied) { + array_unshift($query->wheres, []); + + $query->setFindRequestIndex(0); + + $result = $scope(...$parameters) ?? $this; + } + + $query->resetFindRequestIndex(); + + return $result; + } } diff --git a/src/Database/Eloquent/FMModel.php b/src/Database/Eloquent/FMModel.php index b571846..dc4768b 100644 --- a/src/Database/Eloquent/FMModel.php +++ b/src/Database/Eloquent/FMModel.php @@ -18,9 +18,9 @@ abstract class FMModel extends Model { + use FMGuardsAttributes; use FMHasAttributes; use FMHasRelationships; - use FMGuardsAttributes; /** * Indicates if the model should be timestamped. diff --git a/src/Database/Query/FMBaseBuilder.php b/src/Database/Query/FMBaseBuilder.php index 9086da2..d9c6012 100644 --- a/src/Database/Query/FMBaseBuilder.php +++ b/src/Database/Query/FMBaseBuilder.php @@ -11,6 +11,7 @@ use Illuminate\Pagination\Paginator; use Illuminate\Support\Arr; use Illuminate\Support\Collection; +use Illuminate\Support\Str; use InvalidArgumentException; class FMBaseBuilder extends Builder @@ -102,6 +103,11 @@ class FMBaseBuilder extends Builder */ public array $offsetPortals = []; + /** + * @var int The index of the current request in the find request array + */ + protected int $currentFindRequestIndex = -1; + public const ASCEND = 'ascend'; public const DESCEND = 'descend'; @@ -138,26 +144,53 @@ class FMBaseBuilder extends Builder public $containerFile; + /** + * Array to track the whereIn clauses because FM processes WhereIns differently than other DB engines + * + * @var array + */ protected $whereIns = []; + /** + * Flag to be used to enforce that FileMaker Data API gives us an empty set instead of erroring or returning unexpected records + * + * @var bool = false + */ + protected $forceHighOffset = false; + + public function isForcingHighOffset() + { + return $this->forceHighOffset; + } + /** * Add a basic where clause to the query. */ public function where($column, $operator = null, $value = null, $boolean = 'and'): FMBaseBuilder { + $shouldBeOmit = false; + + if (Str::contains($boolean, 'not')) { + $shouldBeOmit = true; + $boolean = trim(str_replace('not', '', $boolean)); + } + // This is an "orWhere" type query, so add a find request and then work from there - if ($boolean === 'or') { + if ($boolean === 'or' || ($shouldBeOmit && ! $this->isCurrentFindAnOmit())) { $this->addFindRequest(); } + + if ($shouldBeOmit) { + $this->omit(); + } + // If the column is an array, we will assume it is an array of key-value pairs // and can add them each as a where clause. We will maintain the boolean we // received when the method was called and pass it into the nested where. // // If the first value is an array it means the second value is an omit for the whole request if (is_array($column)) { - foreach ($column as $eachColumn => $eachValue) { - $this->where($eachColumn, $eachValue); - } + $this->addArrayOfWheres($column, $boolean); return $this; } @@ -169,20 +202,25 @@ public function where($column, $operator = null, $value = null, $boolean = 'and' $value, $operator, func_num_args() === 2 ); - // we should add this where clause as an AND to the current find request - // This allows us to chain wheres - // Create a new find array if null - $count = count($this->wheres); - if ($count == 0) { - $currentFind = []; - } else { - $currentFind = $this->wheres[count($this->wheres) - 1]; - } + $currentFind = $this->getCurrentFind(); $currentFind[$this->getMappedFieldName($column)] = $operator . $value; // add the where clause KvP to the last item in the array of wheres - $this->wheres[$count > 1 ? $count - 1 : 0] = $currentFind; + $this->updateCurrentFind($currentFind); + + return $this; + } + + protected function addArrayOfWheres($column, $boolean, $method = 'where') + { + foreach ($column as $key => $value) { + if (is_numeric($key) && is_array($value)) { + $this->{$method}(...array_values($value)); + } else { + $this->{$method}($key, '', $value, 'and'); + } + } return $this; } @@ -218,6 +256,7 @@ public function delete($id = null): int throw $e; } } + // we deleted the record, return modified count of 1 return 1; } @@ -247,6 +286,7 @@ protected function bulkDeleteFromQuery(): int } } } + // Return the count of deleted records return $deleteCount; } @@ -271,6 +311,7 @@ public function deleteByRecordId(int $recordId): int throw $e; } } + // we deleted the record, return modified count of 1 return 1; } @@ -403,7 +444,7 @@ public function scriptPresortParam(string $param): FMBaseBuilder return $this; } - public function scriptPrerequest(string $scriptName, string $param = null): FMBaseBuilder + public function scriptPrerequest(string $scriptName, ?string $param = null): FMBaseBuilder { $this->scriptPrerequest = $scriptName; @@ -489,7 +530,7 @@ protected function getMappedFieldName(string $column) /** * A helper function to map an entire array of fields and data to their FileMaker field names * - * @param $array array An array of columns and their values + * @param $array array An array of columns and their values */ protected function mapFieldNamesForArray(array $array): array { @@ -522,80 +563,160 @@ public function setFieldMapping($fieldMapping): void */ public function omit($boolean = true): FMBaseBuilder { - $count = count($this->wheres); - if ($count == 0) { - $currentFind = []; - $count = 1; - } else { - $currentFind = $this->wheres[count($this->wheres) - 1]; - } + $currentFind = $this->getCurrentFind(); $currentFind['omit'] = $boolean ? 'true' : 'false'; - $this->wheres[$count - 1] = $currentFind; + $this->updateCurrentFind($currentFind); return $this; } + /** + * we should add this where clause as an AND to the current find request + * This allows us to chain wheres + * Create a new find array if null + */ + protected function getCurrentFind() + { + if ($this->currentFindRequestIndex === -1) { + $this->addFindRequest(); + } + + return $this->wheres[$this->currentFindRequestIndex]; + } + + protected function isCurrentFindAnOmit() + { + if ($this->currentFindRequestIndex === -1) { + return false; + } + + return Arr::get($this->wheres, "{$this->currentFindRequestIndex}.omit", 'false') === 'true'; + } + + protected function updateCurrentFind($find) + { + $this->wheres[$this->currentFindRequestIndex] = $find; + } + public function whereIn($column, $values, $boolean = 'and', $not = false) { - throw_if($boolean === 'or', new \RuntimeException('Eloquent FileMaker does not currently support or within a where in')); + if ($boolean === 'or' || $not) { + $this->addFindRequest(); + + if ($not) { + $this->omit(); + } + } if ($values instanceof Arrayable) { $values = $values->toArray(); } + // We don't need the current find request but in the case that 0 finds are already performed, + // this will create the first one. + $this->getCurrentFind(); + $this->whereIns[] = [ 'column' => $this->getMappedFieldName($column), 'values' => $values, 'boolean' => $boolean, 'not' => $not, + 'findRequestIndex' => $this->currentFindRequestIndex, ]; return $this; } - protected function computeWhereIns() + public function computeWhereIns() { // If no where in clauses return if (empty($this->whereIns)) { - return; + return $this; } - $whereIns = array_map(function ($whereIn) { + $whereInRequests = collect($this->whereIns)->mapToGroups(function ($whereIn) { $finds = []; - foreach ($whereIn['values'] as $value) { - $find = [ - $whereIn['column'] => $value, - ]; + // If the list of values in a whereIn clause is empty we want the end query to return an empty set instead of other records. + if (empty($whereIn['values'])) { + $this->forceHighOffset = true; - if ($whereIn['not']) { - $find['omit'] = true; + if ($this->isWheresEmpty()) { + $finds[] = [ + $whereIn['column'] => '=', + ]; } + } else { + foreach ($whereIn['values'] as $value) { + $find = [ + $whereIn['column'] => $value, + ]; + + if ($whereIn['not']) { + $find['omit'] = true; + } - $finds[] = $find; + $finds[] = $find; + } } - return $finds; - }, $this->whereIns); + return [$whereIn['findRequestIndex'] => $finds]; + }); - if (empty($this->wheres)) { - $this->wheres = Arr::flatten($whereIns, 1); + if ($this->isWheresEmpty()) { + $this->wheres = $whereInRequests->map(function ($whereInRequest) { + return Arr::crossJoin(...$whereInRequest->values()); + })->map(function ($findRequest) { + return array_map(function ($clauses) { + return array_merge(...$clauses); + }, $findRequest); + })->flatten(1)->toArray(); - return; + return $this; } - $arr = Arr::crossJoin($this->wheres, ...$whereIns); - $function = function ($conditions) { - return array_merge(...array_values($conditions)); - }; + $newWheres = collect([]); + + // loop through each where + // If it is an omit, skip it + // If the where in is an omit, skip it + foreach ($this->wheres as $index => $where) { + $whereInRequest = $whereInRequests->get($index) ?? []; + + if (($where['omit'] ?? 'false') === 'true') { + if (count(array_keys($where)) > 1 || (count(array_keys($where)) === 1 && (collect($whereInRequest)->value('omit') ?? 'false') === 'false')) { + $newWheres->push($where); - if (empty($arr)) { - return; + continue; + } + } + + if (empty($whereInRequest)) { + $newWheres->push($where); + } else { + $newWheres = $newWheres->push(...Arr::crossJoin([$where], ...$whereInRequest->values())); + } } - $this->wheres = array_map($function, $arr); + $this->wheres = $newWheres + ->map(function ($findRequest) { + if (Arr::isAssoc($findRequest)) { + return $findRequest; + } + + return array_merge(...$findRequest); + })->toArray(); + + return $this; + } + + public function getWheres() + { + $this->computeWhereIns(); + + return $this->wheres; } /** @@ -654,7 +775,7 @@ public function createRecord() /** * Set the field data to be used when creating or editing a record * - * @param $array array + * @param $array array * @return $this */ public function fieldData(array $array) @@ -667,7 +788,7 @@ public function fieldData(array $array) /** * Set the portal data to be used when creating or updating a record * - * @param $array array + * @param $array array * @return $this */ public function portalData(array $array) @@ -678,8 +799,8 @@ public function portalData(array $array) } /** - * @param string $column The name of the container field - * @param File | UploadedFile | array $file The file to be uploaded to the container or a file and filename array ex: [$file, 'MyFile.pdf'] + * @param string $column The name of the container field + * @param File | UploadedFile | array $file The file to be uploaded to the container or a file and filename array ex: [$file, 'MyFile.pdf'] * @return mixed */ public function setContainer($column, $file) @@ -775,6 +896,8 @@ public function whereNull($columns, $boolean = 'and', $not = false) protected function addFindRequest() { array_push($this->wheres, []); + + $this->setFindRequestIndex(count($this->wheres) - 1); } /** @@ -915,4 +1038,25 @@ public function whereDate($column, $operator, $value = null, $boolean = 'and') return $this->where($column, $operator, $value, $boolean); } + + protected function isWheresEmpty() + { + $wheres = collect($this->wheres); + + if ($wheres->isEmpty()) { + return true; + } + + return collect($wheres->first())->keys()->except(['omit'])->isEmpty(); + } + + public function setFindRequestIndex($index) + { + $this->currentFindRequestIndex = $index; + } + + public function resetFindRequestIndex() + { + $this->currentFindRequestIndex = -1; + } } diff --git a/src/Services/FileMakerConnection.php b/src/Services/FileMakerConnection.php index 3a9738a..dfb2361 100644 --- a/src/Services/FileMakerConnection.php +++ b/src/Services/FileMakerConnection.php @@ -11,6 +11,7 @@ use GuzzleHttp\Middleware; use Illuminate\Database\Connection; use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; @@ -35,6 +36,12 @@ class FileMakerConnection extends Connection protected $retries = 1; + /** + * Crazy high number of records to return. + * Used to get an empty set when using a whereIn with no values. + */ + public const CRAZY_RECORDS_AMOUNT = 1000000000000000000; + /** * @param string $layout * @return $this @@ -214,14 +221,18 @@ public function performFind(FMBaseBuilder $query) // If limit hasn't been specified we should set it to be very high to bypass FM's default 100-record limit // This more closely matches Laravel's default behavior if (! isset($query->limit)) { - $query->limit = 1000000000000000000; + $query->limit = self::CRAZY_RECORDS_AMOUNT; } // if there are no query parameters we need to do a get all records instead of a find - if (empty($query->wheres)) { + if (empty($query->wheres) && ! $query->isForcingHighOffset()) { return $this->getRecords($query); } + // Update the offset to a crazy high offset when the query is forcing 0 records to be returned + // The records call requires that at least 1 + $query->offset($query->isForcingHighOffset() ? self::CRAZY_RECORDS_AMOUNT : $query->offset); + // There are actually query parameters, so prepare to do our find $this->setLayout($query->from); $url = $this->getLayoutUrl() . '/_find'; @@ -439,7 +450,7 @@ public function createRecord(FMBaseBuilder $query) return $response; } - protected function buildPostDataFromQuery(FMBaseBuilder $query) + public function buildPostDataFromQuery(FMBaseBuilder $query) { $postData = []; @@ -543,7 +554,7 @@ protected function isContainer($field) } // 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 + // it's a container if the first object in the array is a file if (is_array($field) && count($field) === 2 && $this->isFile($field[0])) { return true; } @@ -551,6 +562,12 @@ protected function isContainer($field) return false; } + protected function isFile($object) + { + return is_a($object, \Illuminate\Http\File::class) || + is_a($object, UploadedFile::class); + } + public function executeScript(FMBaseBuilder $query) { $this->setLayout($query->from); diff --git a/src/Support/Facades/FM.php b/src/Support/Facades/FM.php index cd20eae..e5b11ed 100644 --- a/src/Support/Facades/FM.php +++ b/src/Support/Facades/FM.php @@ -14,13 +14,10 @@ * @method static FMBaseBuilder table($layoutName) * @method static FMBaseBuilder delete($recordId) * @method static FMBaseBuilder deleteByRecordId($recordId) - - * @method static array setGlobalFields(array $globalFields) * @method static FileMakerConnection connection(string $name = null) * @method static FileMakerConnection setRetries(int $retries) * @method static FileMakerConnection getLayoutMetadata($layoutName = null) - * * @see \Illuminate\Database\DatabaseManager * @see FileMakerConnection