Passed
Push — master ( 8b0690...6822a9 )
by y
01:22
created

Junction::fromInterface()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 16
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 11
dl 0
loc 16
rs 9.9
c 0
b 0
f 0
cc 4
nc 4
nop 2
1
<?php
2
3
namespace Helix\DB;
4
5
use Helix\DB;
6
use Helix\DB\SQL\Predicate;
7
use LogicException;
8
use ReflectionClass;
9
use ReflectionException;
10
11
/**
12
 * Represents a junction table, derived from an annotated interface.
13
 *
14
 * Interface Annotations:
15
 *
16
 * - `@junction TABLE`
17
 * - `@foreign CLASS COLUMN` or `@for CLASS COLUMN`
18
 */
19
class Junction extends Table {
20
21
    /**
22
     * `[class => column name]`
23
     */
24
    protected $keys = [];
25
26
    /**
27
     * @param DB $db
28
     * @param string $interface
29
     * @return Junction
30
     */
31
    public static function fromInterface (DB $db, string $interface) {
32
        try {
33
            $ref = new ReflectionClass($interface);
34
        }
35
        catch (ReflectionException $exception) {
36
            throw new LogicException('Unexpected ReflectionException', 0, $exception);
37
        }
38
        $doc = $ref->getDocComment();
39
        $keys = [];
40
        foreach (explode("\n", $doc) as $line) {
41
            if (preg_match('/@for(eign)?\s+(?<class>\S+)\s+(?<column>\S+)/', $line, $foreign)) {
42
                $keys[$foreign['class']] = $foreign['column'];
43
            }
44
        }
45
        preg_match('/@junction\s+(?<table>\S+)/', $doc, $junction);
46
        return new static($db, $junction['table'], $keys);
47
    }
48
49
    /**
50
     * @param DB $db
51
     * @param string $table
52
     * @param string[] $keys Columns, keyed by class.
53
     */
54
    public function __construct (DB $db, string $table, array $keys) {
55
        parent::__construct($db, $table, $keys);
56
        $this->keys = $keys;
57
    }
58
59
    /**
60
     * Returns the number of references to an entity in the junction.
61
     *
62
     * @param EntityInterface $entity
63
     * @return int
64
     */
65
    public function count (EntityInterface $entity): int {
66
        $key = $this->getKey($entity);
67
        $count = $this->cache("count.{$key}", function() use ($key) {
68
            return $this->select(['COUNT(*)'])->where("{$key} = ?")->prepare();
69
        });
70
        return $count([$entity->getId()])->fetchColumn();
71
    }
72
73
    /**
74
     * Returns a {@link Select} for entities in a has-many relationship.
75
     *
76
     * The `Select` is literal and can be iterated directly.
77
     *
78
     * @param EntityInterface $owner
79
     * @param string $class
80
     * @return Select
81
     */
82
    public function getCollection (EntityInterface $owner, string $class): Select {
83
        $record = $this->db->getRecord($class);
84
        $select = $record->select();
85
        $select->join($this, $this[$class]->isEqual($record['id']));
86
        $select->where($this[get_class($owner)]->isEqual($owner->getId()));
87
        return $select;
88
    }
89
90
    /**
91
     * Returns the foreign key column name specified in `@foreign CLASS COLUMN`.
92
     *
93
     * @param EntityInterface|string $class
94
     * @return string
95
     */
96
    public function getKey ($class): string {
97
        if (is_object($class)) {
98
            $class = get_class($class);
99
        }
100
        return $this->keys[$class];
101
    }
102
103
    /**
104
     * `INSERT IGNORE` to link entities.
105
     *
106
     * One entity for each column must be given.
107
     *
108
     * The given array doesn't need to be keyed in any special way,
109
     * the columns are looked up based on class.
110
     *
111
     * @param EntityInterface[] $entities
112
     * @return int Rows affected.
113
     */
114
    public function link (array $entities): int {
115
        $link = $this->cache(__FUNCTION__, function() {
116
            $slots = implode(',', SQL::slots($this->keys));
117
            $columns = implode(',', $this->keys);
118
            if ($this->db->getDriver() === 'sqlite') {
119
                return $this->db->prepare("INSERT OR IGNORE INTO {$this} ({$columns}) VALUES ({$slots})");
120
            }
121
            return $this->db->prepare("INSERT IGNORE INTO {$this} ({$columns}) VALUES ({$slots})");
122
        });
123
        $ids = array_fill_keys($this->keys, null);
124
        foreach ($entities as $entity) {
125
            $ids[$this->getKey($entity)] = $entity->getId();
126
        }
127
        return $link($ids)->rowCount();
128
    }
129
130
    /**
131
     * Allows lookup by class name.
132
     *
133
     * @param string $name
134
     * @return bool
135
     */
136
    public function offsetExists ($name): bool {
137
        return isset($this->columns[$name]) or isset($this->keys[$name]);
138
    }
139
140
    /**
141
     * Allows lookup by class name.
142
     *
143
     * @param string $name
144
     * @return Column
145
     */
146
    public function offsetGet ($name): Column {
147
        return $this->columns[$name] ?? $this->columns[$this->keys[$name]];
148
    }
149
150
    /**
151
     * Removes rows that contain all of the given entities.
152
     *
153
     * The given array doesn't need to be keyed in any special way,
154
     * the columns are looked up based on class.
155
     *
156
     * @param EntityInterface[] $entities
157
     * @return int Rows affected.
158
     */
159
    public function unlink (array $entities): int {
160
        $unlink = $this->cache(__FUNCTION__, function() {
161
            $slots = Predicate::all(SQL::slotsEqual($this->keys));
162
            return $this->db->prepare("DELETE FROM {$this} WHERE {$slots}");
163
        });
164
        $ids = array_fill_keys($this->keys, null);
165
        foreach ($entities as $entity) {
166
            $ids[$this->getKey($entity)] = $entity->getId();
167
        }
168
        return $unlink($ids)->rowCount();
169
    }
170
}