PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.6
Yatra – Travel Booking & Tour Operator Software v3.0.2.6
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Repositories / AttributeRepository.php

AttributeRepository.php in Yatra – Travel Booking & Tour Operator Software 3.0.2.6, at app/Repositories/AttributeRepository.php

741 lines 23.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Repositories;
6
7 use Yatra\Constants\ClassificationTypes;
8 use Yatra\Database\Tables\ClassificationsTable;
9 use Yatra\Utils\QueryCache;
10 use Yatra\Utils\Cache;
11
12 /**
13 * Attribute Repository
14 * Handles database operations for trip attributes
15 */
16 class AttributeRepository extends BaseRepository
17 {
18 /**
19 * Rich text fields
20 */
21 protected array $richTextFields = ['description', 'field_options', 'validation_rules'];
22
23 /**
24 * Integer fields
25 */
26 protected array $integerFields = ['id', 'sorting', 'created_by', 'updated_by'];
27
28 /**
29 * Get paginated results
30 */
31 public function paginate(int $page = 1, int $perPage = 10, array $args = []): array
32 {
33 // Convert filter keys to WHERE clause format
34 $where = [];
35
36 // Always filter by type = 'attribute'
37 $where['type'] = ClassificationTypes::ATTRIBUTE;
38
39 // Handle status filter
40 if (isset($args['status'])) {
41 $where['status'] = $args['status'];
42 unset($args['status']);
43 }
44
45 // Handle field_type filter
46 if (isset($args['field_type'])) {
47 $where['field_type'] = $args['field_type'];
48 unset($args['field_type']);
49 }
50
51 // Handle show_on_frontend filter
52 if (isset($args['show_on_frontend'])) {
53 $where['show_on_frontend'] = $args['show_on_frontend'];
54 unset($args['show_on_frontend']);
55 }
56
57 // Handle show_in_filters filter
58 if (isset($args['show_in_filters'])) {
59 $where['show_in_filters'] = $args['show_in_filters'];
60 unset($args['show_in_filters']);
61 }
62
63 // Add WHERE conditions if any
64 if (!empty($where)) {
65 $args['where'] = $where;
66 }
67
68 $args['limit'] = $perPage;
69 $args['offset'] = ($page - 1) * $perPage;
70
71 return $this->all($args);
72 }
73
74 /**
75 * Count records with filters
76 */
77 public function count(array $args = []): int
78 {
79 // Convert filter keys to WHERE clause format (same as paginate)
80 $where = [];
81
82 // Always filter by type = 'attribute'
83 $where['type'] = ClassificationTypes::ATTRIBUTE;
84
85 if (isset($args['status'])) {
86 $where['status'] = $args['status'];
87 unset($args['status']);
88 }
89
90 if (isset($args['field_type'])) {
91 $where['field_type'] = $args['field_type'];
92 unset($args['field_type']);
93 }
94
95 if (isset($args['show_on_frontend'])) {
96 $where['show_on_frontend'] = $args['show_on_frontend'];
97 unset($args['show_on_frontend']);
98 }
99
100 if (isset($args['show_in_filters'])) {
101 $where['show_in_filters'] = $args['show_in_filters'];
102 unset($args['show_in_filters']);
103 }
104
105 if (!empty($where)) {
106 $args['where'] = $where;
107 }
108
109 return parent::count($args);
110 }
111
112 /**
113 * Get table name
114 */
115 protected function getTableName(): string
116 {
117 return ClassificationsTable::getTableName();
118
119 }
120
121 /**
122 * SQL fragment: metadata JSON flag is enabled (handles true, 1, "1", "true" from PHP json_encode).
123 *
124 * @param non-empty-string $key Metadata key (alphanumeric + underscore only)
125 */
126 public static function metadataEnabledSql(string $key): string
127 {
128 $key = preg_replace('/[^a-z0-9_]/i', '', $key) ?: 'invalid';
129 $path = '$.' . $key;
130
131 return "(JSON_EXTRACT(metadata, '{$path}') = true "
132 . "OR JSON_EXTRACT(metadata, '{$path}') = 1 "
133 . "OR JSON_UNQUOTE(JSON_EXTRACT(metadata, '{$path}')) IN ('1','true','TRUE','yes','YES','on','ON'))";
134 }
135
136 /**
137 * Same as {@see metadataEnabledSql()} but for a qualified table alias (e.g. subqueries).
138 *
139 * @param non-empty-string $tableAlias
140 * @param non-empty-string $key
141 */
142 public static function metadataEnabledSqlOnAlias(string $tableAlias, string $key): string
143 {
144 $key = preg_replace('/[^a-z0-9_]/i', '', $key) ?: 'invalid';
145 $path = '$.' . $key;
146 $alias = preg_replace('/[^a-z0-9_]/i', '', $tableAlias) ?: 'm';
147 $col = $alias . '.metadata';
148
149 return "(JSON_EXTRACT({$col}, '{$path}') = true "
150 . "OR JSON_EXTRACT({$col}, '{$path}') = 1 "
151 . "OR JSON_UNQUOTE(JSON_EXTRACT({$col}, '{$path}')) IN ('1','true','TRUE','yes','YES','on','ON'))";
152 }
153
154 /**
155 * Get all published attributes
156 */
157 public function getAllPublished(): array
158 {
159 $table = esc_sql($this->table);
160 $query = "SELECT * FROM `{$table}`
161 WHERE type = %s AND status = 'publish'
162 ORDER BY sorting ASC, name ASC";
163
164 return $this->wpdb->get_results($this->wpdb->prepare($query, ClassificationTypes::ATTRIBUTE)) ?: [];
165 }
166
167 /**
168 * Get attributes for frontend display
169 */
170 public function getFrontendAttributes(): array
171 {
172 $table = esc_sql($this->table);
173 $flag = self::metadataEnabledSql('show_on_frontend');
174 $query = "SELECT * FROM `{$table}`
175 WHERE type = %s AND status = 'publish' AND {$flag}
176 ORDER BY sorting ASC, name ASC";
177
178 return $this->wpdb->get_results($this->wpdb->prepare($query, ClassificationTypes::ATTRIBUTE)) ?: [];
179 }
180
181 /**
182 * Get filterable attributes
183 */
184 public function getFilterableAttributes(): array
185 {
186 $table = esc_sql($this->table);
187 $flag = self::metadataEnabledSql('show_in_filters');
188 $query = "SELECT * FROM `{$table}`
189 WHERE type = %s AND status = 'publish' AND {$flag}
190 ORDER BY sorting ASC, name ASC";
191
192 return $this->wpdb->get_results($this->wpdb->prepare($query, ClassificationTypes::ATTRIBUTE)) ?: [];
193 }
194
195 /**
196 * Published attribute IDs that may be used in public listing filters (URL / sidebar).
197 *
198 * @return list<int>
199 */
200 public function getFilterableAttributeIds(): array
201 {
202 $ids = [];
203 foreach ($this->getFilterableAttributes() as $row) {
204 $ids[] = (int) $row->id;
205 }
206
207 return $ids;
208 }
209
210 /**
211 * Published attributes marked required (trip form / API must supply a value when saving attributes).
212 *
213 * @return list<array{id:int,name:string,field_type:string}>
214 */
215 public function getRequiredPublishedAttributeDefinitions(): array
216 {
217 $table = esc_sql($this->table);
218 $flag = self::metadataEnabledSql('required');
219 $rows = $this->wpdb->get_results(
220 $this->wpdb->prepare(
221 "SELECT id, name, metadata FROM `{$table}` WHERE type = %s AND status = 'publish' AND {$flag}",
222 ClassificationTypes::ATTRIBUTE
223 )
224 ) ?: [];
225
226 $out = [];
227 foreach ($rows as $row) {
228 $meta = !empty($row->metadata) ? json_decode((string) $row->metadata, true) : [];
229 $ft = is_array($meta) && isset($meta['field_type']) ? (string) $meta['field_type'] : 'text';
230 $out[] = [
231 'id' => (int) $row->id,
232 'name' => (string) ($row->name ?? ''),
233 'field_type' => $ft,
234 ];
235 }
236
237 return $out;
238 }
239
240 /**
241 * Normalize REST / form payloads to id-keyed map for validation.
242 *
243 * @param array<int|string, mixed> $attributes
244 * @return array<int, mixed>
245 */
246 public function normalizeTripAttributesPayloadForValidation(array $attributes): array
247 {
248 $out = [];
249 foreach ($attributes as $k => $v) {
250 if (is_array($v) && isset($v['attribute_id'])) {
251 $out[(int) $v['attribute_id']] = $v;
252 continue;
253 }
254 if (is_array($v) && isset($v['id'])) {
255 $out[(int) $v['id']] = $v;
256 continue;
257 }
258 if (is_numeric($k)) {
259 $kid = (int) $k;
260 $out[$kid] = is_array($v) ? $v : ['value' => $v];
261 }
262 }
263
264 return $out;
265 }
266
267 /**
268 * @param array<int|string, mixed> $attributes Payload keyed by attribute id (TripAttributeRepository::saveTripAttributes format)
269 *
270 * @throws \InvalidArgumentException
271 */
272 public function validatePayloadCoversRequiredAttributes(array $attributes): void
273 {
274 $attributes = $this->normalizeTripAttributesPayloadForValidation($attributes);
275
276 foreach ($this->getRequiredPublishedAttributeDefinitions() as $def) {
277 $id = $def['id'];
278 $fieldType = $def['field_type'] ?: 'text';
279 if (!$this->payloadHasNonEmptyAttributeValue($attributes, $id, $fieldType)) {
280 $label = $def['name'] !== '' ? $def['name'] : (string) $id;
281 throw new \InvalidArgumentException(
282 sprintf(
283 /* translators: %s: attribute name */
284 __('Required trip attribute "%s" is missing or empty.', 'yatra'),
285 $label
286 ),
287 400
288 );
289 }
290 }
291 }
292
293 /**
294 * @param array<int|string, mixed> $attributes
295 */
296 private function payloadHasNonEmptyAttributeValue(array $attributes, int $attributeId, string $fieldType): bool
297 {
298 $keys = [(string) $attributeId, $attributeId];
299 $entry = null;
300 foreach ($keys as $k) {
301 if (array_key_exists($k, $attributes)) {
302 $entry = $attributes[$k];
303 break;
304 }
305 }
306
307 if ($entry === null) {
308 return false;
309 }
310
311 if (is_array($entry) && array_key_exists('value', $entry)) {
312 $val = $entry['value'];
313 if (isset($entry['field_type']) && is_string($entry['field_type']) && $entry['field_type'] !== '') {
314 $fieldType = $entry['field_type'];
315 }
316 } else {
317 $val = $entry;
318 }
319
320 if ($fieldType === 'checkbox') {
321 if (is_array($val)) {
322 return $val !== [];
323 }
324
325 return $val === true || $val === 1 || $val === '1' || $val === 'true' || $val === 'on';
326 }
327
328 if (is_array($val)) {
329 if (isset($val['min'], $val['max'])) {
330 return ($val['min'] !== '' && $val['min'] !== null) || ($val['max'] !== '' && $val['max'] !== null);
331 }
332 if (isset($val['from'], $val['to'])) {
333 return ($val['from'] !== '' && $val['from'] !== null) || ($val['to'] !== '' && $val['to'] !== null);
334 }
335
336 return $val !== [];
337 }
338
339 if (is_string($val)) {
340 return trim($val) !== '';
341 }
342
343 return $val !== null && $val !== '';
344 }
345
346 /**
347 * Get attribute by slug
348 */
349 public function findBySlug(string $slug): ?\stdClass
350 {
351 $table = esc_sql($this->table);
352 $query = "SELECT * FROM `{$table}`
353 WHERE type = %s AND slug = %s AND status = 'publish'";
354
355 return $this->wpdb->get_row($this->wpdb->prepare($query, ClassificationTypes::ATTRIBUTE ,$slug));
356 }
357
358 /**
359 * Search attributes by name or description
360 */
361 public function search(string $term): array
362 {
363 $table = esc_sql($this->table);
364 $query = "SELECT * FROM `{$table}`
365 WHERE type = %s AND status = 'publish'
366 AND (name LIKE %s OR description LIKE %s)
367 ORDER BY sorting ASC, name ASC
368 LIMIT 50";
369
370 $searchTerm = '%' . $this->wpdb->esc_like($term) . '%';
371
372 return $this->wpdb->get_results(
373 $this->wpdb->prepare($query, ClassificationTypes::ATTRIBUTE, $searchTerm, $searchTerm)
374 ) ?: [];
375 }
376
377 /**
378 * Normalize attribute status for DB (column on classifications row).
379 */
380 private function normalizeAttributeStatus($status, string $default = 'publish'): string
381 {
382 $s = is_string($status) ? strtolower(trim($status)) : '';
383 if ($s === '') {
384 return $default;
385 }
386 if (in_array($s, ['publish', 'draft', 'trash'], true)) {
387 return $s;
388 }
389
390 return $default;
391 }
392
393 /**
394 * Create attribute
395 */
396 public function create(array $data): int
397 {
398 $table = esc_sql($this->table);
399
400 // Extract metadata fields
401 $metadata = [];
402 $metadataFields = [
403 'field_type', 'required', 'show_on_frontend', 'show_in_filters',
404 'filter_type', 'searchable', 'display_order', 'default_value',
405 'placeholder', 'field_options', 'validation_rules'
406 ];
407
408 foreach ($metadataFields as $field) {
409 if (isset($data[$field])) {
410 $metadata[$field] = $data[$field];
411 unset($data[$field]);
412 }
413 }
414
415 $sorting = $data['sorting'] ?? null;
416 if ($sorting === null && isset($metadata['display_order'])) {
417 $sorting = (int) $metadata['display_order'];
418 }
419
420 // Prepare core data
421 $coreData = [
422 'type' => ClassificationTypes::ATTRIBUTE,
423 'name' => $data['name'],
424 'slug' => !empty($data['slug']) ? $data['slug'] : sanitize_title($data['name']),
425 'description' => $data['description'] ?? null,
426 'icon' => $data['icon'] ?? null,
427 'metadata' => !empty($metadata) ? json_encode($metadata) : null,
428 'sorting' => (int) ($sorting ?? 0),
429 'status' => $this->normalizeAttributeStatus($data['status'] ?? null, 'publish'),
430 'created_at' => current_time('mysql'),
431 'updated_at' => current_time('mysql'),
432 'created_by' => $data['created_by'] ?? null,
433 'updated_by' => $data['updated_by'] ?? null,
434 ];
435
436 $result = $this->wpdb->insert($table, $coreData);
437
438 if ($result === false) {
439 throw new \Exception('Failed to create attribute: ' . $this->wpdb->last_error);
440 }
441
442 return $this->wpdb->insert_id;
443 }
444
445 /**
446 * Update attribute
447 */
448 public function update(int $id, array $data): bool
449 {
450 $table = esc_sql($this->table);
451
452 // Extract metadata fields
453 $metadata = [];
454 $metadataFields = [
455 'field_type', 'required', 'show_on_frontend', 'show_in_filters',
456 'filter_type', 'searchable', 'display_order', 'default_value',
457 'placeholder', 'field_options', 'validation_rules'
458 ];
459
460 foreach ($metadataFields as $field) {
461 if (isset($data[$field])) {
462 $metadata[$field] = $data[$field];
463 unset($data[$field]);
464 }
465 }
466
467 // Prepare core data
468 $coreData = [
469 'updated_at' => current_time('mysql'),
470 ];
471
472 // Update core fields if provided
473 if (isset($data['name'])) {
474 $coreData['name'] = $data['name'];
475 // Update slug if name changed and slug not provided
476 if (!isset($data['slug'])) {
477 $coreData['slug'] = sanitize_title($data['name']);
478 }
479 }
480
481 if (isset($data['slug'])) {
482 $coreData['slug'] = $data['slug'];
483 }
484
485 if (isset($data['description'])) {
486 $coreData['description'] = $data['description'];
487 }
488
489 if (isset($data['icon'])) {
490 $coreData['icon'] = $data['icon'];
491 }
492
493 if (isset($data['sorting'])) {
494 $coreData['sorting'] = $data['sorting'];
495 }
496
497 if (isset($data['status'])) {
498 $coreData['status'] = $this->normalizeAttributeStatus($data['status'], 'draft');
499 }
500
501 if (isset($data['updated_by'])) {
502 $coreData['updated_by'] = $data['updated_by'];
503 }
504
505 // Handle metadata - merge with existing metadata
506 if (!empty($metadata)) {
507 // Get existing metadata
508 $existing = $this->wpdb->get_var(
509 $this->wpdb->prepare(
510 "SELECT metadata FROM `{$table}` WHERE id = %d AND type = %s",
511 $id,
512 ClassificationTypes::ATTRIBUTE
513 )
514 );
515
516 $existingMetadata = !empty($existing) ? json_decode($existing, true) : [];
517 if (!is_array($existingMetadata)) {
518 $existingMetadata = [];
519 }
520
521 // Merge new metadata with existing
522 $mergedMetadata = array_merge($existingMetadata, $metadata);
523 $coreData['metadata'] = json_encode($mergedMetadata);
524 if (isset($metadata['display_order'])) {
525 $coreData['sorting'] = (int) $metadata['display_order'];
526 }
527 }
528
529 // Let WordPress infer formats — fixed-length format arrays misaligned with dynamic $coreData
530 // and corrupted string columns (e.g. status, icon, metadata).
531 $result = $this->wpdb->update(
532 $table,
533 $coreData,
534 ['id' => $id, 'type' => ClassificationTypes::ATTRIBUTE],
535 null,
536 ['%d', '%s']
537 );
538
539 return $result !== false;
540 }
541
542 /**
543 * Delete attribute (soft delete)
544 */
545 public function delete(int $id): bool
546 {
547 return $this->update($id, ['status' => 'trash']);
548 }
549
550 /**
551 * Permanently delete attribute
552 */
553 public function forceDelete(int $id): bool
554 {
555 $table = esc_sql($this->table);
556 $tripAttrTable = esc_sql($this->getTripAttributesTableName());
557
558 // Start transaction
559 $this->wpdb->query('START TRANSACTION');
560
561 try {
562 // Delete related trip attributes
563 $this->wpdb->delete(
564 $tripAttrTable,
565 ['attribute_id' => $id],
566 ['%d']
567 );
568
569 // Delete attribute
570 $result = $this->wpdb->delete(
571 $table,
572 ['id' => $id, 'type' => ClassificationTypes::ATTRIBUTE],
573 ['%d', '%s']
574 );
575
576 $this->wpdb->query('COMMIT');
577
578 return $result !== false;
579 } catch (\Exception $e) {
580 $this->wpdb->query('ROLLBACK');
581 throw $e;
582 }
583 }
584
585 /**
586 * Check if slug exists
587 */
588 public function slugExists(string $slug, ?int $excludeId = null): bool
589 {
590 $table = esc_sql($this->table);
591 $query = "SELECT COUNT(*) FROM `{$table}` WHERE type = %s AND slug = %s";
592
593 if ($excludeId) {
594 $query .= " AND id != %d";
595 return (int) $this->wpdb->get_var(
596 $this->wpdb->prepare($query, ClassificationTypes::ATTRIBUTE, $slug, $excludeId)
597 ) > 0;
598 }
599
600 return (int) $this->wpdb->get_var(
601 $this->wpdb->prepare($query, $slug)
602 ) > 0;
603 }
604
605 /**
606 * Get trips table name
607 */
608 public function getTripsTableName(): string
609 {
610 return ClassificationsTable::getTableName();
611 }
612
613 /**
614 * Get max display order
615 */
616 public function getMaxDisplayOrder(): int
617 {
618 $table = esc_sql($this->table);
619
620 return (int) $this->wpdb->get_var(
621 $this->wpdb->prepare(
622 "SELECT MAX(sorting) FROM `{$table}` WHERE type = %s",
623 ClassificationTypes::ATTRIBUTE
624 )
625 );
626 }
627
628 /**
629 * Update display orders
630 */
631 public function updateDisplayOrders(array $orders): bool
632 {
633 $table = esc_sql($this->table);
634
635 $this->wpdb->query('START TRANSACTION');
636
637 try {
638 foreach ($orders as $id => $order) {
639 $this->wpdb->update(
640 $table,
641 ['sorting' => $order, 'updated_at' => current_time('mysql')],
642 ['id' => $id, 'type' => ClassificationTypes::ATTRIBUTE],
643 ['%d', '%s'],
644 ['%d', '%s']
645 );
646 }
647
648 $this->wpdb->query('COMMIT');
649 return true;
650 } catch (\Exception $e) {
651 $this->wpdb->query('ROLLBACK');
652 return false;
653 }
654 }
655
656 /**
657 * Get status counts for attributes
658 */
659 public function getStatusCounts(array $args = []): array
660 {
661 $table = esc_sql($this->table);
662
663 // Clear any query cache
664 wp_cache_flush();
665
666 // Get counts for each status
667 $query = "SELECT status, COUNT(*) as count FROM `{$table}` WHERE type = %s GROUP BY status";
668 $results = $this->wpdb->get_results($this->wpdb->prepare($query, ClassificationTypes::ATTRIBUTE), ARRAY_A);
669
670
671 $counts = [];
672 if (!empty($results) && is_array($results)) {
673 foreach ($results as $row) {
674 if (isset($row['status']) && isset($row['count'])) {
675 $counts[$row['status']] = (int) $row['count'];
676 }
677 }
678 }
679
680 // Ensure we have entries for all main statuses even if count is 0
681 $counts['publish'] = $counts['publish'] ?? 0;
682 $counts['draft'] = $counts['draft'] ?? 0;
683 $counts['trash'] = $counts['trash'] ?? 0;
684
685 return $counts;
686 }
687
688 /**
689 * Check if attributes table exists
690 *
691 * @return bool True if table exists, false otherwise
692 */
693 public function tableExists(): bool
694 {
695 global $wpdb;
696 $attributesTable = $this->getTableName();
697
698 // Check if attributes table exists
699 $tableExists = $wpdb->get_var(
700 $wpdb->prepare("SHOW TABLES LIKE %s", $attributesTable)
701 ) === $attributesTable;
702
703 return $tableExists;
704 }
705
706 /**
707 * Get available attributes for filtering
708 *
709 * @return array Array of available attributes
710 */
711 public function getAvailableAttributes(): array
712 {
713 if (!$this->tableExists()) {
714 return [];
715 }
716
717 // Use QueryCache for caching attributes
718 return $this->cacheQueryResult(Cache::KEY_AVAILABLE_ATTRIBUTES, function() {
719 $formattedAttributes = [];
720 foreach ($this->getFilterableAttributes() as $row) {
721 $meta = !empty($row->metadata) ? json_decode((string) $row->metadata, true) : [];
722 if (!is_array($meta)) {
723 $meta = [];
724 }
725 $fo = $meta['field_options'] ?? null;
726 $fieldOptions = $fo === null ? null : (is_string($fo) ? $fo : wp_json_encode($fo));
727 $formattedAttributes[] = [
728 'id' => (int) $row->id,
729 'name' => (string) ($row->name ?? ''),
730 'field_type' => isset($meta['field_type']) ? (string) $meta['field_type'] : 'text',
731 'field_options' => $fieldOptions,
732 'icon' => $row->icon ?? null,
733 'description' => (string) ($row->description ?? ''),
734 ];
735 }
736
737 return $formattedAttributes;
738 }, Cache::DURATION_ATTRIBUTES); // Cache for 1 hour
739 }
740 }
741