Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
JsonContains
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
4 / 4
12
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getCandidate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasCandidate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Db\Sql\Predicate;
16
17use Pop\Db\Sql\AbstractSql;
18use Pop\Db\Sql\JsonExtract;
19
20/**
21 * Json Contains predicate class
22 *
23 * @category   Pop
24 * @package    Pop\Db
25 * @author     Nick Sagona, III <nick@popphp.org>
26 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
27 * @license    https://www.popphp.org/license     New BSD License
28 * @version    7.0.0
29 */
30class JsonContains extends AbstractPredicate
31{
32
33    /**
34     * Candidate value to test for containment
35     *
36     * Deliberately stored OUTSIDE of $this->values: the containment candidate is a raw PHP
37     * value that gets json_encode()'d verbatim at render time, never a bound-parameter
38     * placeholder token. PredicateSet::addPredicate() walks every element of a predicate's
39     * $values array through AbstractSql::isParameter()/getParameter(), which would silently
40     * rewrite candidates such as true, '?', '$1' (or arrays containing them) into a dialect
41     * placeholder token - corrupting the rendered SQL and, on PostgreSQL, desyncing the
42     * parameter-number sequence for unrelated sibling predicates. Keeping it off the values
43     * array keeps it out of that loop entirely.
44     *
45     * @var mixed
46     */
47    protected mixed $candidate = null;
48
49    /**
50     * Flag denoting whether a candidate value was supplied
51     * @var bool
52     */
53    protected bool $hasCandidate = false;
54
55    /**
56     * Constructor
57     *
58     * Instantiate the JSON CONTAINS predicate set object
59     *
60     * @param  array  $values
61     * @param  string $conjunction
62     * @throws Exception
63     */
64    public function __construct(array $values, string $conjunction = 'AND')
65    {
66        // Split the raw candidate value off into its own property (see $candidate above),
67        // leaving only [column, path] - two plain strings - in $this->values.
68        if (count($values) == 3) {
69            $values             = array_values($values);
70            $this->candidate    = $values[2];
71            $this->hasCandidate = true;
72            $values             = [$values[0], $values[1]];
73        }
74
75        parent::__construct($values, $conjunction);
76    }
77
78    /**
79     * Get the candidate value
80     *
81     * @return mixed
82     */
83    public function getCandidate(): mixed
84    {
85        return $this->candidate;
86    }
87
88    /**
89     * Determine if a candidate value was supplied
90     *
91     * @return bool
92     */
93    public function hasCandidate(): bool
94    {
95        return $this->hasCandidate;
96    }
97
98    /**
99     * Render the predicate string
100     *
101     * @param  AbstractSql $sql
102     * @throws Exception
103     * @return string
104     */
105    public function render(AbstractSql $sql): string
106    {
107        if (!is_array($this->values) || (count($this->values) != 2) || !$this->hasCandidate) {
108            throw new Exception('Error: The values array must have 3 values in it (column, path, value).');
109        }
110
111        [$column, $path] = $this->values;
112        $encoded         = json_encode($this->candidate);
113
114        if ($encoded === false) {
115            throw new Exception('Error: The value for JSON containment could not be encoded as JSON.');
116        }
117
118        // Force quoting: a JSON-encoded string value (e.g. '"admin"') starts and ends with the
119        // same character PostgreSQL uses to quote identifiers, which would otherwise fool the
120        // non-forced quote() heuristic into treating it as "already quoted" and skip wrapping it.
121        $candidate = (string)$sql->quote($encoded, true);
122
123        // quote()'s forced branch still leaves an all-digit string unquoted (json_encode(5)
124        // === '5'), and PostgreSQL cannot cast a bare integer literal to jsonb. Wrap it so the
125        // candidate is always a quoted JSON document literal. Digits need no escaping.
126        if (!str_starts_with($candidate, "'")) {
127            $candidate = "'" . $candidate . "'";
128        }
129
130        if ($sql->isMysql()) {
131            $rendered = 'JSON_CONTAINS(' . $sql->quoteId($column) . ', ' . $candidate . ', ' . $sql->quote($path) . ')';
132        } else if ($sql->isPgsql()) {
133            $segments = JsonExtract::parsePathSegments($path);
134            $pgPath   = '{' . implode(',', $segments) . '}';
135            $rendered = '(' . $sql->quoteId($column) . ' #> ' . $sql->quote($pgPath) . ') @> ' . $candidate . '::jsonb';
136        } else {
137            throw new Exception('Error: JSON containment is not supported on this database type.');
138        }
139
140        return '(' . $rendered . ')';
141    }
142
143}