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

diego-ninja / granite / 18882994613

28 Oct 2025 05:05PM UTC coverage: 83.036% (+0.08%) from 82.96%
18882994613

push

github

web-flow
Merge pull request #17 from diego-ninja/hotfix/coderabbit_review

fix: coderabbit suggestions

28 of 35 new or added lines in 1 file covered. (80.0%)

1 existing line in 1 file now uncovered.

2795 of 3366 relevant lines covered (83.04%)

16.45 hits per line

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

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

3
namespace Ninja\Granite;
4

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

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

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

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

70
        // Compute fingerprint eagerly since we can't modify after construction (readonly)
71
        $this->fingerprint = self::computeFingerprint($this->data);
60✔
72
    }
73

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

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

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

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

124
    /**
125
     * Convert to string representation.
126
     *
127
     * @return string JSON representation
128
     */
129
    public function __toString(): string
1✔
130
    {
131
        try {
132
            return $this->json();
1✔
133
        } catch (Throwable) {
×
134
            return '{}';
×
135
        }
136
    }
137

138
    /**
139
     * Debug info for var_dump.
140
     *
141
     * @return array<string, mixed> Data for debugging
142
     */
143
    public function __debugInfo(): array
1✔
144
    {
145
        return $this->data;
1✔
146
    }
147

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

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

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

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

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

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

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

229
        if (is_array($other)) {
3✔
230
            // For arrays, compute hash on the fly using the same canonicalization
231
            /** @phpstan-ignore-next-line */
232
            return $this->fingerprint() === self::computeFingerprint($other);
2✔
233
        }
234

235
        return false;
1✔
236
    }
237

238
    /**
239
     * Check if Pebble is empty.
240
     *
241
     * @return bool True if no data
242
     */
243
    public function isEmpty(): bool
2✔
244
    {
245
        return empty($this->data);
2✔
246
    }
247

248
    /**
249
     * Get the number of properties.
250
     * Implements Countable interface, allowing count($pebble).
251
     *
252
     * @return int Property count
253
     */
254
    public function count(): int
7✔
255
    {
256
        return count($this->data);
7✔
257
    }
258

259
    /**
260
     * Check if a property exists with the given name.
261
     *
262
     * @param string $name Property name
263
     * @return bool True if property exists
264
     */
265
    public function has(string $name): bool
1✔
266
    {
267
        return array_key_exists($name, $this->data);
1✔
268
    }
269

270
    /**
271
     * Get a property with a default value if not found.
272
     *
273
     * @param string $name Property name
274
     * @param mixed $default Default value
275
     * @return mixed Property value or default
276
     */
277
    public function get(string $name, mixed $default = null): mixed
2✔
278
    {
279
        return $this->data[$name] ?? $default;
2✔
280
    }
281

282
    /**
283
     * Get only specified properties.
284
     *
285
     * @param array<string> $keys Property names to extract
286
     * @return self New Pebble with only specified properties
287
     * @throws JsonException
288
     */
289
    public function only(array $keys): self
2✔
290
    {
291
        $filtered = array_intersect_key(
2✔
292
            $this->data,
2✔
293
            array_flip($keys),
2✔
294
        );
2✔
295
        return new self($filtered);
2✔
296
    }
297

298
    /**
299
     * Get all properties except specified ones.
300
     *
301
     * @param array<string> $keys Property names to exclude
302
     * @return self New Pebble without specified properties
303
     * @throws JsonException
304
     */
305
    public function except(array $keys): self
3✔
306
    {
307
        $filtered = array_diff_key(
3✔
308
            $this->data,
3✔
309
            array_flip($keys),
3✔
310
        );
3✔
311
        return new self($filtered);
3✔
312
    }
313

314
    /**
315
     * Create a new Pebble with merged data.
316
     *
317
     * @param array<string, mixed> $data Additional data to merge
318
     * @return self New Pebble with merged data
319
     * @throws JsonException
320
     */
321
    public function merge(array $data): self
5✔
322
    {
323
        return new self(array_merge($this->data, $data));
5✔
324
    }
325

326
    /**
327
     * ArrayAccess: Check if offset exists.
328
     *
329
     * @param mixed $offset Property name
330
     * @return bool True if property exists
331
     */
332
    public function offsetExists(mixed $offset): bool
1✔
333
    {
334
        if ( ! is_int($offset) && ! is_string($offset)) {
1✔
335
            return false;
×
336
        }
337

338
        return array_key_exists($offset, $this->data);
1✔
339
    }
340

341
    /**
342
     * ArrayAccess: Get value at offset.
343
     *
344
     * @param mixed $offset Property name
345
     * @return mixed Property value or null if not found
346
     */
347
    public function offsetGet(mixed $offset): mixed
2✔
348
    {
349
        if ( ! is_string($offset) && ! is_int($offset)) {
2✔
350
            return null;
×
351
        }
352
        return $this->data[$offset] ?? null;
2✔
353
    }
354

355
    /**
356
     * ArrayAccess: Set value at offset (disabled for immutability).
357
     *
358
     * @param mixed $offset Property name
359
     * @param mixed $value Property value
360
     * @throws InvalidArgumentException Always throws, as Pebble is immutable
361
     */
362
    public function offsetSet(mixed $offset, mixed $value): void
1✔
363
    {
364
        throw new InvalidArgumentException(
1✔
365
            'Cannot modify Pebble properties via array access. Pebble objects are immutable. '
1✔
366
            . 'Create a new instance with Pebble::from() or use merge() instead.',
1✔
367
        );
1✔
368
    }
369

370
    /**
371
     * ArrayAccess: Unset value at offset (disabled for immutability).
372
     *
373
     * @param mixed $offset Property name
374
     * @throws InvalidArgumentException Always throws, as Pebble is immutable
375
     */
376
    public function offsetUnset(mixed $offset): void
1✔
377
    {
378
        throw new InvalidArgumentException(
1✔
379
            'Cannot unset Pebble properties via array access. Pebble objects are immutable.',
1✔
380
        );
1✔
381
    }
382

383
    /**
384
     * Extract data from various source types.
385
     *
386
     * @param array<string, mixed>|object|string $source Data source
387
     * @return array<string, mixed> Extracted data
388
     * @throws Exceptions\ReflectionException
389
     * @throws SerializationException
390
     */
391
    private static function extractData(array|object|string $source): array
61✔
392
    {
393
        // Array source
394
        if (is_array($source)) {
61✔
395
            /** @var array<string, mixed> $source */
396
            return $source;
46✔
397
        }
398

399
        // JSON string source
400
        if (is_string($source)) {
16✔
401
            if ( ! json_validate($source)) {
2✔
402
                throw new InvalidArgumentException('Invalid JSON string provided to Pebble::from()');
1✔
403
            }
404

405
            $decoded = json_decode($source, true);
1✔
406
            if ( ! is_array($decoded)) {
1✔
407
                throw new InvalidArgumentException('JSON string must decode to an associative array');
×
408
            }
409

410
            /** @var array<string, mixed> $decoded */
411
            return $decoded;
1✔
412
        }
413

414
        // Object source - try multiple extraction strategies
415
        return self::extractFromObject($source);
14✔
416
    }
417

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

442
        // Strategy 2: toArray() method
443
        if (method_exists($source, 'toArray')) {
11✔
444
            /** @var mixed $result */
445
            $result = $source->toArray();
4✔
446
            /** @var array<string, mixed> $arrayResult */
447
            $arrayResult = is_array($result) ? $result : [];
4✔
448
            return $arrayResult;
4✔
449
        }
450

451
        // Strategy 3: JsonSerializable
452
        if ($source instanceof JsonSerializable) {
7✔
453
            $result = $source->jsonSerialize();
1✔
454
            /** @var array<string, mixed> $arrayResult */
455
            $arrayResult = is_array($result) ? $result : [];
1✔
456
            return $arrayResult;
1✔
457
        }
458

459
        // Strategy 4: Public properties
460
        $data = self::extractPublicProperties($source);
6✔
461

462
        // Strategy 5: Enrich with getter methods
463
        /** @var array<string, mixed> $result */
464
        $result = array_merge($data, self::extractGetters($source, $data));
6✔
465
        return $result;
6✔
466
    }
467

468
    /**
469
     * Extract public properties from an object.
470
     *
471
     * @param object $source Source object
472
     * @return array<string, mixed> Public properties
473
     */
474
    private static function extractPublicProperties(object $source): array
6✔
475
    {
476
        // Use get_object_vars for performance (faster than reflection for public props)
477
        /** @var array<string, mixed> $vars */
478
        $vars = get_object_vars($source);
6✔
479
        return $vars;
6✔
480
    }
481

482
    /**
483
     * Extract data from getter methods.
484
     *
485
     * Converts methods like getName() to 'name' property.
486
     * Only extracts getters that don't already exist in the data.
487
     *
488
     * @param object $source Source object
489
     * @param array<string, mixed> $existingData Existing extracted data
490
     * @return array<string, mixed> Data from getters
491
     */
492
    private static function extractGetters(object $source, array $existingData): array
6✔
493
    {
494
        $getterData = [];
6✔
495
        $methods = get_class_methods($source);
6✔
496

497
        if ([] === $methods) {
6✔
498
            return $getterData;
2✔
499
        }
500

501
        foreach ($methods as $method) {
4✔
502
            // Match getter methods: getName, getEmail, isActive, hasPermission
503
            if ( ! preg_match('/^(get|is|has)([A-Z].*)$/', $method, $matches)) {
4✔
504
                continue;
×
505
            }
506

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

510
            // Skip if already exists in data
511
            if (array_key_exists($propertyName, $existingData)) {
4✔
512
                continue;
1✔
513
            }
514

515
            // Call getter and store value
516
            try {
517
                $reflection = new ReflectionMethod($source, $method);
3✔
518

519
                // Skip if method requires parameters
520
                if ($reflection->getNumberOfRequiredParameters() > 0) {
3✔
521
                    continue;
1✔
522
                }
523

524
                // Skip if not public
525
                if ( ! $reflection->isPublic()) {
3✔
526
                    continue;
×
527
                }
528

529
                // Invoke the getter - may throw runtime exceptions
530
                try {
531
                    $getterData[$propertyName] = $source->{$method}();
3✔
NEW
532
                } catch (Throwable $e) {
×
533
                    // Skip this getter if it throws any exception during invocation
534
                    // This prevents a single failing getter from aborting the entire snapshot
535
                    continue;
3✔
536
                }
UNCOV
537
            } catch (ReflectionException) {
×
538
                // Skip this getter if reflection fails
539
                continue;
×
540
            }
541
        }
542

543
        return $getterData;
4✔
544
    }
545

546
    /**
547
     * Compute a stable fingerprint from array data.
548
     *
549
     * @param array<string, mixed> $data Data to hash
550
     * @return string Hash fingerprint
551
     * @throws JsonException
552
     * @internal
553
     */
554
    private static function computeFingerprint(array $data): string
60✔
555
    {
556
        $normalized = self::normalizeForHash($data);
60✔
557
        $json = json_encode(
60✔
558
            $normalized,
60✔
559
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
60✔
560
        );
60✔
561

562
        $algos = hash_algos();
60✔
563
        $algo = in_array('xxh128', $algos, true)
60✔
564
            ? 'xxh128'
60✔
NEW
565
            : (in_array('xxh3', $algos, true) ? 'xxh3' : 'sha256');
×
566

567
        return hash($algo, $json);
60✔
568
    }
569

570
    /**
571
     * Normalize values and sort assoc keys to make hashing order-insensitive.
572
     *
573
     * @internal
574
     * @param mixed $value Value to normalize
575
     * @return mixed Normalized value
576
     */
577
    private static function normalizeForHash(mixed $value): mixed
60✔
578
    {
579
        if (is_array($value)) {
60✔
580
            $isAssoc = array_keys($value) !== range(0, count($value) - 1);
60✔
581
            if ($isAssoc) {
60✔
582
                ksort($value);
60✔
583
            }
584
            foreach ($value as $k => $v) {
60✔
585
                $value[$k] = self::normalizeForHash($v);
58✔
586
            }
587
            return $value;
60✔
588
        }
589

590
        if ($value instanceof JsonSerializable) {
58✔
NEW
591
            return self::normalizeForHash($value->jsonSerialize());
×
592
        }
593

594
        if ($value instanceof DateTimeInterface) {
58✔
NEW
595
            return $value->format(DATE_ATOM);
×
596
        }
597

598
        if ($value instanceof UnitEnum) {
58✔
599
            /** @phpstan-ignore-next-line */
NEW
600
            return property_exists($value, 'value') ? $value->value : $value->name;
×
601
        }
602

603
        if (is_object($value)) {
58✔
NEW
604
            return self::normalizeForHash(get_object_vars($value));
×
605
        }
606

607
        if (is_resource($value)) {
58✔
NEW
608
            return get_resource_type($value);
×
609
        }
610

611
        return $value;
58✔
612
    }
613
}
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