Skip to content
Draft
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
479 changes: 392 additions & 87 deletions README.md

Large diffs are not rendered by default.

913 changes: 913 additions & 0 deletions src/Query/Builder.php

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions src/Query/Builder/BuildResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Utopia\Query\Builder;

readonly class BuildResult
{
/**
* @param list<mixed> $bindings
*/
public function __construct(
public string $query,
public array $bindings,
) {
}
}
141 changes: 141 additions & 0 deletions src/Query/Builder/ClickHouse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

namespace Utopia\Query\Builder;

use Utopia\Query\Builder as BaseBuilder;
use Utopia\Query\Exception;
use Utopia\Query\Query;

class ClickHouse extends BaseBuilder
{
/**
* @var array<Query>
*/
protected array $prewhereQueries = [];

protected bool $useFinal = false;

protected ?float $sampleFraction = null;

// ── ClickHouse-specific fluent API ──

/**
* Add PREWHERE filters (evaluated before reading all columns — major ClickHouse optimization)
*
* @param array<Query> $queries
*/
public function prewhere(array $queries): static
{
foreach ($queries as $query) {
$this->prewhereQueries[] = $query;
}

return $this;
}

/**
* Add FINAL keyword after table name (forces merging of data parts)
*/
public function final(): static
{
$this->useFinal = true;

return $this;
}

/**
* Add SAMPLE clause after table name (approximate query processing)
*/
public function sample(float $fraction): static
{
if ($fraction <= 0.0 || $fraction >= 1.0) {
throw new \InvalidArgumentException('Sample fraction must be between 0 and 1 exclusive');
}

$this->sampleFraction = $fraction;

return $this;
}

public function reset(): static
{
parent::reset();
$this->prewhereQueries = [];
$this->useFinal = false;
$this->sampleFraction = null;

return $this;
}

// ── Dialect-specific compilation ──

protected function wrapIdentifier(string $identifier): string
{
$segments = \explode('.', $identifier);
$wrapped = \array_map(fn (string $segment): string => $segment === '*'
? '*'
: '`' . \str_replace('`', '``', $segment) . '`', $segments);

return \implode('.', $wrapped);
}

protected function compileRandom(): string
{
return 'rand()';
}

/**
* ClickHouse uses the match(column, pattern) function instead of REGEXP
*
* @param array<mixed> $values
*/
protected function compileRegex(string $attribute, array $values): string
{
$this->addBinding($values[0]);

return 'match(' . $attribute . ', ?)';
}

/**
* ClickHouse does not support MATCH() AGAINST() full-text search
*
* @param array<mixed> $values
*
* @throws Exception
*/
protected function compileSearch(string $attribute, array $values, bool $not): string
{
throw new Exception('Full-text search (MATCH AGAINST) is not supported in ClickHouse. Use contains() or a custom full-text index instead.');
}

// ── Hooks ──

protected function buildTableClause(): string
{
$sql = 'FROM ' . $this->wrapIdentifier($this->table);

if ($this->useFinal) {
$sql .= ' FINAL';
}

if ($this->sampleFraction !== null) {
$sql .= ' SAMPLE ' . $this->sampleFraction;
}

return $sql;
}

/**
* @param array<string> $parts
*/
protected function buildAfterJoins(array &$parts, GroupedQueries $grouped): void
{
if (! empty($this->prewhereQueries)) {
$clauses = [];
foreach ($this->prewhereQueries as $query) {
$clauses[] = $this->compileFilter($query);
}
$parts[] = 'PREWHERE ' . \implode(' AND ', $clauses);
}
}
}
26 changes: 26 additions & 0 deletions src/Query/Builder/Condition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Utopia\Query\Builder;

readonly class Condition
{
/**
* @param list<mixed> $bindings
*/
public function __construct(
public string $expression,
public array $bindings = [],
) {
}

public function getExpression(): string
{
return $this->expression;
}

/** @return list<mixed> */
public function getBindings(): array
{
return $this->bindings;
}
}
39 changes: 39 additions & 0 deletions src/Query/Builder/GroupedQueries.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace Utopia\Query\Builder;

use Utopia\Query\CursorDirection;
use Utopia\Query\OrderDirection;
use Utopia\Query\Query;

readonly class GroupedQueries
{
/**
* @param list<Query> $filters
* @param list<Query> $selections
* @param list<Query> $aggregations
* @param list<string> $groupBy
* @param list<Query> $having
* @param list<Query> $joins
* @param list<Query> $unions
* @param array<string> $orderAttributes
* @param array<OrderDirection> $orderTypes
*/
public function __construct(
public array $filters = [],
public array $selections = [],
public array $aggregations = [],
public array $groupBy = [],
public array $having = [],
public bool $distinct = false,
public array $joins = [],
public array $unions = [],
public ?int $limit = null,
public ?int $offset = null,
public array $orderAttributes = [],
public array $orderTypes = [],
public mixed $cursor = null,
public ?CursorDirection $cursorDirection = null,
) {
}
}
56 changes: 56 additions & 0 deletions src/Query/Builder/SQL.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace Utopia\Query\Builder;

use Utopia\Query\Builder as BaseBuilder;

class SQL extends BaseBuilder
{
private string $wrapChar = '`';

public function setWrapChar(string $char): static
{
$this->wrapChar = $char;

return $this;
}

protected function wrapIdentifier(string $identifier): string
{
$segments = \explode('.', $identifier);
$wrapped = \array_map(fn (string $segment): string => $segment === '*'
? '*'
: $this->wrapChar . \str_replace($this->wrapChar, $this->wrapChar . $this->wrapChar, $segment) . $this->wrapChar, $segments);

return \implode('.', $wrapped);
}

protected function compileRandom(): string
{
return 'RAND()';
}

/**
* @param array<mixed> $values
*/
protected function compileRegex(string $attribute, array $values): string
{
$this->addBinding($values[0]);

return $attribute . ' REGEXP ?';
}

/**
* @param array<mixed> $values
*/
protected function compileSearch(string $attribute, array $values, bool $not): string
{
$this->addBinding($values[0]);

if ($not) {
return 'NOT (MATCH(' . $attribute . ') AGAINST(?))';
}

return 'MATCH(' . $attribute . ') AGAINST(?)';
}
}
16 changes: 16 additions & 0 deletions src/Query/Builder/UnionClause.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Utopia\Query\Builder;

readonly class UnionClause
{
/**
* @param list<mixed> $bindings
*/
public function __construct(
public string $type,
public string $query,
public array $bindings,
) {
}
}
51 changes: 51 additions & 0 deletions src/Query/Compiler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace Utopia\Query;

interface Compiler
{
/**
* Compile a filter query (equal, greaterThan, contains, between, spatial, vector, logical, etc.)
*/
public function compileFilter(Query $query): string;

/**
* Compile an order query (orderAsc, orderDesc, orderRandom)
*/
public function compileOrder(Query $query): string;

/**
* Compile a limit query
*/
public function compileLimit(Query $query): string;

/**
* Compile an offset query
*/
public function compileOffset(Query $query): string;

/**
* Compile a select query
*/
public function compileSelect(Query $query): string;

/**
* Compile a cursor query (cursorAfter, cursorBefore)
*/
public function compileCursor(Query $query): string;

/**
* Compile an aggregate query (count, sum, avg, min, max)
*/
public function compileAggregate(Query $query): string;

/**
* Compile a group by query
*/
public function compileGroupBy(Query $query): string;

/**
* Compile a join query (join, leftJoin, rightJoin, crossJoin)
*/
public function compileJoin(Query $query): string;
}
9 changes: 9 additions & 0 deletions src/Query/CursorDirection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Utopia\Query;

enum CursorDirection: string
{
case After = 'after';
case Before = 'before';
}
7 changes: 7 additions & 0 deletions src/Query/Hook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace Utopia\Query;

interface Hook
{
}
10 changes: 10 additions & 0 deletions src/Query/Hook/AttributeHook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Utopia\Query\Hook;

use Utopia\Query\Hook;

interface AttributeHook extends Hook
{
public function resolve(string $attribute): string;
}
16 changes: 16 additions & 0 deletions src/Query/Hook/AttributeMapHook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Utopia\Query\Hook;

readonly class AttributeMapHook implements AttributeHook
{
/** @param array<string, string> $map */
public function __construct(public array $map)
{
}

public function resolve(string $attribute): string
{
return $this->map[$attribute] ?? $attribute;
}
}
Loading