• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ICanBoogie / ActiveRecord / 8582028303

06 Apr 2024 03:09PM UTC coverage: 83.938%. First build
8582028303

push

github

olvlvl
Revision of how records are saved

24 of 47 new or added lines in 5 files covered. (51.06%)

1343 of 1600 relevant lines covered (83.94%)

23.53 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

69.57
/lib/ActiveRecord.php
1
<?php
2

3
/*
4
 * This file is part of the ICanBoogie package.
5
 *
6
 * (c) Olivier Laviale <olivier.laviale@gmail.com>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11

12
namespace ICanBoogie;
13

14
use ICanBoogie\ActiveRecord\Model;
15
use ICanBoogie\ActiveRecord\Query;
16
use ICanBoogie\ActiveRecord\RecordNotValid;
17
use ICanBoogie\ActiveRecord\Schema;
18
use ICanBoogie\ActiveRecord\Schema\Serial;
19
use ICanBoogie\ActiveRecord\StaticModelProvider;
20
use ICanBoogie\Validate\ValidationErrors;
21
use LogicException;
22
use ReflectionException;
23
use Throwable;
24

25
use function array_keys;
26
use function is_array;
27

28
/**
29
 * Active Record facilitates the creation and use of business objects whose data require persistent
30
 * storage via database.
31
 *
32
 * @method ValidationErrors validate() Validate the active record, returns an array of errors.
33
 *
34
 * @property-read Model $model The model managing the active record.
35
 * @see self::get_model()
36
 * @property-read bool $is_new Whether the record is new or not.
37
 * @see self::get_is_new()
38
 * @property-read TKey $primary_key_value The value of the primary key.
39
 * @see self::get_primary_key_value()
40
 *
41
 * @template TKey of int|non-empty-string|non-empty-array<non-empty-string>
42
 */
43
abstract class ActiveRecord extends Prototyped
44
{
45
    /**
46
     * Returns a new query.
47
     *
48
     * @return Query<static>
49
     *
50
     * @see Model::query()
51
     */
52
    final public static function query(): Query
53
    {
54
        return StaticModelProvider::model_for_record(static::class)->query();
4✔
55
    }
56

57
    /**
58
     * Returns a new query with the WHERE clause initialized with the provided conditions and arguments.
59
     *
60
     * @param mixed ...$conditions_and_args
61
     *
62
     * @return Query<static>
63
     *
64
     * @see Query::where()
65
     */
66
    final public static function where(...$conditions_and_args): Query
67
    {
68
        return self::query()->where(...$conditions_and_args);
2✔
69
    }
70

71
    /**
72
     * Model managing the active record.
73
     *
74
     * @var Model<TKey, static>
75
     */
76
    private Model $model;
77

78
    /**
79
     * @return Model<TKey, static>
80
     */
81
    protected function get_model(): Model
82
    {
83
        return $this->model
9✔
84
            ??= StaticModelProvider::model_for_record($this::class);
9✔
85
    }
86

87
    /**
88
     * @return mixed&TKey
89
     */
90
    protected function get_primary_key_value(): mixed
91
    {
92
        $model = $this->get_model();
4✔
93
        $primary = $model->extended_schema->primary;
4✔
94

95
        if (is_array($primary)) {
4✔
NEW
96
            $actual = [];
×
97

NEW
98
            foreach ($primary as $property) {
×
NEW
99
                $actual[] = $this->$property;
×
100
            }
101

NEW
102
            return $actual;
×
103
        }
104

105
        return $this->$primary;
4✔
106
    }
107

108
    /**
109
     * @param ?Model<TKey, static> $model
110
     *     The model managing the active record. A {@link Model} instance can be specified as well as a model
111
     *     identifier. If `$model` is null, the model will be resolved with {@link StaticModelProvider} when required.
112
     */
113
    public function __construct(Model $model = null)
114
    {
115
        if ($model) {
30✔
116
            $this->model = $model;
20✔
117
        }
118
    }
119

120
    /**
121
     * Removes the {@link $model} property.
122
     *
123
     * Properties whose value are instances of the {@link ActiveRecord} class are removed from the
124
     * exported properties.
125
     *
126
     * @return array<non-empty-string, mixed>
127
     *
128
     * @throws ReflectionException
129
     */
130
    public function __sleep() // @phpstan-ignore-line
131
    {
132
        $properties = parent::__sleep();
2✔
133

134
        /** @phpstan-ignore-next-line */
135
        unset($properties['model']);
2✔
136

137
        foreach (array_keys($properties) as $property) {
2✔
138
            if ($this->$property instanceof self) {
×
139
                unset($properties[$property]);
×
140
            }
141
        }
142

143
        return $properties;
2✔
144
    }
145

146
    /**
147
     * Removes `model` from the output.
148
     *
149
     * @return array<non-empty-string, mixed>
150
     */
151
    public function __debugInfo(): array
152
    {
153
        $array = (array)$this;
1✔
154

155
        unset($array["\0" . __CLASS__ . "\0model"]);
1✔
156

157
        return $array;
1✔
158
    }
159

160
    /**
161
     * Whether the record is new or not.
162
     */
163
    protected function get_is_new(): bool
164
    {
165
        $primary = $this->get_model()->primary;
×
166

167
        if (is_array($primary)) {
×
168
            foreach ($primary as $property) {
×
169
                if (empty($this->$property)) {
×
170
                    return true;
×
171
                }
172
            }
173
        } elseif (empty($this->$primary)) {
×
174
            return true;
×
175
        }
176

177
        return false;
×
178
    }
179

180
    /**
181
     * Saves the active record using its model.
182
     *
183
     * @return mixed&TKey
184
     *
185
     * @throws Throwable
186
     */
187
    public function save(bool $skip_validation = false): mixed
188
    {
189
        if (!$skip_validation) {
5✔
190
            $this->assert_is_valid();
5✔
191
        }
192

193
        $model = $this->get_model();
4✔
194
        $schema = $model->extended_schema;
4✔
195
        $properties = $this->alter_persistent_properties($this->to_array(), $schema);
4✔
196

197
        if (count($properties) == 0) {
4✔
NEW
198
            throw new LogicException("No properties to save");
×
199
        }
200

201
        #
202
        # Multi-column primary key
203
        #
204

205
        $primary = $model->primary;
4✔
206

207
        if (is_array($primary)) {
4✔
NEW
208
            $model->insert($properties, upsert: true);
×
209

NEW
210
            return $this->get_primary_key_value();
×
211
        }
212

213
        #
214
        # Non auto-increment primary key, unless the key is inherited from parent model.
215
        #
216

217
        if (
218
            !$model->parent && $primary && isset($properties[$primary])
4✔
219
            && !$model->extended_schema->columns[$primary] instanceof Serial
4✔
220
        ) {
NEW
221
            $model->insert($properties, upsert: true);
×
222

NEW
223
            return $this->get_primary_key_value();
×
224
        }
225

226
        #
227
        # Serial primary key
228
        #
229

230
        $key = null;
4✔
231

232
        if (isset($properties[$primary])) {
4✔
233
            $key = $properties[$primary];
1✔
234
            unset($properties[$primary]);
1✔
235
        }
236

237
        $rc = $model->save($properties, $key);
4✔
238

239
        if ($key === null) {
4✔
240
            $this->$primary = $rc;
4✔
241
        }
242

243
        return $this->get_primary_key_value();
4✔
244
    }
245

246
    /**
247
     * Assert that a record is valid.
248
     *
249
     * @throws RecordNotValid if the record is not valid.
250
     */
251
    public function assert_is_valid(): void
252
    {
253
        $errors = $this->validate();
5✔
254

255
        if (count($errors)) {
5✔
256
            throw new RecordNotValid($this, $errors);
1✔
257
        }
258
    }
259

260
    /**
261
     * Creates validation rules.
262
     *
263
     * @return array<string, mixed>
264
     */
265
    public function create_validation_rules(): array
266
    {
267
        return [];
8✔
268
    }
269

270
    /**
271
     * Unless it's an acceptable value for a column, columns with `null` values are discarded.
272
     * This way, we don't have to define every property before saving our active record.
273
     *
274
     * @param array<non-empty-string, mixed> $properties
275
     * @param Schema $schema The model's extended schema.
276
     *
277
     * @return array<non-empty-string, mixed> The altered persistent properties
278
     */
279
    protected function alter_persistent_properties(array $properties, Schema $schema): array
280
    {
281
        foreach ($properties as $identifier => $value) {
4✔
282
            if ($value !== null || ($schema->has_column($identifier) && $schema->columns[$identifier]->null)) {
4✔
283
                continue;
4✔
284
            }
285

286
            unset($properties[$identifier]);
×
287
        }
288

289
        return $properties;
4✔
290
    }
291

292
    /**
293
     * Deletes the active record using its model.
294
     *
295
     * @return bool `true` if the record was deleted, `false` otherwise.
296
     *
297
     * @throws LogicException in attempt to delete a record from a model which primary key is empty.
298
     */
299
    public function delete(): bool
300
    {
301
        $model = $this->get_model();
1✔
302
        $model_class = $model::class;
1✔
303
        $primary = $model->primary
1✔
304
            ?? throw new LogicException("Unable to delete record, model `$model_class` doesn't have a primary key");
×
305
        $key = $this->$primary
1✔
306
            ?? throw new LogicException("Unable to delete record, the primary key is not defined");
1✔
307

308
        return $model->delete($key);
309
    }
310
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc