1
|
|
|
<?php declare(strict_types=1); |
2
|
|
|
/** |
3
|
|
|
* @author Nicolas CARPi <[email protected]> |
4
|
|
|
* @copyright 2022 Nicolas CARPi |
5
|
|
|
* @see https://www.elabftw.net Official website |
6
|
|
|
* @license AGPL-3.0 |
7
|
|
|
* @package elabftw |
8
|
|
|
*/ |
9
|
|
|
|
10
|
|
|
namespace Elabftw\Elabftw; |
11
|
|
|
|
12
|
|
|
use function array_column; |
13
|
|
|
use function array_unique; |
14
|
|
|
use Elabftw\Enums\BasePermissions; |
15
|
|
|
use Elabftw\Enums\EntityType; |
16
|
|
|
use Elabftw\Exceptions\IllegalActionException; |
17
|
|
|
use Elabftw\Models\AbstractEntity; |
18
|
|
|
use Elabftw\Models\Experiments; |
19
|
|
|
use Elabftw\Models\Items; |
20
|
|
|
use Elabftw\Services\UsersHelper; |
21
|
|
|
use function implode; |
22
|
|
|
|
23
|
|
|
class EntitySqlBuilder |
24
|
|
|
{ |
25
|
|
|
private array $selectSql = array(); |
26
|
|
|
|
27
|
|
|
private array $joinsSql = array(); |
28
|
|
|
|
29
|
|
|
public function __construct(private AbstractEntity $entity) |
30
|
177 |
|
{ |
31
|
|
|
} |
32
|
177 |
|
|
33
|
|
|
/** |
34
|
|
|
* Get the SQL string for read before the WHERE |
35
|
|
|
* |
36
|
|
|
* @param bool $getTags do we get the tags too? |
37
|
|
|
* @param bool $fullSelect select all the columns of entity |
38
|
|
|
* @param null|EntityType $relatedOrigin Are we looking for related entries, what is the origin, experiments or items? |
39
|
|
|
*/ |
40
|
|
|
public function getReadSqlBeforeWhere( |
41
|
175 |
|
bool $getTags = true, |
42
|
|
|
bool $fullSelect = false, |
43
|
|
|
?EntityType $relatedOrigin = null, |
44
|
|
|
): string { |
45
|
|
|
$this->entity($fullSelect); |
46
|
175 |
|
$this->status(); |
47
|
175 |
|
$this->category(); |
48
|
175 |
|
$this->comments(); |
49
|
175 |
|
if ($getTags) { |
50
|
175 |
|
$this->tags(); |
51
|
175 |
|
} |
52
|
|
|
if ($fullSelect) { |
53
|
175 |
|
$this->teamEvents(); |
54
|
175 |
|
} |
55
|
|
|
$this->steps(); |
56
|
175 |
|
// The links tables are only joined if we want to show related entities |
57
|
|
|
if ($relatedOrigin !== null) { |
58
|
175 |
|
$this->links($relatedOrigin); |
59
|
|
|
} |
60
|
|
|
$this->usersTeams(); |
61
|
175 |
|
$this->uploads(); |
62
|
175 |
|
|
63
|
|
|
$sql = array( |
64
|
175 |
|
'SELECT DISTINCT', |
65
|
175 |
|
implode(', ', $this->selectSql), |
66
|
175 |
|
'FROM %1$s AS entity', |
67
|
175 |
|
implode(' ', $this->joinsSql), |
68
|
175 |
|
); |
69
|
175 |
|
|
70
|
|
|
// replace all %1$s by 'experiments' or 'items', there are many more than the one in FROM |
71
|
|
|
return sprintf(implode(' ', $sql), $this->entity->type); |
72
|
175 |
|
} |
73
|
|
|
|
74
|
|
|
public function getCanFilter(string $can): string |
75
|
8 |
|
{ |
76
|
|
|
$sql = ''; |
77
|
8 |
|
if ($this->entity->isAnon) { |
78
|
8 |
|
$sql .= ' AND ' . $this->canAnon($can); |
79
|
|
|
} |
80
|
|
|
$sql .= sprintf( |
81
|
8 |
|
' AND (%s)', |
82
|
8 |
|
implode(' OR ', array( |
83
|
8 |
|
$this->canBasePub($can), |
84
|
8 |
|
$this->canBaseOrg($can), |
85
|
8 |
|
$this->canBaseTeam($can), |
86
|
8 |
|
$this->canBaseUser($can), |
87
|
8 |
|
$this->canBaseUserOnly($can), |
88
|
8 |
|
$this->canTeams($can), |
89
|
8 |
|
$this->canTeamGroups($can), |
90
|
8 |
|
$this->canUsers($can), |
91
|
8 |
|
)), |
92
|
8 |
|
); |
93
|
|
|
|
94
|
8 |
|
return $sql; |
95
|
|
|
} |
96
|
|
|
|
97
|
175 |
|
private function entity(bool $fullSelect): void |
98
|
|
|
{ |
99
|
175 |
|
if ($fullSelect) { |
100
|
|
|
// get all the columns of entity table |
101
|
175 |
|
$this->selectSql[] = 'entity.*'; |
102
|
|
|
// add a literal string for the page that can be used by the mention tinymce plugin code |
103
|
175 |
|
$this->selectSql[] = sprintf( |
104
|
175 |
|
"'%s' AS page, '%s' AS type", |
105
|
175 |
|
$this->entity->page, |
106
|
175 |
|
$this->entity->type, |
107
|
175 |
|
); |
108
|
|
|
} else { |
109
|
|
|
// only get the columns interesting for show mode |
110
|
|
|
$this->selectSql[] = 'entity.id, |
111
|
|
|
entity.title, |
112
|
|
|
entity.custom_id, |
113
|
|
|
entity.date, |
114
|
|
|
entity.category, |
115
|
|
|
entity.team, |
116
|
|
|
entity.status, |
117
|
|
|
entity.rating, |
118
|
|
|
entity.userid, |
119
|
|
|
entity.locked, |
120
|
|
|
entity.state, |
121
|
|
|
entity.canread, |
122
|
|
|
entity.canwrite, |
123
|
|
|
entity.modified_at, |
124
|
|
|
entity.timestamped'; |
125
|
|
|
// only include columns (created_at, locked_at, timestamped_at, entity.metadata) if actually searching for it |
126
|
|
|
if (!empty(array_column($this->entity->extendedValues, 'additional_columns'))) { |
127
|
|
|
$this->selectSql[] = implode(', ', array_unique(array_column($this->entity->extendedValues, 'additional_columns'))); |
128
|
|
|
} |
129
|
|
|
} |
130
|
|
|
} |
131
|
175 |
|
|
132
|
|
|
private function tags(): void |
133
|
175 |
|
{ |
134
|
|
|
$this->selectSql[] = "GROUP_CONCAT( |
135
|
|
|
DISTINCT tags.tag |
136
|
|
|
ORDER BY tags.id SEPARATOR '|' |
137
|
175 |
|
) as tags, |
138
|
175 |
|
GROUP_CONCAT(DISTINCT tags.id) as tags_id"; |
139
|
|
|
$this->joinsSql[] = 'LEFT JOIN tags2entity |
140
|
|
|
ON (tags2entity.item_id = entity.id |
141
|
|
|
AND tags2entity.item_type = \'%1$s\') |
142
|
175 |
|
LEFT JOIN tags |
143
|
|
|
ON (tags.id = tags2entity.tag_id)'; |
144
|
|
|
} |
145
|
175 |
|
|
146
|
|
|
private function teamEvents(): void |
147
|
175 |
|
{ |
148
|
|
|
$this->selectSql[] = "GROUP_CONCAT( |
149
|
|
|
DISTINCT team_events.start |
150
|
|
|
ORDER BY team_events.start |
151
|
175 |
|
SEPARATOR '|' |
152
|
|
|
) AS events_start"; |
153
|
175 |
|
|
154
|
115 |
|
if ($this->entity instanceof Experiments) { |
155
|
72 |
|
$eventsColumn = 'experiment'; |
156
|
72 |
|
} elseif ($this->entity instanceof Items) { |
157
|
72 |
|
$this->selectSql[] = 'entity.is_bookable'; |
158
|
|
|
$eventsColumn = 'item_link = entity.id OR team_events.item'; |
159
|
|
|
} else { |
160
|
|
|
throw new IllegalActionException('Nope.'); |
161
|
|
|
} |
162
|
|
|
|
163
|
175 |
|
// only select events from the future |
164
|
175 |
|
$this->joinsSql[] = "LEFT JOIN team_events |
165
|
175 |
|
ON ((team_events.$eventsColumn = entity.id) |
166
|
|
|
AND team_events.start > NOW())"; |
167
|
|
|
} |
168
|
175 |
|
|
169
|
|
|
private function usersTeams(): void |
170
|
175 |
|
{ |
171
|
|
|
$this->selectSql[] = "users.firstname, |
172
|
|
|
users.lastname, |
173
|
175 |
|
users.orcid, |
174
|
|
|
CONCAT(users.firstname, ' ', users.lastname) AS fullname, |
175
|
175 |
|
teams.name AS team_name"; |
176
|
175 |
|
|
177
|
175 |
|
$this->joinsSql[] = 'LEFT JOIN users |
178
|
175 |
|
ON (users.userid = entity.userid)'; |
179
|
|
|
$this->joinsSql[] = sprintf( |
180
|
175 |
|
'LEFT JOIN users2teams |
181
|
175 |
|
ON (users2teams.users_id = users.userid |
182
|
175 |
|
AND users2teams.teams_id = %d) |
183
|
|
|
LEFT JOIN teams ON (entity.team = teams.id)', |
184
|
|
|
$this->entity->Users->userData['team'], |
185
|
175 |
|
); |
186
|
|
|
} |
187
|
175 |
|
|
188
|
175 |
|
private function category(): void |
189
|
|
|
{ |
190
|
175 |
|
$this->selectSql[] = 'categoryt.title AS category_title, |
191
|
175 |
|
categoryt.color AS category_color'; |
192
|
175 |
|
|
193
|
175 |
|
$this->joinsSql[] = sprintf( |
194
|
115 |
|
'LEFT JOIN %s AS categoryt |
195
|
175 |
|
ON (categoryt.id = entity.category)', |
196
|
175 |
|
$this->entity->type === 'experiments' |
197
|
|
|
? 'experiments_categories' |
198
|
|
|
: 'items_types', |
199
|
175 |
|
); |
200
|
|
|
} |
201
|
175 |
|
|
202
|
175 |
|
private function status(): void |
203
|
175 |
|
{ |
204
|
175 |
|
$this->selectSql[] = 'statust.title AS status_title, |
205
|
|
|
statust.color AS status_color'; |
206
|
|
|
$this->joinsSql[] = 'LEFT JOIN %1$s_status AS statust |
207
|
175 |
|
ON (statust.id = entity.status)'; |
208
|
|
|
} |
209
|
175 |
|
|
210
|
175 |
|
private function uploads(): void |
211
|
|
|
{ |
212
|
|
|
$this->selectSql[] = 'uploads.up_item_id, |
213
|
175 |
|
uploads.has_attachment'; |
214
|
175 |
|
|
215
|
|
|
// only include columns if actually searching for comments/filenames |
216
|
|
|
$searchAttachments = ''; |
217
|
|
|
if (!empty(array_column($this->entity->extendedValues, 'searchAttachments'))) { |
218
|
|
|
$searchAttachments = ', GROUP_CONCAT(comment) AS comments |
219
|
175 |
|
, GROUP_CONCAT(real_name) AS real_names'; |
220
|
|
|
} |
221
|
|
|
|
222
|
|
|
$this->joinsSql[] = 'LEFT JOIN ( |
223
|
175 |
|
SELECT item_id AS up_item_id, |
224
|
|
|
(item_id IS NOT NULL) AS has_attachment, |
225
|
|
|
type |
226
|
|
|
' . $searchAttachments . ' |
227
|
|
|
FROM uploads |
228
|
175 |
|
GROUP BY item_id, type |
229
|
|
|
) AS uploads |
230
|
|
|
ON (uploads.up_item_id = entity.id |
231
|
|
|
AND uploads.type = \'%1$s\')'; |
232
|
|
|
} |
233
|
|
|
|
234
|
|
|
private function links(EntityType $relatedOrigin): void |
235
|
|
|
{ |
236
|
|
|
$table = 'items'; |
237
|
|
|
if ($this->entity->entityType === EntityType::Experiments) { |
238
|
|
|
$table = 'experiments'; |
239
|
|
|
} |
240
|
|
|
|
241
|
|
|
$related = '_links'; |
242
|
|
|
if ($relatedOrigin === EntityType::Experiments) { |
243
|
|
|
$related = '2experiments'; |
244
|
|
|
} |
245
|
|
|
|
246
|
|
|
$this->joinsSql[] = "LEFT JOIN $table$related AS linkst |
247
|
175 |
|
ON (linkst.item_id = entity.id)"; |
248
|
|
|
} |
249
|
175 |
|
|
250
|
|
|
private function steps(): void |
251
|
|
|
{ |
252
|
|
|
$this->selectSql[] = "SUBSTRING_INDEX(GROUP_CONCAT( |
253
|
175 |
|
stepst.next_step |
254
|
175 |
|
ORDER BY steps_ordering, steps_id |
255
|
|
|
SEPARATOR '|' |
256
|
|
|
), '|', 1) AS next_step"; |
257
|
|
|
$this->joinsSql[] = 'LEFT JOIN ( |
258
|
|
|
SELECT item_id AS steps_item_id, |
259
|
|
|
body AS next_step, |
260
|
|
|
ordering AS steps_ordering, |
261
|
|
|
id AS steps_id, |
262
|
|
|
finished AS finished |
263
|
175 |
|
FROM %1$s_steps |
264
|
|
|
WHERE finished = 0 |
265
|
|
|
) AS stepst |
266
|
175 |
|
ON (stepst.steps_item_id = entity.id)'; |
267
|
|
|
} |
268
|
175 |
|
|
269
|
175 |
|
private function comments(): void |
270
|
175 |
|
{ |
271
|
|
|
$this->selectSql[] = 'commentst.recent_comment, |
272
|
|
|
(commentst.recent_comment IS NOT NULL) AS has_comment'; |
273
|
|
|
$this->joinsSql[] = 'LEFT JOIN ( |
274
|
|
|
SELECT MAX(created_at) AS recent_comment, |
275
|
|
|
item_id |
276
|
175 |
|
FROM %1$s_comments |
277
|
|
|
GROUP BY item_id |
278
|
|
|
) AS commentst |
279
|
|
|
ON (commentst.item_id = entity.id)'; |
280
|
|
|
} |
281
|
|
|
|
282
|
|
|
/** |
283
|
|
|
* anon filter |
284
|
|
|
*/ |
285
|
|
|
private function canAnon(string $can): string |
286
|
|
|
{ |
287
|
|
|
return sprintf( |
288
|
|
|
"entity.%s->'$.base' = %s", |
289
|
|
|
$can, |
290
|
|
|
BasePermissions::Full->value, |
291
|
|
|
); |
292
|
|
|
} |
293
|
|
|
|
294
|
|
|
/** |
295
|
8 |
|
* base pub filter |
296
|
|
|
*/ |
297
|
8 |
|
private function canBasePub(string $can): string |
298
|
8 |
|
{ |
299
|
8 |
|
return sprintf( |
300
|
8 |
|
"entity.%s->'$.base' = %d", |
301
|
8 |
|
$can, |
302
|
8 |
|
BasePermissions::Full->value, |
303
|
8 |
|
); |
304
|
8 |
|
} |
305
|
8 |
|
|
306
|
|
|
/** |
307
|
8 |
|
* base org filter |
308
|
|
|
*/ |
309
|
|
|
private function canBaseOrg(string $can): string |
310
|
|
|
{ |
311
|
|
|
return sprintf( |
312
|
|
|
"entity.%s->'$.base' = %d", |
313
|
|
|
$can, |
314
|
|
|
BasePermissions::Organization->value, |
315
|
8 |
|
); |
316
|
|
|
} |
317
|
8 |
|
|
318
|
8 |
|
/** |
319
|
|
|
* base team filter |
320
|
8 |
|
*/ |
321
|
8 |
|
private function canBaseTeam(string $can): string |
322
|
8 |
|
{ |
323
|
|
|
return sprintf( |
324
|
8 |
|
"(entity.%s->'$.base' = %d |
325
|
4 |
|
AND users2teams.teams_id = entity.team)", |
326
|
8 |
|
$can, |
327
|
8 |
|
BasePermissions::Team->value, |
328
|
|
|
); |
329
|
|
|
} |
330
|
|
|
|
331
|
|
|
/** |
332
|
|
|
* base user filter |
333
|
|
|
* entities are accessible for admins too |
334
|
8 |
|
*/ |
335
|
|
|
private function canBaseUser(string $can): string |
336
|
8 |
|
{ |
337
|
8 |
|
return sprintf( |
338
|
8 |
|
"(entity.%s->'$.base' = %d |
339
|
8 |
|
AND entity.userid = %s |
340
|
8 |
|
AND users2teams.teams_id = entity.team)", |
341
|
8 |
|
$can, |
342
|
8 |
|
BasePermissions::User->value, |
343
|
8 |
|
$this->entity->Users->isAdmin |
344
|
8 |
|
? 'users2teams.users_id' |
345
|
|
|
: ':userid', |
346
|
|
|
); |
347
|
|
|
} |
348
|
|
|
|
349
|
|
|
/** |
350
|
|
|
* base user only filter |
351
|
8 |
|
* entities are listed only if we own them |
352
|
|
|
*/ |
353
|
8 |
|
private function canBaseUserOnly(string $can): string |
354
|
8 |
|
{ |
355
|
8 |
|
return sprintf( |
356
|
8 |
|
"(entity.%s->'$.base' = %d |
357
|
8 |
|
AND entity.userid = :userid |
358
|
8 |
|
AND users2teams.teams_id = entity.team)", |
359
|
|
|
$can, |
360
|
|
|
BasePermissions::UserOnly->value, |
361
|
|
|
); |
362
|
|
|
} |
363
|
|
|
|
364
|
8 |
|
/** |
365
|
|
|
* teams filter |
366
|
8 |
|
*/ |
367
|
8 |
|
private function canTeams(string $can): string |
368
|
8 |
|
{ |
369
|
|
|
$UsersHelper = new UsersHelper((int) $this->entity->Users->userData['userid']); |
370
|
|
|
$teamsOfUser = $UsersHelper->getTeamsIdFromUserid(); |
371
|
8 |
|
if (!empty($teamsOfUser)) { |
372
|
8 |
|
// JSON_OVERLAPS checks for the intersection of two arrays |
373
|
8 |
|
// for instance [4,5,6] vs [2,6] has 6 in common -> 1 (true) |
374
|
8 |
|
return sprintf( |
375
|
8 |
|
"JSON_OVERLAPS(entity.%s->'$.teams', CAST('[%s]' AS JSON))", |
376
|
|
|
$can, |
377
|
|
|
implode(', ', $teamsOfUser), |
378
|
|
|
); |
379
|
|
|
} |
380
|
|
|
return '0'; |
381
|
|
|
} |
382
|
|
|
|
383
|
8 |
|
/** |
384
|
|
|
* teamgroups filter |
385
|
8 |
|
*/ |
386
|
8 |
|
private function canTeamGroups(string $can): string |
387
|
|
|
{ |
388
|
|
|
$teamgroupsOfUser = array_column($this->entity->TeamGroups->readGroupsFromUser(), 'id'); |
389
|
|
|
if (!empty($teamgroupsOfUser)) { |
390
|
|
|
// JSON_OVERLAPS checks for the intersection of two arrays |
391
|
|
|
// for instance [4,5,6] vs [2,6] has 6 in common -> 1 (true) |
392
|
|
|
return sprintf( |
393
|
|
|
"JSON_OVERLAPS(entity.%s->'$.teamgroups', CAST('[%s]' AS JSON))", |
394
|
|
|
$can, |
395
|
8 |
|
implode(', ', $teamgroupsOfUser), |
396
|
|
|
); |
397
|
|
|
} |
398
|
|
|
return '0'; |
399
|
|
|
} |
400
|
|
|
|
401
|
8 |
|
/** |
402
|
|
|
* users filter |
403
|
8 |
|
*/ |
404
|
|
|
private function canUsers(string $can): string |
405
|
|
|
{ |
406
|
|
|
return ":userid MEMBER OF (entity.$can->>'$.users')"; |
407
|
|
|
} |
408
|
|
|
} |
409
|
|
|
|