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

diego-ninja / granite / 18881089453

28 Oct 2025 04:00PM UTC coverage: 82.96% (+0.3%) from 82.689%
18881089453

Pull #16

github

web-flow
Merge c1fabef3b into 620442ba4
Pull Request #16: feature: pebble immutable dtro

136 of 151 new or added lines in 6 files covered. (90.07%)

1 existing line in 1 file now uncovered.

2775 of 3345 relevant lines covered (82.96%)

16.16 hits per line

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

89.21
/src/Pebble.php
1
<?php
2

3
namespace Ninja\Granite;
4

5
use ArrayAccess;
6
use Countable;
7
use InvalidArgumentException;
8
use JsonException;
9
use JsonSerializable;
10
use Ninja\Granite\Exceptions\SerializationException;
11
use ReflectionException;
12
use ReflectionMethod;
13
use Throwable;
14

15
/**
16
 * Pebble - A lightweight, immutable data container for quick object snapshots.
17
 *
18
 * Pebble provides a simple way to create readonly, immutable versions of mutable objects
19
 * without the overhead of validation, custom serialization, or type definitions.
20
 *
21
 * Use cases:
22
 * - Quick snapshots of Eloquent models or other mutable objects
23
 * - Lightweight DTOs for simple data transfer
24
 * - Immutable copies for caching or comparison
25
 *
26
 * For advanced features like validation, custom serialization, or type safety,
27
 * use the full Granite class instead.
28
 *
29
 * Example:
30
 * ```php
31
 * $user = User::query()->first(); // Eloquent model (mutable)
32
 * $userSnapshot = Pebble::from($user); // Immutable snapshot
33
 *
34
 * echo $userSnapshot->name;  // Access via magic __get
35
 * echo $userSnapshot->email;
36
 *
37
 * $json = $userSnapshot->json();
38
 * $array = $userSnapshot->array();
39
 * ```
40
 *
41
 * @since 2.1.0
42
 */
43
final readonly class Pebble implements JsonSerializable, ArrayAccess, Countable
44
{
45
    /**
46
     * Internal data storage.
47
     *
48
     * @var array<string, mixed>
49
     */
50
    private array $data;
51

52
    /**
53
     * Cached fingerprint for fast comparisons.
54
     * Computed eagerly at construction.
55
     */
56
    private string $fingerprint;
57

58
    /**
59
     * Private constructor to enforce usage of static factory methods.
60
     *
61
     * @param array<string, mixed> $data Extracted data
62
     */
63
    private function __construct(array $data)
60✔
64
    {
65
        $this->data = $data;
60✔
66

67
        // Compute fingerprint eagerly since we can't modify after construction (readonly)
68
        $serialized = serialize($this->data);
60✔
69

70
        if (function_exists('hash') && in_array('xxh3', hash_algos(), true)) {
60✔
71
            $this->fingerprint = hash('xxh3', $serialized);
60✔
NEW
72
        } elseif (function_exists('hash') && in_array('xxh64', hash_algos(), true)) {
×
NEW
73
            $this->fingerprint = hash('xxh64', $serialized);
×
74
        } else {
NEW
75
            $this->fingerprint = md5($serialized);
×
76
        }
77
    }
78

79
    /**
80
     * Get a property value.
81
     *
82
     * @param string $name Property name
83
     * @return mixed Property value or null if not found
84
     */
85
    public function __get(string $name): mixed
24✔
86
    {
87
        return $this->data[$name] ?? null;
24✔
88
    }
89

90
    /**
91
     * Check if a property exists.
92
     *
93
     * @param string $name Property name
94
     * @return bool True if property exists
95
     */
96
    public function __isset(string $name): bool
1✔
97
    {
98
        return array_key_exists($name, $this->data);
1✔
99
    }
100

101
    /**
102
     * Prevent setting properties (immutability).
103
     *
104
     * @param string $name Property name
105
     * @param mixed $value Property value
106
     * @throws InvalidArgumentException Always throws, as Pebble is immutable
107
     */
108
    public function __set(string $name, mixed $value): void
1✔
109
    {
110
        throw new InvalidArgumentException(
1✔
111
            'Cannot modify Pebble properties. Pebble objects are immutable. '
1✔
112
            . 'Create a new instance with Pebble::from() instead.',
1✔
113
        );
1✔
114
    }
115

116
    /**
117
     * Prevent unsetting properties (immutability).
118
     *
119
     * @param string $name Property name
120
     * @throws InvalidArgumentException Always throws, as Pebble is immutable
121
     */
122
    public function __unset(string $name): void
1✔
123
    {
124
        throw new InvalidArgumentException(
1✔
125
            'Cannot unset Pebble properties. Pebble objects are immutable.',
1✔
126
        );
1✔
127
    }
128

129
    /**
130
     * Convert to string representation.
131
     *
132
     * @return string JSON representation
133
     */
134
    public function __toString(): string
1✔
135
    {
136
        try {
137
            return $this->json();
1✔
NEW
138
        } catch (Throwable) {
×
NEW
139
            return '{}';
×
140
        }
141
    }
142

143
    /**
144
     * Debug info for var_dump.
145
     *
146
     * @return array<string, mixed> Data for debugging
147
     */
148
    public function __debugInfo(): array
1✔
149
    {
150
        return $this->data;
1✔
151
    }
152

153
    /**
154
     * Create an immutable Pebble from various data sources.
155
     *
156
     * Supports:
157
     * - Arrays: ['key' => 'value']
158
     * - Objects: Eloquent models, stdClass, any object with public properties
159
     * - JSON strings: '{"key": "value"}'
160
     * - Granite objects: User::from([...])
161
     *
162
     * @param array<string, mixed>|object|string $source Data source
163
     * @return self Immutable Pebble instance
164
     * @throws Exceptions\ReflectionException
165
     * @throws SerializationException
166
     */
167
    public static function from(array|object|string $source): self
61✔
168
    {
169
        return new self(self::extractData($source));
61✔
170
    }
171

172
    /**
173
     * Get all data as an array.
174
     *
175
     * @return array<string, mixed> Data array
176
     */
177
    public function array(): array
2✔
178
    {
179
        return $this->data;
2✔
180
    }
181

182
    /**
183
     * Convert to JSON string.
184
     *
185
     * @return string JSON representation
186
     * @throws JsonException
187
     */
188
    public function json(): string
3✔
189
    {
190
        return json_encode($this->data, JSON_THROW_ON_ERROR);
3✔
191
    }
192

193
    /**
194
     * JsonSerializable implementation.
195
     *
196
     * @return array<string, mixed> Data for JSON encoding
197
     */
198
    public function jsonSerialize(): array
2✔
199
    {
200
        return $this->data;
2✔
201
    }
202

203
    /**
204
     * Get the fingerprint (hash) of this Pebble's data.
205
     * Used for fast equality comparisons.
206
     *
207
     * @return string 128-bit xxh3 hash as hexadecimal
208
     */
209
    public function fingerprint(): string
9✔
210
    {
211
        return $this->fingerprint;
9✔
212
    }
213

214
    /**
215
     * Compare this Pebble with another Pebble or array.
216
     * Uses fingerprint comparison for Pebble-to-Pebble comparisons for O(1) performance.
217
     *
218
     * @param mixed $other Comparison target
219
     * @return bool True if equal
220
     */
221
    public function equals(mixed $other): bool
7✔
222
    {
223
        if ($this === $other) {
7✔
224
            return true;
1✔
225
        }
226

227
        if ($other instanceof self) {
6✔
228
            // Fast O(1) fingerprint comparison
229
            return $this->fingerprint() === $other->fingerprint();
3✔
230
        }
231

232
        if (is_array($other)) {
3✔
233
            // For arrays, compute hash on the fly
234
            $serialized = serialize($other);
2✔
235

236
            if (function_exists('hash') && in_array('xxh3', hash_algos(), true)) {
2✔
237
                $otherHash = hash('xxh3', $serialized);
2✔
NEW
238
            } elseif (function_exists('hash') && in_array('xxh64', hash_algos(), true)) {
×
NEW
239
                $otherHash = hash('xxh64', $serialized);
×
240
            } else {
NEW
241
                $otherHash = md5($serialized);
×
242
            }
243

244
            return $this->fingerprint() === $otherHash;
2✔
245
        }
246

247
        return false;
1✔
248
    }
249

250
    /**
251
     * Check if Pebble is empty.
252
     *
253
     * @return bool True if no data
254
     */
255
    public function isEmpty(): bool
2✔
256
    {
257
        return empty($this->data);
2✔
258
    }
259

260
    /**
261
     * Get the number of properties.
262
     * Implements Countable interface, allowing count($pebble).
263
     *
264
     * @return int Property count
265
     */
266
    public function count(): int
7✔
267
    {
268
        return count($this->data);
7✔
269
    }
270

271
    /**
272
     * Check if a property exists with the given name.
273
     *
274
     * @param string $name Property name
275
     * @return bool True if property exists
276
     */
277
    public function has(string $name): bool
1✔
278
    {
279
        return array_key_exists($name, $this->data);
1✔
280
    }
281

282
    /**
283
     * Get a property with a default value if not found.
284
     *
285
     * @param string $name Property name
286
     * @param mixed $default Default value
287
     * @return mixed Property value or default
288
     */
289
    public function get(string $name, mixed $default = null): mixed
2✔
290
    {
291
        return $this->data[$name] ?? $default;
2✔
292
    }
293

294
    /**
295
     * Get only specified properties.
296
     *
297
     * @param array<string> $keys Property names to extract
298
     * @return self New Pebble with only specified properties
299
     */
300
    public function only(array $keys): self
2✔
301
    {
302
        $filtered = array_intersect_key(
2✔
303
            $this->data,
2✔
304
            array_flip($keys),
2✔
305
        );
2✔
306
        return new self($filtered);
2✔
307
    }
308

309
    /**
310
     * Get all properties except specified ones.
311
     *
312
     * @param array<string> $keys Property names to exclude
313
     * @return self New Pebble without specified properties
314
     */
315
    public function except(array $keys): self
3✔
316
    {
317
        $filtered = array_diff_key(
3✔
318
            $this->data,
3✔
319
            array_flip($keys),
3✔
320
        );
3✔
321
        return new self($filtered);
3✔
322
    }
323

324
    /**
325
     * Create a new Pebble with merged data.
326
     *
327
     * @param array<string, mixed> $data Additional data to merge
328
     * @return self New Pebble with merged data
329
     */
330
    public function merge(array $data): self
5✔
331
    {
332
        return new self(array_merge($this->data, $data));
5✔
333
    }
334

335
    /**
336
     * ArrayAccess: Check if offset exists.
337
     *
338
     * @param mixed $offset Property name
339
     * @return bool True if property exists
340
     */
341
    public function offsetExists(mixed $offset): bool
1✔
342
    {
343
        if ( ! is_int($offset) && ! is_string($offset)) {
1✔
NEW
344
            return false;
×
345
        }
346

347
        return array_key_exists($offset, $this->data);
1✔
348
    }
349

350
    /**
351
     * ArrayAccess: Get value at offset.
352
     *
353
     * @param mixed $offset Property name
354
     * @return mixed Property value or null if not found
355
     */
356
    public function offsetGet(mixed $offset): mixed
2✔
357
    {
358
        if ( ! is_string($offset) && ! is_int($offset)) {
2✔
NEW
359
            return null;
×
360
        }
361
        return $this->data[$offset] ?? null;
2✔
362
    }
363

364
    /**
365
     * ArrayAccess: Set value at offset (disabled for immutability).
366
     *
367
     * @param mixed $offset Property name
368
     * @param mixed $value Property value
369
     * @throws InvalidArgumentException Always throws, as Pebble is immutable
370
     */
371
    public function offsetSet(mixed $offset, mixed $value): void
1✔
372
    {
373
        throw new InvalidArgumentException(
1✔
374
            'Cannot modify Pebble properties via array access. Pebble objects are immutable. '
1✔
375
            . 'Create a new instance with Pebble::from() or use merge() instead.',
1✔
376
        );
1✔
377
    }
378

379
    /**
380
     * ArrayAccess: Unset value at offset (disabled for immutability).
381
     *
382
     * @param mixed $offset Property name
383
     * @throws InvalidArgumentException Always throws, as Pebble is immutable
384
     */
385
    public function offsetUnset(mixed $offset): void
1✔
386
    {
387
        throw new InvalidArgumentException(
1✔
388
            'Cannot unset Pebble properties via array access. Pebble objects are immutable.',
1✔
389
        );
1✔
390
    }
391

392
    /**
393
     * Extract data from various source types.
394
     *
395
     * @param array<string, mixed>|object|string $source Data source
396
     * @return array<string, mixed> Extracted data
397
     * @throws Exceptions\ReflectionException
398
     * @throws SerializationException
399
     */
400
    private static function extractData(array|object|string $source): array
61✔
401
    {
402
        // Array source
403
        if (is_array($source)) {
61✔
404
            /** @var array<string, mixed> $source */
405
            return $source;
46✔
406
        }
407

408
        // JSON string source
409
        if (is_string($source)) {
16✔
410
            if ( ! json_validate($source)) {
2✔
411
                throw new InvalidArgumentException('Invalid JSON string provided to Pebble::from()');
1✔
412
            }
413

414
            $decoded = json_decode($source, true);
1✔
415
            if ( ! is_array($decoded)) {
1✔
NEW
416
                throw new InvalidArgumentException('JSON string must decode to an associative array');
×
417
            }
418

419
            /** @var array<string, mixed> $decoded */
420
            return $decoded;
1✔
421
        }
422

423
        // Object source - try multiple extraction strategies
424
        return self::extractFromObject($source);
14✔
425
    }
426

427
    /**
428
     * Extract data from an object using various strategies.
429
     *
430
     * Priority order:
431
     * 1. Granite objects - use array() method
432
     * 2. Objects with toArray() method
433
     * 3. Objects implementing JsonSerializable
434
     * 4. Public properties extraction
435
     * 5. Getter methods (getName() -> name)
436
     *
437
     * @param object $source Source object
438
     * @return array<string, mixed> Extracted data
439
     * @throws Exceptions\ReflectionException
440
     * @throws SerializationException
441
     */
442
    private static function extractFromObject(object $source): array
14✔
443
    {
444
        // Strategy 1: Granite objects
445
        if ($source instanceof Granite) {
14✔
446
            /** @var array<string, mixed> $graniteArray */
447
            $graniteArray = $source->array();
3✔
448
            return $graniteArray;
3✔
449
        }
450

451
        // Strategy 2: toArray() method
452
        if (method_exists($source, 'toArray')) {
11✔
453
            /** @var mixed $result */
454
            $result = $source->toArray();
4✔
455
            /** @var array<string, mixed> $arrayResult */
456
            $arrayResult = is_array($result) ? $result : [];
4✔
457
            return $arrayResult;
4✔
458
        }
459

460
        // Strategy 3: JsonSerializable
461
        if ($source instanceof JsonSerializable) {
7✔
462
            /** @var mixed $result */
463
            $result = $source->jsonSerialize();
1✔
464
            /** @var array<string, mixed> $arrayResult */
465
            $arrayResult = is_array($result) ? $result : [];
1✔
466
            return $arrayResult;
1✔
467
        }
468

469
        // Strategy 4: Public properties
470
        $data = self::extractPublicProperties($source);
6✔
471

472
        // Strategy 5: Enrich with getter methods
473
        /** @var array<string, mixed> $result */
474
        $result = array_merge($data, self::extractGetters($source, $data));
6✔
475
        return $result;
6✔
476
    }
477

478
    /**
479
     * Extract public properties from an object.
480
     *
481
     * @param object $source Source object
482
     * @return array<string, mixed> Public properties
483
     */
484
    private static function extractPublicProperties(object $source): array
6✔
485
    {
486
        // Use get_object_vars for performance (faster than reflection for public props)
487
        /** @var array<string, mixed> $vars */
488
        $vars = get_object_vars($source);
6✔
489
        return $vars;
6✔
490
    }
491

492
    /**
493
     * Extract data from getter methods.
494
     *
495
     * Converts methods like getName() to 'name' property.
496
     * Only extracts getters that don't already exist in the data.
497
     *
498
     * @param object $source Source object
499
     * @param array<string, mixed> $existingData Existing extracted data
500
     * @return array<string, mixed> Data from getters
501
     */
502
    private static function extractGetters(object $source, array $existingData): array
6✔
503
    {
504
        $getterData = [];
6✔
505
        $methods = get_class_methods($source);
6✔
506

507
        if ([] === $methods) {
6✔
508
            return $getterData;
2✔
509
        }
510

511
        foreach ($methods as $method) {
4✔
512
            // Match getter methods: getName, getEmail, isActive, hasPermission
513
            if ( ! preg_match('/^(get|is|has)([A-Z].*)$/', $method, $matches)) {
4✔
NEW
514
                continue;
×
515
            }
516

517
            // Convert to property name: getName -> name, isActive -> active
518
            $propertyName = lcfirst($matches[2]);
4✔
519

520
            // Skip if already exists in data
521
            if (array_key_exists($propertyName, $existingData)) {
4✔
522
                continue;
1✔
523
            }
524

525
            // Call getter and store value
526
            try {
527
                $reflection = new ReflectionMethod($source, $method);
3✔
528

529
                // Skip if method requires parameters
530
                if ($reflection->getNumberOfRequiredParameters() > 0) {
3✔
531
                    continue;
1✔
532
                }
533

534
                // Skip if not public
535
                if ( ! $reflection->isPublic()) {
3✔
NEW
536
                    continue;
×
537
                }
538

539
                $getterData[$propertyName] = $source->{$method}();
3✔
NEW
540
            } catch (ReflectionException) {
×
541
                // Skip this getter if reflection fails
NEW
542
                continue;
×
543
            }
544
        }
545

546
        return $getterData;
4✔
547
    }
548
}
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