Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions lib/PicoDb/Builder/BaseConditionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -479,4 +479,104 @@ public function notNull($column)
{
$this->addCondition($this->db->escapeIdentifier($column).' IS NOT NULL');
}

/**
* Normalize a JSON path to JSONPath format ($.key).
* Accepts 'key', '$.key', 'key1.key2', or '$.key1.key2'.
*
* @access private
* @param string $path
* @return string
*/
private function normalizeJsonPath(string $path): string
{
return str_starts_with($path, '$') ? $path : '$.'.$path;
}

/**
* JSON field equality condition
*
* Compares a scalar value extracted from a JSON column at the given JSONPath.
*
* @access public
* @param string $column Column name
* @param string $path JSONPath expression (e.g. '$.key' or '$.key1.key2')
* @param mixed $value Value to compare against
*/
public function jsonEq(string $column, string $path, $value): void
{
[$sql, $preValueBindings] = $this->db->getDriver()->buildJsonExtractCondition(
$this->db->escapeIdentifier($column),
$this->normalizeJsonPath($path)
);

$this->addCondition($sql);
$this->values = array_merge($this->values, $preValueBindings, [$value]);
}

/**
* JSON field inequality condition
*
* @access public
* @param string $column Column name
* @param string $path JSONPath expression (e.g. 'key' or '$.key' or '$.key1.key2')
* @param mixed $value Value to compare against
*/
public function jsonNeq(string $column, string $path, $value): void
{
[$sql, $preValueBindings] = $this->db->getDriver()->buildJsonExtractCondition(
$this->db->escapeIdentifier($column),
$this->normalizeJsonPath($path),
'!='
);

$this->addCondition($sql);
$this->values = array_merge($this->values, $preValueBindings, [$value]);
}

/**
* JSON array containment condition
*
* Checks that all elements of $values exist in the JSON array stored in $column,
* optionally at a JSONPath within the column.
*
* @access public
* @param string $column Column name
* @param array $values Values that must all be present in the JSON array
* @param string|null $path JSONPath expression, or null to target the column directly
*/
public function jsonContains(string $column, array $values, ?string $path = null): void
{
[$sql, $bindings] = $this->db->getDriver()->buildJsonContainsCondition(
$this->db->escapeIdentifier($column),
$path !== null ? $this->normalizeJsonPath($path) : null,
$values
);

$this->addCondition($sql);
$this->values = array_merge($this->values, $bindings);
}

/**
* JSON array non-containment condition
*
* The inverse of jsonContains — matches rows where the JSON array does NOT
* contain all of the given values.
*
* @access public
* @param string $column Column name
* @param array $values Values that must not all be present in the JSON array
* @param string|null $path JSONPath expression, or null to target the column directly
*/
public function jsonNotContains(string $column, array $values, ?string $path = null): void
{
[$sql, $bindings] = $this->db->getDriver()->buildJsonContainsCondition(
$this->db->escapeIdentifier($column),
$path !== null ? $this->normalizeJsonPath($path) : null,
$values
);

$this->addCondition('NOT ('.$sql.')');
$this->values = array_merge($this->values, $bindings);
}
}
33 changes: 33 additions & 0 deletions lib/PicoDb/Driver/Base.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,39 @@ abstract public function escape($identifier);
*/
abstract public function getOperator($operator);

/**
* Build a JSON field equality condition
*
* Returns [string $sql, array $preValueBindings] where $sql contains a trailing ?
* for the comparison value, and $preValueBindings contains any path-related bindings
* that must be merged before the value.
*
* @abstract
* @access public
* @param string $column Escaped column identifier
* @param string $path JSONPath expression (e.g. '$.key' or '$.key1.key2')
* @param string $operator Comparison operator, defaults to '='
* @return array{0: string, 1: array}
*/
abstract public function buildJsonExtractCondition(string $column, string $path, string $operator = '='): array;

/**
* Build a JSON array containment condition
*
* Checks that all elements of $values exist in the JSON array stored in $column
* (optionally at $path within the column).
*
* Returns [string $sql, array $bindings] — a complete condition with all bindings included.
*
* @abstract
* @access public
* @param string $column Escaped column identifier
* @param string|null $path JSONPath expression, or null to target the column directly
* @param array $values The values that must all be present in the JSON array
* @return array{0: string, 1: array}
*/
abstract public function buildJsonContainsCondition(string $column, ?string $path, array $values): array;

/**
* Get last inserted id
*
Expand Down
19 changes: 19 additions & 0 deletions lib/PicoDb/Driver/Mssql.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,25 @@ public function getOperator($operator)
return '';
}

public function buildJsonExtractCondition(string $column, string $path, string $operator = '='): array
{
return ['JSON_VALUE('.$column.', ?) '.$operator.' ?', [$path]];
}

public function buildJsonContainsCondition(string $column, ?string $path, array $values): array
{
$count = count($values);
$placeholders = implode(', ', array_fill(0, $count, '?'));

if ($path === null) {
$sql = '(SELECT COUNT(*) FROM OPENJSON('.$column.') WHERE value IN ('.$placeholders.')) = '.$count;
return [$sql, $values];
}

$sql = '(SELECT COUNT(*) FROM OPENJSON('.$column.', ?) WHERE value IN ('.$placeholders.')) = '.$count;
return [$sql, array_merge([$path], $values)];
}

/**
* Get last inserted id
*
Expand Down
16 changes: 16 additions & 0 deletions lib/PicoDb/Driver/Mysql.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,22 @@ public function getOperator($operator)
return '';
}

public function buildJsonExtractCondition(string $column, string $path, string $operator = '='): array
{
return ['JSON_UNQUOTE(JSON_EXTRACT('.$column.', ?)) '.$operator.' ?', [$path]];
}

public function buildJsonContainsCondition(string $column, ?string $path, array $values): array
{
$placeholders = implode(', ', array_fill(0, count($values), '?'));

if ($path === null) {
return ['JSON_CONTAINS('.$column.', JSON_ARRAY('.$placeholders.'))', $values];
}

return ['JSON_CONTAINS(JSON_EXTRACT('.$column.', ?), JSON_ARRAY('.$placeholders.'))', array_merge([$path], $values)];
}

/**
* Get last inserted id
*
Expand Down
38 changes: 38 additions & 0 deletions lib/PicoDb/Driver/Postgres.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,44 @@ public function getOperator($operator)
return '';
}

/**
* Convert a JSONPath expression ($.key or $.key1.key2) to a Postgres path value
* and select the appropriate operator.
*
* Single-key paths use ->> / -> operators; nested paths use #>> / #> operators
* with a path literal like {key1,key2}.
*
* @param string $path JSONPath expression
* @return array{0: string, 1: string, 2: string} [path value, text operator, jsonb operator]
*/
private function convertJsonPath(string $path): array
{
$stripped = substr($path, 2); // strip leading '$.'
$parts = explode('.', $stripped);

if (count($parts) === 1) {
return [$parts[0], '->>', '->'];
}

return ['{'.implode(',', $parts).'}', '#>>', '#>'];
}

public function buildJsonExtractCondition(string $column, string $path, string $operator = '='): array
{
[$pgPath, $textOp] = $this->convertJsonPath($path);
return [$column.$textOp.'? '.$operator.' ?', [$pgPath]];
}

public function buildJsonContainsCondition(string $column, ?string $path, array $values): array
{
if ($path === null) {
return [$column.' @> ?::jsonb', [json_encode($values)]];
}

[$pgPath, , $jsonbOp] = $this->convertJsonPath($path);
return [$column.$jsonbOp.'? @> ?::jsonb', [$pgPath, json_encode($values)]];
}

/**
* Get last inserted id
*
Expand Down
16 changes: 16 additions & 0 deletions lib/PicoDb/Driver/Sqlite.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@ public function getOperator($operator)
return '';
}

public function buildJsonExtractCondition(string $column, string $path, string $operator = '='): array
{
return ['JSON_EXTRACT('.$column.', ?) '.$operator.' ?', [$path]];
}

public function buildJsonContainsCondition(string $column, ?string $path, array $values): array
{
$count = count($values);
$placeholders = implode(', ', array_fill(0, $count, '?'));
$target = $path !== null ? 'JSON_EXTRACT('.$column.', ?)' : $column;
$sql = '(SELECT COUNT(*) FROM JSON_EACH('.$target.') WHERE value IN ('.$placeholders.')) = '.$count;
$bindings = $path !== null ? array_merge([$path], $values) : $values;

return [$sql, $bindings];
}

/**
* Get last inserted id
*
Expand Down
4 changes: 4 additions & 0 deletions lib/PicoDb/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
* @method $this notBetween($column, $lowValue, $highValue)
* @method $this isNull($column)
* @method $this notNull($column)
* @method $this jsonEq(string $column, string $path, mixed $value)
* @method $this jsonNeq(string $column, string $path, mixed $value)
* @method $this jsonContains(string $column, array $values, ?string $path = null)
* @method $this jsonNotContains(string $column, array $values, ?string $path = null)
*/
class Table
{
Expand Down
76 changes: 76 additions & 0 deletions tests/MssqlTableTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -671,4 +671,80 @@ public function testHashTable()
$this->db->hashtable('foobar')->getAll('column1', 'column2')
);
}

public function testJsonEq()
{
$this->assertNotFalse($this->db->execute('CREATE TABLE foobar (label VARCHAR(50), data NVARCHAR(MAX))'));
$this->assertTrue($this->db->table('foobar')->insert(['label' => 'first', 'data' => '{"user":"alice","address":{"city":"NYC"}}']));
$this->assertTrue($this->db->table('foobar')->insert(['label' => 'second', 'data' => '{"user":"bob","address":{"city":"LA"}}']));

// explicit JSONPath
$this->assertEquals('first', $this->db->table('foobar')->jsonEq('data', '$.user', 'alice')->findOneColumn('label'));
$this->assertEquals('second', $this->db->table('foobar')->jsonEq('data', '$.user', 'bob')->findOneColumn('label'));
// bare key normalises to same result
$this->assertEquals('first', $this->db->table('foobar')->jsonEq('data', 'user', 'alice')->findOneColumn('label'));
// nested path
$this->assertEquals('first', $this->db->table('foobar')->jsonEq('data', 'address.city', 'NYC')->findOneColumn('label'));
// no match
$this->assertFalse($this->db->table('foobar')->jsonEq('data', 'user', 'charlie')->findOneColumn('label'));
}

public function testJsonNeq()
{
$this->assertNotFalse($this->db->execute('CREATE TABLE foobar (label VARCHAR(50), data NVARCHAR(MAX))'));
$this->assertTrue($this->db->table('foobar')->insert(['label' => 'first', 'data' => '{"user":"alice"}']));
$this->assertTrue($this->db->table('foobar')->insert(['label' => 'second', 'data' => '{"user":"bob"}']));

$this->assertEquals(1, $this->db->table('foobar')->jsonNeq('data', 'user', 'alice')->count());
$this->assertEquals('second', $this->db->table('foobar')->jsonNeq('data', 'user', 'alice')->findOneColumn('label'));
$this->assertEquals(2, $this->db->table('foobar')->jsonNeq('data', 'user', 'charlie')->count());
}

public function testJsonContainsOnColumn()
{
$this->assertNotFalse($this->db->execute('CREATE TABLE foobar (label VARCHAR(50), tags NVARCHAR(MAX))'));
$this->assertTrue($this->db->table('foobar')->insert(['label' => 'first', 'tags' => '["php","js","mysql"]']));
$this->assertTrue($this->db->table('foobar')->insert(['label' => 'second', 'tags' => '["python","django"]']));

$this->assertEquals('first', $this->db->table('foobar')->jsonContains('tags', ['php', 'js'])->findOneColumn('label'));
$this->assertEquals('second', $this->db->table('foobar')->jsonContains('tags', ['python'])->findOneColumn('label'));
// values split across rows — neither row contains both
$this->assertEquals(0, $this->db->table('foobar')->jsonContains('tags', ['php', 'python'])->count());
}

public function testJsonContainsWithPath()
{
$this->assertNotFalse($this->db->execute('CREATE TABLE foobar (label VARCHAR(50), data NVARCHAR(MAX))'));
$this->assertTrue($this->db->table('foobar')->insert(['label' => 'first', 'data' => '{"tags":["php","js","mysql"]}']));
$this->assertTrue($this->db->table('foobar')->insert(['label' => 'second', 'data' => '{"tags":["python","django"]}']));

// explicit path
$this->assertEquals('first', $this->db->table('foobar')->jsonContains('data', ['php', 'js'], '$.tags')->findOneColumn('label'));
// bare key path
$this->assertEquals('first', $this->db->table('foobar')->jsonContains('data', ['php', 'js'], 'tags')->findOneColumn('label'));
$this->assertEquals('second', $this->db->table('foobar')->jsonContains('data', ['python'], 'tags')->findOneColumn('label'));
$this->assertEquals(0, $this->db->table('foobar')->jsonContains('data', ['php', 'python'], 'tags')->count());
}

public function testJsonNotContainsOnColumn()
{
$this->assertNotFalse($this->db->execute('CREATE TABLE foobar (label VARCHAR(50), tags NVARCHAR(MAX))'));
$this->assertTrue($this->db->table('foobar')->insert(['label' => 'first', 'tags' => '["php","js","mysql"]']));
$this->assertTrue($this->db->table('foobar')->insert(['label' => 'second', 'tags' => '["python","django"]']));

$this->assertEquals('second', $this->db->table('foobar')->jsonNotContains('tags', ['php', 'js'])->findOneColumn('label'));
$this->assertEquals('first', $this->db->table('foobar')->jsonNotContains('tags', ['python'])->findOneColumn('label'));
$this->assertEquals(2, $this->db->table('foobar')->jsonNotContains('tags', ['php', 'python'])->count());
}

public function testJsonNotContainsWithPath()
{
$this->assertNotFalse($this->db->execute('CREATE TABLE foobar (label VARCHAR(50), data NVARCHAR(MAX))'));
$this->assertTrue($this->db->table('foobar')->insert(['label' => 'first', 'data' => '{"tags":["php","js","mysql"]}']));
$this->assertTrue($this->db->table('foobar')->insert(['label' => 'second', 'data' => '{"tags":["python","django"]}']));

$this->assertEquals('second', $this->db->table('foobar')->jsonNotContains('data', ['php', 'js'], 'tags')->findOneColumn('label'));
$this->assertEquals('first', $this->db->table('foobar')->jsonNotContains('data', ['python'], 'tags')->findOneColumn('label'));
$this->assertEquals(2, $this->db->table('foobar')->jsonNotContains('data', ['php', 'python'], 'tags')->count());
}
}
Loading