Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
Drafts
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
6 / 6
23
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 save
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 matchesLive
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 essence
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
12
 delete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Node;
6
7use Celema\Quma\Database;
8
9/**
10 * The working copies of published nodes: one row per node, content in
11 * the shape of `nodes.content`, plus the drafted handle and URL paths.
12 */
13final class Drafts
14{
15    public function __construct(
16        private readonly Database $db,
17    ) {}
18
19    /**
20     * @return ?array{
21     *     node: int,
22     *     content: array<string, mixed>,
23     *     settings: array{handle?: ?string, paths?: array<string, string>},
24     *     created: string,
25     *     changed: string,
26     *     editor: int,
27     * }
28     */
29    public function get(int $node): ?array
30    {
31        $row = $this->db->drafts->get(['node' => $node])->first();
32
33        if (!$row) {
34            return null;
35        }
36
37        $content = json_decode((string) $row['content'], true);
38        $settings = json_decode((string) $row['settings'], true);
39
40        return [
41            'node' => (int) $row['node'],
42            'content' => is_array($content) ? $content : [],
43            'settings' => is_array($settings) ? $settings : [],
44            'created' => (string) $row['created'],
45            'changed' => (string) $row['changed'],
46            'editor' => (int) $row['editor'],
47        ];
48    }
49
50    /**
51     * @param array<string, mixed> $content
52     * @param array{handle?: ?string, paths?: array<string, string>} $settings
53     */
54    public function save(int $node, array $content, array $settings, int $editor): void
55    {
56        $this->db->drafts->upsert([
57            'node' => $node,
58            'content' => json_encode($content),
59            'settings' => json_encode((object) $settings),
60            'editor' => $editor,
61        ])->run();
62    }
63
64    /**
65     * Whether a working copy would leave the live node as it is: the same
66     * content, handle and active URL paths, compared by self::essence().
67     *
68     * @param array<string, mixed> $content
69     * @param array<string, string> $paths
70     */
71    public function matchesLive(int $node, array $content, ?string $handle, array $paths): bool
72    {
73        $live = $this->db->drafts->live(['node' => $node])->one();
74        $livePaths = json_decode((string) $live['paths'], true);
75        $liveContent = json_decode((string) $live['content'], true);
76
77        return (
78            $live['handle'] === $handle
79                && self::essence($livePaths) === self::essence(array_map(trim(...), $paths))
80                && self::essence($liveContent) === self::essence($content)
81        );
82    }
83
84    /**
85     * Content as far as an editor can tell it apart. The form submits
86     * every slot it shows, numbers as text, so empty values and fields
87     * holding nothing but empty values count as absent, and 1041, 1041.0
88     * and '1041' are the same; key order does not count either.
89     */
90    private static function essence(mixed $value): mixed
91    {
92        if (is_int($value) || is_float($value)) {
93            return (string) $value;
94        }
95
96        if ($value === '') {
97            return null;
98        }
99
100        if (!is_array($value)) {
101            return $value;
102        }
103
104        if (array_is_list($value)) {
105            return $value === [] ? null : array_map(self::essence(...), $value);
106        }
107
108        $essence = array_filter(
109            array_map(self::essence(...), $value),
110            static fn(mixed $item): bool => $item !== null,
111        );
112
113        $field = array_key_exists('type', $value) && array_key_exists('value', $value);
114
115        if ($field && !isset($essence['value']) && !isset($essence['meta'])) {
116            return null;
117        }
118
119        ksort($essence);
120
121        return $essence === [] ? null : $essence;
122    }
123
124    /**
125     * Deletes the working copy; its history keeps the last state and, when
126     * the caller says the content went live, records it as published rather
127     * than discarded. Must run inside the caller's transaction: the reason
128     * travels as a transaction-local setting the history trigger reads.
129     */
130    public function delete(int $node, bool $published = false): void
131    {
132        // Set either way: an earlier delete in the same transaction may have
133        // left `published` behind.
134        $this->db->drafts->outcome(['outcome' => $published ? 'published' : 'discarded'])->run();
135        $this->db->drafts->delete(['node' => $node])->run();
136    }
137}