Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
AbstractPredicate
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
9 / 9
18
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFormat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setValues
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getValues
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setConjunction
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getConjunction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 assertNoSubqueryAlias
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 renderValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 renderJsonValue
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 render
n/a
0 / 0
n/a
0 / 0
0
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\AbstractClause;
18use Pop\Db\Sql\AbstractSql;
19
20/**
21 * Abstract 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 */
30abstract class AbstractPredicate
31{
32
33    /**
34     * Format
35     * @var ?string
36     */
37    protected ?string $format = null;
38
39    /**
40     * Values
41     * @var mixed
42     */
43    protected mixed $values = null;
44
45    /**
46     * Conjunction
47     * @var string
48     */
49    protected string $conjunction = 'AND';
50
51    /**
52     * Constructor
53     *
54     * Instantiate the predicate set object
55     *
56     * @param  mixed  $values
57     * @param  string $conjunction
58     * @throws Exception
59     */
60    public function __construct(mixed $values, string $conjunction = 'AND')
61    {
62        $this->setValues($values);
63        $this->setConjunction($conjunction);
64    }
65
66    /**
67     * Get the format
68     *
69     * @return string
70     */
71    public function getFormat(): string
72    {
73        return $this->format;
74    }
75
76    /**
77     * Set values
78     *
79     * @param  mixed  $values
80     * @return AbstractPredicate
81     */
82    public function setValues(mixed $values): AbstractPredicate
83    {
84        $this->values = $values;
85        return $this;
86    }
87
88    /**
89     * Get the values
90     *
91     * @return mixed
92     */
93    public function getValues(): mixed
94    {
95        return $this->values;
96    }
97
98    /**
99     * Get the conjunction
100     *
101     * @param  string $conjunction
102     * @throws Exception
103     * @return AbstractPredicate
104     */
105    public function setConjunction(string $conjunction): AbstractPredicate
106    {
107        if ((strtoupper($conjunction) != 'OR') && (strtoupper($conjunction) != 'AND')) {
108            throw new Exception("Error: The conjunction must be 'AND' or 'OR'. '" . $conjunction . "' is not allowed.");
109        }
110
111        $this->conjunction = $conjunction;
112
113        return $this;
114    }
115
116    /**
117     * Get the conjunction
118     *
119     * @return string
120     */
121    public function getConjunction(): string
122    {
123        return $this->conjunction;
124    }
125
126    /**
127     * Assert that a nested Sql instance used as a subquery value has no alias set.
128     *
129     * An aliased Select renders itself as "(SELECT ...) AS `alias`", which is only valid
130     * in a FROM/JOIN context. Embedded inside a predicate it would produce silently
131     * invalid SQL, so reject it up front with a clear error.
132     *
133     * @param  AbstractSql $value
134     * @throws Exception
135     * @return void
136     */
137    protected static function assertNoSubqueryAlias(AbstractSql $value): void
138    {
139        if (($value instanceof AbstractClause) && ($value->getAlias() !== null)) {
140            throw new Exception(
141                'Error: A Select instance used as a subquery value cannot have an alias set ' .
142                '(an alias is only valid for FROM/JOIN subqueries).'
143            );
144        }
145    }
146
147    /**
148     * Render a single predicate value: a nested Select/Sql instance embeds as a
149     * parenthesized subquery, anything else quotes as a literal/placeholder as before
150     *
151     * @param  AbstractSql $sql
152     * @param  mixed       $value
153     * @throws Exception
154     * @return string
155     */
156    protected static function renderValue(AbstractSql $sql, mixed $value): string
157    {
158        if ($value instanceof AbstractSql) {
159            static::assertNoSubqueryAlias($value);
160            return '(' . $value . ')';
161        }
162
163        return $sql->quote($value);
164    }
165
166    /**
167     * Render a JSON path comparison value
168     *
169     * PostgreSQL's JSON extraction operators ('->>' / '#>>') always return text, and PostgreSQL
170     * has no implicit text-to-number comparison: a bare numeric literal on the right-hand side
171     * fails at execution time with "operator does not exist: text = integer". So on PostgreSQL a
172     * plain scalar value is forced to a quoted text literal, which compares correctly against the
173     * extracted text. Two cases must NOT be force-quoted and are handed to renderValue()
174     * unchanged: a nested Sql instance (which embeds as a parenthesized subquery), and a bound
175     * parameter placeholder token (which the database binds as text anyway).
176     *
177     * @param  AbstractSql $sql
178     * @param  ?string     $column
179     * @param  mixed       $value
180     * @throws Exception
181     * @return string
182     */
183    protected static function renderJsonValue(AbstractSql $sql, ?string $column, mixed $value): string
184    {
185        if (($value instanceof AbstractSql) || !$sql->isPgsql() || $sql->isParameter($value, $column)) {
186            return static::renderValue($sql, $value);
187        }
188
189        $quoted = (string)$sql->quote((string)$value, true);
190
191        // quote()'s forced branch still leaves an all-digit string unquoted, which is exactly the
192        // case that breaks on PostgreSQL, so wrap it. Digits need no escaping.
193        if (!str_starts_with($quoted, "'")) {
194            $quoted = "'" . $quoted . "'";
195        }
196
197        return $quoted;
198    }
199
200    /**
201     * Render the predicate string
202     *
203     * @param  AbstractSql $sql
204     * @return string
205     */
206    abstract public function render(AbstractSql $sql): string;
207
208}