PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 2.0.11 All 82 releases
yatra / app / Repositories / AttributeRepository.php

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

749 lines 23.3 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 trip attributes table name
615 */
616 public function getTripAttributesTableName(): string
617 {
618 return \Yatra\Database\Tables\TripClassificationsTable::getTableName();
619 }
620
621 /**
622 * Get max display order
623 */
624 public function getMaxDisplayOrder(): int
625 {
626 $table = esc_sql($this->table);
627
628 return (int) $this->wpdb->get_var(
629 $this->wpdb->prepare(
630 "SELECT MAX(sorting) FROM `{$table}` WHERE type = %s",
631 ClassificationTypes::ATTRIBUTE
632 )
633 );
634 }
635
636 /**
637 * Update display orders
638 */
639 public function updateDisplayOrders(array $orders): bool
640 {
641 $table = esc_sql($this->table);
642
643 $this->wpdb->query('START TRANSACTION');
644
645 try {
646 foreach ($orders as $id => $order) {
647 $this->wpdb->update(
648 $table,
649 ['sorting' => $order, 'updated_at' => current_time('mysql')],
650 ['id' => $id, 'type' => ClassificationTypes::ATTRIBUTE],
651 ['%d', '%s'],
652 ['%d', '%s']
653 );
654 }
655
656 $this->wpdb->query('COMMIT');
657 return true;
658 } catch (\Exception $e) {
659 $this->wpdb->query('ROLLBACK');
660 return false;
661 }
662 }
663
664 /**
665 * Get status counts for attributes
666 */
667 public function getStatusCounts(array $args = []): array
668 {
669 $table = esc_sql($this->table);
670
671 // Clear any query cache
672 wp_cache_flush();
673
674 // Get counts for each status
675 $query = "SELECT status, COUNT(*) as count FROM `{$table}` WHERE type = %s GROUP BY status";
676 $results = $this->wpdb->get_results($this->wpdb->prepare($query, ClassificationTypes::ATTRIBUTE), ARRAY_A);
677
678
679 $counts = [];
680 if (!empty($results) && is_array($results)) {
681 foreach ($results as $row) {
682 if (isset($row['status']) && isset($row['count'])) {
683 $counts[$row['status']] = (int) $row['count'];
684 }
685 }
686 }
687
688 // Ensure we have entries for all main statuses even if count is 0
689 $counts['publish'] = $counts['publish'] ?? 0;
690 $counts['draft'] = $counts['draft'] ?? 0;
691 $counts['trash'] = $counts['trash'] ?? 0;
692
693 return $counts;
694 }
695
696 /**
697 * Check if attributes table exists
698 *
699 * @return bool True if table exists, false otherwise
700 */
701 public function tableExists(): bool
702 {
703 global $wpdb;
704 $attributesTable = $this->getTableName();
705
706 // Check if attributes table exists
707 $tableExists = $wpdb->get_var(
708 $wpdb->prepare("SHOW TABLES LIKE %s", $attributesTable)
709 ) === $attributesTable;
710
711 return $tableExists;
712 }
713
714 /**
715 * Get available attributes for filtering
716 *
717 * @return array Array of available attributes
718 */
719 public function getAvailableAttributes(): array
720 {
721 if (!$this->tableExists()) {
722 return [];
723 }
724
725 // Use QueryCache for caching attributes
726 return $this->cacheQueryResult(Cache::KEY_AVAILABLE_ATTRIBUTES, function() {
727 $formattedAttributes = [];
728 foreach ($this->getFilterableAttributes() as $row) {
729 $meta = !empty($row->metadata) ? json_decode((string) $row->metadata, true) : [];
730 if (!is_array($meta)) {
731 $meta = [];
732 }
733 $fo = $meta['field_options'] ?? null;
734 $fieldOptions = $fo === null ? null : (is_string($fo) ? $fo : wp_json_encode($fo));
735 $formattedAttributes[] = [
736 'id' => (int) $row->id,
737 'name' => (string) ($row->name ?? ''),
738 'field_type' => isset($meta['field_type']) ? (string) $meta['field_type'] : 'text',
739 'field_options' => $fieldOptions,
740 'icon' => $row->icon ?? null,
741 'description' => (string) ($row->description ?? ''),
742 ];
743 }
744
745 return $formattedAttributes;
746 }, Cache::DURATION_ATTRIBUTES); // Cache for 1 hour
747 }
748 }
749