1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Models;
7
8use App\Exceptions\ModelNotSavedException;
9use App\Libraries\MorphMap;
10use App\Libraries\Transactions\AfterCommit;
11use App\Libraries\Transactions\AfterRollback;
12use App\Libraries\TransactionStateManager;
13use App\Scopes\MacroableModelScope;
14use App\Traits\Validatable;
15use Exception;
16use Illuminate\Database\ClassMorphViolationException;
17use Illuminate\Database\Eloquent\Factories\HasFactory;
18use Illuminate\Database\Eloquent\Model as BaseModel;
19
20abstract class Model extends BaseModel
21{
22 use HasFactory, Traits\FasterAttributes, Validatable;
23
24 const MAX_FIELD_LENGTHS = [];
25 const int PER_PAGE = 50;
26
27 protected $connection = 'mysql';
28 protected $guarded = [];
29 protected array $macros = [];
30 protected $perPage = self::PER_PAGE;
31 protected $primaryKeys;
32
33 public static function booted()
34 {
35 static::addGlobalScope(new MacroableModelScope());
36 }
37
38 protected static function searchQueryAndParams(array $params)
39 {
40 $limit = \Number::clamp(get_int($params['limit'] ?? null) ?? static::PER_PAGE, 5, 50);
41 $page = max(get_int($params['page'] ?? null), 1);
42
43 $offset = max_offset($page, $limit);
44 $page = 1 + $offset / $limit;
45
46 $query = static::limit($limit)->offset($offset);
47
48 return [$query, compact('limit', 'page')];
49 }
50
51 public function getForeignKey()
52 {
53 if ($this->primaryKey === null || $this->primaryKey === 'id') {
54 return parent::getForeignKey();
55 }
56
57 return $this->primaryKey;
58 }
59
60 public function getKey()
61 {
62 return $this->getRawAttribute($this->primaryKey);
63 }
64
65 public function getMacros(): array
66 {
67 return [
68 ...$this->macros,
69 'getWithHasMore',
70 'last',
71 'realCount',
72 ];
73 }
74
75 public function getMorphClass()
76 {
77 $className = static::class;
78
79 $ret = MorphMap::getType($className);
80
81 if ($ret !== null) {
82 return $ret;
83 }
84
85 throw new ClassMorphViolationException($this);
86 }
87
88 /**
89 * Locks the current model for update with `select for update`.
90 *
91 * @return $this
92 */
93 public function lockSelf()
94 {
95 return $this->lockForUpdate()->find($this->getKey());
96 }
97
98 public function macroGetWithHasMore()
99 {
100 return function ($query) {
101 $limit = $query->getQuery()->limit;
102 if ($limit === null) {
103 throw new Exception('"getWithHasMore" was called on query without "limit" specified');
104 }
105 $moreLimit = $limit + 1;
106 $result = $query->limit($moreLimit)->get();
107
108 $hasMore = $result->count() === $moreLimit;
109
110 if ($hasMore) {
111 $result->pop();
112 }
113
114 return [$result, $hasMore];
115 };
116 }
117
118 public function macroLast()
119 {
120 return function ($baseQuery, $column = null) {
121 $query = clone $baseQuery;
122
123 return $query->orderBy($column ?? $this->getKeyName(), 'DESC')->first();
124 };
125 }
126
127 public function macroRealCount()
128 {
129 return function ($baseQuery) {
130 $query = clone $baseQuery;
131 $query->unorder();
132 $query->getQuery()->offset = null;
133 $query->limit(null);
134
135 return min($query->count(), $GLOBALS['cfg']['osu']['pagination']['max_count']);
136 };
137 }
138
139 public function refresh()
140 {
141 if (method_exists($this, 'resetMemoized')) {
142 $this->resetMemoized();
143 }
144
145 return parent::refresh();
146 }
147
148 public function scopeReorderBy($query, $field, $order)
149 {
150 return $query->unorder()->orderBy($field, $order);
151 }
152
153 public function scopeOrderByField($query, $field, $ids)
154 {
155 $size = count($ids);
156
157 if ($size === 0) {
158 return;
159 }
160
161 $bind = implode(',', array_fill(0, $size, '?'));
162 $string = "FIELD({$field}, {$bind})";
163 $values = array_map('strval', $ids);
164
165 $query->orderByRaw($string, $values);
166 }
167
168 public function scopeNone($query)
169 {
170 $query->whereRaw('false');
171 }
172
173 public function scopeUnorder($query)
174 {
175 $query->getQuery()->orders = null;
176
177 return $query;
178 }
179
180 public function scopeWithPresent($query, $column)
181 {
182 $query->whereNotNull($column)->where($column, '<>', '');
183 }
184
185 /**
186 * Just like decrement but only works on saved instance instead of falling back to entire model
187 */
188 public function decrementInstance()
189 {
190 if (!$this->exists) {
191 return false;
192 }
193
194 return $this->decrement(...func_get_args());
195 }
196
197 public function delete()
198 {
199 return $this->runAfterCommitWrapper(function () {
200 return parent::delete();
201 });
202 }
203
204 /**
205 * Just like increment but only works on saved instance instead of falling back to entire model
206 */
207 public function incrementInstance()
208 {
209 if (!$this->exists) {
210 return false;
211 }
212
213 return $this->increment(...func_get_args());
214 }
215
216 public function save(array $options = [])
217 {
218 return $this->runAfterCommitWrapper(function () use ($options) {
219 return parent::save($options);
220 });
221 }
222
223 public function saveOrExplode($options = [])
224 {
225 return $this->getConnection()->transaction(function () use ($options) {
226 $result = $this->save($options);
227
228 if ($result === false) {
229 $errors = $this->validationErrors();
230 $message = $errors->isEmpty() ? 'failed saving model' : $errors->toSentence();
231
232 throw new ModelNotSavedException($message);
233 }
234
235 return $result;
236 });
237 }
238
239 public function dbName()
240 {
241 $connection = $this->connection ?? $GLOBALS['cfg']['database']['default'];
242
243 return $GLOBALS['cfg']['database']['connections'][$connection]['database'];
244 }
245
246 public function tableName(bool $includeDbPrefix = false)
247 {
248 return ($includeDbPrefix ? $this->dbName().'.' : '').$this->getTable();
249 }
250
251 // Allows save/update/delete to work with composite primary keys.
252 // Note this doesn't fix 'find' method and a bunch of other laravel things
253 // which rely on getKeyName and getKey (and they themselves are broken as well).
254 protected function setKeysForSaveQuery($query)
255 {
256 if (isset($this->primaryKeys)) {
257 foreach ($this->primaryKeys as $key) {
258 $query->where([$key => $this->original[$key] ?? null]);
259 }
260
261 return $query;
262 }
263
264 return parent::setKeysForSaveQuery($query);
265 }
266
267 // same deal with setKeysForSaveQuery but for select query
268 protected function setKeysForSelectQuery($query)
269 {
270 return $this->setKeysForSaveQuery($query);
271 }
272
273 protected function validateDbFieldLength(int $limit, string $dbField, ?string $checkField = null): void
274 {
275 if ($this->isDirty($dbField)) {
276 $this->validateFieldLength($limit, $dbField, $checkField);
277 }
278 }
279
280 protected function validateDbFieldLengths(): void
281 {
282 foreach (static::MAX_FIELD_LENGTHS as $field => $limit) {
283 $this->validateDbFieldLength($limit, $field, $field);
284 }
285 }
286
287 protected function validationErrorsTranslationPrefix(): string
288 {
289 return '';
290 }
291
292 private function enlistCallbacks($model, $connection)
293 {
294 $transaction = resolve(TransactionStateManager::class)->current($connection);
295 if ($model instanceof AfterCommit) {
296 $transaction->addCommittable($model);
297 }
298
299 if ($model instanceof AfterRollback) {
300 $transaction->addRollbackable($model);
301 }
302
303 return $transaction;
304 }
305
306 private function runAfterCommitWrapper(callable $fn)
307 {
308 $transaction = $this->enlistCallbacks($this, $this->connection);
309
310 $result = $fn();
311
312 if ($this instanceof AfterCommit && $transaction->isReal() === false) {
313 $transaction->commit();
314 }
315
316 return $result;
317 }
318}