Issues (3)

src/Eloquent/SpatialTrait.php (2 issues)

1
<?php
2
3
namespace LaravelSpatial\Eloquent;
4
5
use Exception;
6
use GeoJson\GeoJson;
7
use GeoJSON\Geometry\Geometry;
8
use geoPHP\geoPHP;
9
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
10
use Illuminate\Database\MySqlConnection;
11
use Illuminate\Database\PostgresConnection;
12
use LaravelSpatial\Exceptions\SpatialFieldsNotDefinedException;
13
use LaravelSpatial\Exceptions\SpatialParseException;
14
use LaravelSpatial\Exceptions\UnknownSpatialRelationFunction;
15
16
/**
17
 * Trait SpatialTrait.
18
 *
19
 * @property array $attributes
20
 * @method static static|EloquentBuilder distance($geometryColumn, $geometry, $distance)
21
 * @method static static|EloquentBuilder distanceValue($geometryColumn, $geometry)
22
 * @method static static|EloquentBuilder distanceExcludingSelf($geometryColumn, $geometry, $distance)
23
 * @method static static|EloquentBuilder distanceSphere($geometryColumn, $geometry, $distance)
24
 * @method static static|EloquentBuilder distanceSphereValue($geometryColumn, $geometry)
25
 * @method static static|EloquentBuilder distanceSphereExcludingSelf($geometryColumn, $geometry, $distance)
26
 * @method static static|EloquentBuilder comparison($geometryColumn, $geometry, $relationship)
27
 * @method static static|EloquentBuilder within($geometryColumn, $polygon)
28
 * @method static static|EloquentBuilder crosses($geometryColumn, $geometry)
29
 * @method static static|EloquentBuilder contains($geometryColumn, $geometry)
30
 * @method static static|EloquentBuilder disjoint($geometryColumn, $geometry)
31
 * @method static static|EloquentBuilder equals($geometryColumn, $geometry)
32
 * @method static static|EloquentBuilder intersects($geometryColumn, $geometry)
33
 * @method static static|EloquentBuilder overlaps($geometryColumn, $geometry)
34
 * @method static static|EloquentBuilder doesTouch($geometryColumn, $geometry)
35
 */
36
trait SpatialTrait
37
{
38
    /*
39
     * The attributes that are spatial representations.
40
     * To use this Trait, add the following array to the model class
41
     *
42
     * @var array
43
     *
44
     * protected $spatialFields = [];
45
     */
46
47
    /**
48
     * @var array
49
     */
50
    public $geometries = [];
51
52
    /**
53
     * @var array
54
     */
55
    protected $stRelations = [
56
        'Within',
57
        'Crosses',
58
        'Contains',
59
        'Disjoint',
60
        'Equals',
61
        'Intersects',
62
        'Overlaps',
63
        'Touches',
64
    ];
65
66
    /**
67
     * Create a new Eloquent query builder for the model.
68
     *
69
     * @param  \Illuminate\Database\Query\Builder $query
70
     *
71
     * @return \LaravelSpatial\Eloquent\Builder
72
     */
73 48
    public function newEloquentBuilder($query)
74
    {
75 48
        return new Builder($query);
76
    }
77
78
    /**
79
     * @inheritDoc
80
     */
81 11
    public function setRawAttributes(array $attributes, $sync = false)
82
    {
83 11
        $spatial_fields = $this->getSpatialFields();
84
85 11
        foreach ($attributes as $attribute => &$value) {
86 11
            if (is_string($value) && in_array($attribute, $spatial_fields, true) && strlen($value) >= 15) {
87 11
                $connection = $this->getConnection();
88
89
                // MySQL adds 4 NULL bytes at the start of the binary
90 11
                if ($connection instanceof MySqlConnection && strpos($value, "\0\0\0\0") === 0) {
91 5
                    $value = substr($value, 4);
92 6
                } elseif ($connection instanceof PostgresConnection) {
93 5
                    $value = pack('H*', $value);
94
                }
95
96
                try {
97 11
                    $value = GeoJson::jsonUnserialize(
98
                        json_decode(
99
                            geoPHP::load($value, 'wkb')
100
                                  ->out('json'),
101
                            false,
102
                            512,
103
                            JSON_THROW_ON_ERROR
104 11
                        )
105
                    );
106
                } catch (Exception $e) {
107
                    throw new SpatialParseException("Can't parse WKB {$value}: {$e->getMessage()}", $e->getCode(), $e);
108
                }
109
            }
110 50
        }
111
112 50
        return parent::setRawAttributes($attributes, $sync);
113 47
    }
114
115
    /**
116 3
     * @return array
117
     */
118
    public function getSpatialFields(): array
119
    {
120
        if (property_exists($this, 'spatialFields') && !empty($this->spatialFields)) {
121
            return $this->spatialFields;
122
        }
123
124 46
        throw new SpatialFieldsNotDefinedException(__CLASS__ . ' has to define $spatialFields');
125
    }
126
127 46
    /**
128
     * @param \GeoJSON\Geometry\Geometry $value
129
     *
130
     * @return string
131
     */
132 46
    protected function toWkt(Geometry $value): string
133
    {
134
        try {
135
            $decoded = json_decode(
136
                json_encode($value->jsonSerialize(), JSON_THROW_ON_ERROR),
137
                false,
138
                512,
139
                JSON_THROW_ON_ERROR
140 31
            );
141
            $wkt     = geoPHP::load($decoded, 'json')->out('wkt');
142 31
        } catch (Exception $e) {
143 31
            throw new SpatialParseException('Unable to data to geometry.', 0, $e);
144 29
        }
145 29
146
        return ($this->getConnection() instanceof PostgresConnection ? 'SRID=4326;' : '') . $wkt;
147
    }
148
149 29
    /**
150
     * @param \Illuminate\Database\Eloquent\Builder $query
151 29
     *
152 29
     * @return bool
153
     */
154
    protected function performInsert(EloquentBuilder $query)
155 29
    {
156
        foreach ($this->attributes as $key => $value) {
157
            if ($value instanceof Geometry && $this->isColumnAllowed($key)) {
158
                $this->geometries[$key] = $value; // Preserve the geometry objects prior to the insert
159
                $this->attributes[$key] = $this->getConnection()->raw("ST_GeomFromText('{$this->toWkt($value)}')");
160
            }
161
        }
162
163 48
        $insert = parent::performInsert($query);
164
165 48
        foreach ($this->geometries as $key => $value) {
166
            $this->attributes[$key] = $value; // Retrieve the geometry objects so they can be used in the model
167
        }
168
169 46
        return $insert; // Return the result of the parent insert
170
    }
171
172
    /**
173
     * @param $geometryColumn
174
     *
175
     * @return bool
176
     */
177
    public function isColumnAllowed($geometryColumn): bool
178
    {
179
        if (!in_array($geometryColumn, $this->getSpatialFields(), true)) {
180 4
            throw new SpatialFieldsNotDefinedException(sprintf('%s is not a valid spatial column.', $geometryColumn));
181
        }
182 4
183 4
        return true;
184 4
    }
185 4
186 4
    /**
187
     * @param \Illuminate\Database\Eloquent\Builder $query
188
     * @param $geometryColumn
189
     * @param \GeoJSON\Geometry\Geometry $geometry
190 4
     * @param $distance
191
     *
192
     * @return \Illuminate\Database\Eloquent\Builder
193
     */
194
    public function scopeDistance(EloquentBuilder $query, $geometryColumn, Geometry $geometry, $distance): EloquentBuilder
195
    {
196
        if ($this->isColumnAllowed($geometryColumn)) {
197
            $geometryColumn .= $this->getConnection() instanceof PostgresConnection ? '::geometry' : '';
198
            $query->whereRaw("ST_Distance({$geometryColumn}, ST_GeomFromText(?)) <= ?", [
199
                $this->toWkt($geometry),
200
                $distance,
201 3
            ]);
202
        }
203 3
204 3
        return $query;
205
    }
206 3
207 3
    /**
208 3
     * @param \Illuminate\Database\Eloquent\Builder $query
209
     * @param $geometryColumn
210
     * @param \GeoJSON\Geometry\Geometry $geometry
211
     * @param $distance
212 3
     *
213
     * @return \Illuminate\Database\Eloquent\Builder
214
     */
215
    public function scopeDistanceExcludingSelf(EloquentBuilder $query, $geometryColumn, Geometry $geometry, $distance): EloquentBuilder
216
    {
217
        if ($this->isColumnAllowed($geometryColumn)) {
218
            $query = $this->scopeDistance($query, $geometryColumn, $geometry, $distance);
219
220
            $geometryColumn .= $this->getConnection() instanceof PostgresConnection ? '::geometry' : '';
221
            $query->whereRaw("ST_Distance({$geometryColumn}, ST_GeomFromText(?)) != 0", [
222 4
                $this->toWkt($geometry),
223
            ]);
224 4
        }
225 4
226
        return $query;
227 4
    }
228 3
229
    /**
230
     * @param \Illuminate\Database\Eloquent\Builder $query
231 4
     * @param $geometryColumn
232 4
     * @param \GeoJSON\Geometry\Geometry $geometry
233 4
     *
234
     * @return \Illuminate\Database\Eloquent\Builder
235
     */
236
    public function scopeDistanceValue(EloquentBuilder $query, $geometryColumn, Geometry $geometry): EloquentBuilder
237 4
    {
238
        if ($this->isColumnAllowed($geometryColumn)) {
239
            $columns = $query->getQuery()->columns;
240
241
            if (!$columns) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $columns of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
242
                $query->select('*');
243
            }
244
245
            $geometryColumn .= $this->getConnection() instanceof PostgresConnection ? '::geometry' : '';
246
            $query->selectRaw("ST_Distance({$geometryColumn}, ST_GeomFromText(?)) as distance", [
247
                $this->toWkt($geometry),
248 4
            ]);
249
        }
250 4
251
        return $query;
252 4
    }
253 4
254 4
    /**
255 4
     * @param \Illuminate\Database\Eloquent\Builder $query
256 4
     * @param $geometryColumn
257
     * @param \GeoJSON\Geometry\Geometry $geometry
258
     * @param $distance
259
     *
260 4
     * @return \Illuminate\Database\Eloquent\Builder
261
     */
262
    public function scopeDistanceSphere(EloquentBuilder $query, $geometryColumn, Geometry $geometry, $distance): EloquentBuilder
263
    {
264
        $distFunc = $this->getConnection() instanceof PostgresConnection ? 'ST_DistanceSphere' : 'ST_Distance_Sphere';
265
266
        if ($this->isColumnAllowed($geometryColumn)) {
267
            $geometryColumn .= $this->getConnection() instanceof PostgresConnection ? '::geometry' : '';
268
            $query->whereRaw("{$distFunc}({$geometryColumn}, ST_GeomFromText(?)) <= ?", [
269
                $this->toWkt($geometry),
270
                $distance,
271 3
            ]);
272
        }
273 3
274
        return $query;
275 3
    }
276 3
277
    /**
278 3
     * @param \Illuminate\Database\Eloquent\Builder $query
279 3
     * @param $geometryColumn
280 3
     * @param \GeoJSON\Geometry\Geometry $geometry
281
     * @param $distance
282
     *
283
     * @return \Illuminate\Database\Eloquent\Builder
284 3
     */
285
    public function scopeDistanceSphereExcludingSelf(EloquentBuilder $query, $geometryColumn, Geometry $geometry, $distance): EloquentBuilder
286
    {
287
        $distFunc = $this->getConnection() instanceof PostgresConnection ? 'ST_DistanceSphere' : 'ST_Distance_Sphere';
288
289
        if ($this->isColumnAllowed($geometryColumn)) {
290
            $query = $this->scopeDistanceSphere($query, $geometryColumn, $geometry, $distance);
291
292
            $geometryColumn .= $this->getConnection() instanceof PostgresConnection ? '::geometry' : '';
293
            $query->whereRaw("{$distFunc}({$geometryColumn}, ST_GeomFromText(?)) != 0", [
294 4
                $this->toWkt($geometry),
295
            ]);
296 4
        }
297
298 4
        return $query;
299 4
    }
300
301 4
    /**
302 3
     * @param \Illuminate\Database\Eloquent\Builder $query
303
     * @param $geometryColumn
304
     * @param \GeoJSON\Geometry\Geometry $geometry
305 4
     *
306 4
     * @return \Illuminate\Database\Eloquent\Builder
307 4
     */
308
    public function scopeDistanceSphereValue(EloquentBuilder $query, $geometryColumn, Geometry $geometry): EloquentBuilder
309
    {
310
        $distFunc = $this->getConnection() instanceof PostgresConnection ? 'ST_DistanceSphere' : 'ST_Distance_Sphere';
311 4
312
        if ($this->isColumnAllowed($geometryColumn)) {
313
            $columns = $query->getQuery()->columns;
314
315
            if (!$columns) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $columns of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
316
                $query->select('*');
317
            }
318
319
            $geometryColumn .= $this->getConnection() instanceof PostgresConnection ? '::geometry' : '';
320
            $query->selectRaw("{$distFunc}({$geometryColumn}, ST_GeomFromText(?)) as distance", [
321
                $this->toWkt($geometry),
322 9
            ]);
323
        }
324 9
325 9
        return $query;
326
    }
327 9
328
    /**
329
     * @param \Illuminate\Database\Eloquent\Builder $query
330
     * @param $geometryColumn
331 9
     * @param \GeoJSON\Geometry\Geometry $geometry
332 9
     * @param $relationship
333 9
     *
334
     * @return \Illuminate\Database\Eloquent\Builder
335
     */
336
    public function scopeComparison(EloquentBuilder $query, $geometryColumn, Geometry $geometry, $relationship): EloquentBuilder
337 9
    {
338
        if ($this->isColumnAllowed($geometryColumn)) {
339
            $relationship = ucfirst(strtolower($relationship));
340
341
            if (!in_array($relationship, $this->stRelations, true)) {
342
                throw new UnknownSpatialRelationFunction($relationship);
343
            }
344
345
            $geometryColumn .= $this->getConnection() instanceof PostgresConnection ? '::geometry' : '';
346
            $query->whereRaw("ST_{$relationship}(`{$geometryColumn}`, ST_GeomFromText(?))", [
347 1
                $this->toWkt($geometry),
348
            ]);
349 1
        }
350
351
        return $query;
352
    }
353
354
    /**
355
     * @param \Illuminate\Database\Eloquent\Builder $query
356
     * @param $geometryColumn
357
     * @param $polygon
358
     *
359 1
     * @return \Illuminate\Database\Eloquent\Builder
360
     */
361 1
    public function scopeWithin(EloquentBuilder $query, $geometryColumn, Geometry $polygon): EloquentBuilder
362
    {
363
        return $this->scopeComparison($query, $geometryColumn, $polygon, 'within');
364
    }
365
366
    /**
367
     * @param \Illuminate\Database\Eloquent\Builder $query
368
     * @param $geometryColumn
369
     * @param \GeoJSON\Geometry\Geometry $geometry
370
     *
371 1
     * @return \Illuminate\Database\Eloquent\Builder
372
     */
373 1
    public function scopeCrosses(EloquentBuilder $query, $geometryColumn, Geometry $geometry): EloquentBuilder
374
    {
375
        return $this->scopeComparison($query, $geometryColumn, $geometry, 'crosses');
376
    }
377
378
    /**
379
     * @param \Illuminate\Database\Eloquent\Builder $query
380
     * @param $geometryColumn
381
     * @param \GeoJSON\Geometry\Geometry $geometry
382
     *
383 1
     * @return \Illuminate\Database\Eloquent\Builder
384
     */
385 1
    public function scopeContains(EloquentBuilder $query, $geometryColumn, Geometry $geometry): EloquentBuilder
386
    {
387
        return $this->scopeComparison($query, $geometryColumn, $geometry, 'contains');
388
    }
389
390
    /**
391
     * @param \Illuminate\Database\Eloquent\Builder $query
392
     * @param $geometryColumn
393
     * @param \GeoJSON\Geometry\Geometry $geometry
394
     *
395 1
     * @return \Illuminate\Database\Eloquent\Builder
396
     */
397 1
    public function scopeDisjoint(EloquentBuilder $query, $geometryColumn, Geometry $geometry): EloquentBuilder
398
    {
399
        return $this->scopeComparison($query, $geometryColumn, $geometry, 'disjoint');
400
    }
401
402
    /**
403
     * @param \Illuminate\Database\Eloquent\Builder $query
404
     * @param $geometryColumn
405
     * @param \GeoJSON\Geometry\Geometry $geometry
406
     *
407 1
     * @return \Illuminate\Database\Eloquent\Builder
408
     */
409 1
    public function scopeEquals(EloquentBuilder $query, $geometryColumn, Geometry $geometry): EloquentBuilder
410
    {
411
        return $this->scopeComparison($query, $geometryColumn, $geometry, 'equals');
412
    }
413
414
    /**
415
     * @param \Illuminate\Database\Eloquent\Builder $query
416
     * @param $geometryColumn
417
     * @param \GeoJSON\Geometry\Geometry $geometry
418
     *
419 1
     * @return \Illuminate\Database\Eloquent\Builder
420
     */
421 1
    public function scopeIntersects(EloquentBuilder $query, $geometryColumn, Geometry $geometry): EloquentBuilder
422
    {
423
        return $this->scopeComparison($query, $geometryColumn, $geometry, 'intersects');
424
    }
425
426
    /**
427
     * @param \Illuminate\Database\Eloquent\Builder $query
428
     * @param $geometryColumn
429
     * @param \GeoJSON\Geometry\Geometry $geometry
430
     *
431 1
     * @return \Illuminate\Database\Eloquent\Builder
432
     */
433 1
    public function scopeOverlaps(EloquentBuilder $query, $geometryColumn, Geometry $geometry): EloquentBuilder
434
    {
435
        return $this->scopeComparison($query, $geometryColumn, $geometry, 'overlaps');
436
    }
437
438
    /**
439
     * @param \Illuminate\Database\Eloquent\Builder $query
440
     * @param $geometryColumn
441
     * @param \GeoJSON\Geometry\Geometry $geometry
442
     *
443
     * @return \Illuminate\Database\Eloquent\Builder
444
     */
445
    public function scopeDoesTouch(EloquentBuilder $query, $geometryColumn, Geometry $geometry): EloquentBuilder
446
    {
447
        return $this->scopeComparison($query, $geometryColumn, $geometry, 'touches');
448
    }
449
}
450