This commit is contained in:
2023-10-02 23:10:49 +03:00
commit c6cb488379
255 changed files with 18731 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
<?php
namespace Lucent\Query;
use Illuminate\Support\Facades\DB;
final class Filter
{
private array $operatorsList;
public function __construct(
public array $arguments = [],
)
{
}
public function add(array $arguments): Filter
{
$this->arguments = $arguments;
return $this;
}
private function formatArguments(array $arguments): array
{
return (array)collect($arguments)->reduce(fn($c, $v, $k) => $this->formatArgument($c, $v, $k), []);
}
private function formatArgument(array $arguments, mixed $value, string $filter): array
{
$operator = $this->detectOperator($filter);
$field = $this->detectField($filter, $operator);
$formattedValue = match ($operator) {
"eq" => $this->formatText($value),
"ne" => $this->formatText($value),
"eqnum" => $this->formatNumber($value),
"nenum" => $this->formatNumber($value),
"object" => $this->formatText($value),
"in" => $this->formatListString($value),
"nin" => $this->formatListString($value),
"innum" => $this->formatListNum($value),
"ninnum" => $this->formatListNum($value),
"eqtrue" => true,
"eqfalse" => false,
"netrue" => true,
"nefalse" => false,
"regex" => "%{$value}%",
"gt" => \is_numeric($value) ? floatval($value) : $value,
"gte" => \is_numeric($value) ? floatval($value) : $value,
"lt" => \is_numeric($value) ? floatval($value) : $value,
"lte" => \is_numeric($value) ? floatval($value) : $value,
"null" => null,
"nnull" => null,
"exists" => true,
"nexists" => false,
default => $value,
};
$matchedOperator = $this->operatorsList[$operator];
$arguments[] = [
"field" => str_replace(".", "->", $field),
"operator" => $matchedOperator->db,
"value" => $formattedValue
];
return $arguments;
}
private function formatText(string $value): string
{
return trim($value);
}
private function formatNumber(string $value): float
{
return \floatval($value);
}
private function formatListString(mixed $value): array
{
if (\is_string($value)) {
$value = explode(",", $value);
}
return \array_map(fn($v) => $this->formatText($v), $value);
}
private function formatListNum(mixed $value): array
{
if (\is_string($value)) {
$value = explode(",", $value);
}
return \array_map(fn($v) => $this->formatNumber($v), $value);
}
private function detectOperator(string $filter): string
{
$exploded = \explode("_", $filter);
$candidate = end($exploded);
$operatorsListNames = collect($this->operatorsList)->map(fn($o) => $o->name)->toArray();
if (\in_array($candidate, $operatorsListNames)) {
return $candidate;
}
return 'eq';
}
private function detectField(string $filter, string $operator): string
{
$exploded = \explode("_", $filter);
$candidate = array_pop($exploded);
if ($candidate == $operator) {
return \implode("_", $exploded);
}
return $filter;
}
private function formatReferences(Query $query): array
{
[$arguments, $referenceArguments] = $this->separateMainFromReferenceArguments();
if (empty($referenceArguments)) {
return [$arguments, []];
};
$subqueries = collect($referenceArguments)->reduce(function ($c, $v, $k) {
$keyWithoutRef = str_replace("children.", "", $k);
[$field] = explode(".", $keyWithoutRef);
$referenceField = str_replace($field . ".", "", $keyWithoutRef);
$c[$field][$referenceField] = $v;
return $c;
}, []);
$sourceIds = collect($subqueries)->reduce(function ($c, $subquery, $k) use ($query) {
$graph = $query->filter($subquery)->run();
if (!$graph->hasResults()) {
return $c;
}
$targetIds = collect($graph->records)->pluck("id");
$sourceIds = DB::table("edges")->whereIn("target", $targetIds)->where("field", $k)->get()->pluck("source");
return array_merge($c, $sourceIds->toArray());
}, []);
return [$arguments, [
"field" => "id",
"operator" => "in",
"value" => $sourceIds
]];
}
private function separateMainFromReferenceArguments(): array
{
return collect($this->arguments)->partition(function ($v, $k) {
if (!str_starts_with($k, "children.")) {
return true;
}
return false;
})->toArray();
}
public function run(Query $query): array
{
[$argumentsWithoutReferences, $referencesFilter] = $this->formatReferences($query);
$this->operatorsList = Operator::list();
$formattedArguments = $this->formatArguments($argumentsWithoutReferences);
if (!empty($referencesFilter)) {
$formattedArguments[] = $referencesFilter;
}
return $formattedArguments;
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace Lucent\Query;
use Lucent\Edge\Edge;
use Lucent\Primitive\Collection;
use Lucent\Record\QueryRecord;
final class Graph
{
/**
* @param Collection<QueryRecord> $records
* @param Collection<Edge> $edges
* */
public function __construct(
public Collection $records,
public Collection $edges,
public ?int $total = null,
)
{
}
/**
* @return Collection<QueryRecord>
* */
public function getRootRecords(): Collection
{
return $this->records->where("isRoot", true)->values();
}
public function hasResults(): bool
{
return !empty($this->records);
}
// public function getRootRecordsWithChildren(): Collection
// {
// $rootRecords = $this->records->where("isRoot", true)->values();
// return $rootRecords->map([$this, 'findChildren']);
// }
//
// public function findChildren(QueryRecord $record): QueryRecord
// {
// $recordEdges = $this->edges
// ->where("source", $record->id)
// ->values()
// ->sortBy("rank")
// ->groupBy("field")->toArray();
//
//
// foreach ($recordEdges as $field => $edges) {
// $recordEdges[$field] = [];
// foreach ($edges as $anEdge) {
// $aRecord = $this->records->where("id", $anEdge->target)->first();
// if (empty($aRecord)) {
// continue;
// }
// $recordEdges[$field][] = $this->findChildren($aRecord);
// }
// }
//
// $record->_children = new Collection($recordEdges);
// return $record;
// }
public function tree(): Collection
{
return $this->records->filter(function (QueryRecord $record) {
return $this->edges->filter(fn(Edge $ed) => $ed->target == $record->id)->isEmpty();
})->values()
->map([$this, 'findChildren']);
}
public function findChildren(QueryRecord $record): QueryRecord
{
$recordEdges = $this->edges->filter(fn(Edge $ed) => $ed->source === $record->id)->values()->sort(fn($a, $b) => $a->rank <=> $b->rank)->values();
$groupRecordEdges = [];
foreach ($recordEdges as $element) {
$groupRecordEdges[$element->field][] = $element;
}
$children = [];
foreach ($groupRecordEdges as $field => $edges) {
$children[$field] = [];
foreach ($edges as $anEdge) {
$aRecord = $this->records->filter(fn(QueryRecord $rec) => $rec->id == $anEdge->target)->values();
if (empty($aRecord[0])) {
continue;
}
$children[$field][] = $this->findChildren($aRecord[0]);
}
}
$record->_children = $children;
return $record;
}
}
+186
View File
@@ -0,0 +1,186 @@
<?php
namespace Lucent\Query;
final class Operator
{
/**
* @psalm-param string[] $uis
*/
public function __construct(
public string $name,
public string $label,
public string $symbol,
public string $db,
public array $uis,
)
{
}
/**
* @return array<string, Operator>
*/
public static function list(): array
{
return [
"regex" => new Operator(
name: "regex",
label: "Search",
symbol: "~",
db: 'like',
uis: ["id", "text", "textarea", "url", "color", "date", "datetime"],
),
"eq" => new Operator(
name: "eq",
label: "Equals",
symbol: "is",
db: '=',
uis: ["id", "text", "textarea", "url", "color", "date", "datetime"],
),
"ne" => new Operator(
name: "ne",
label: "Not Equals",
symbol: "is not",
db: '!=',
uis: ["id", "text", "textarea", "url", "color", "date", "datetime"],
),
"eqnum" => new Operator(
name: "eqnum",
label: "Equals number",
symbol: "is",
db: '=',
uis: ["number"],
),
"neqnum" => new Operator(
name: "nenum",
label: "Not Equals number",
symbol: "is not",
db: '$ne',
uis: ["number"],
),
"object" => new Operator(
name: "object",
label: "Equals Object",
symbol: "is",
db: 'eqobject',
uis: [],
),
"eqtrue" => new Operator(
name: "eqtrue",
label: "Equals true",
symbol: "is",
db: '=',
uis: ["checkbox"],
),
"eqfalse" => new Operator(
name: "eqfalse",
label: "Equals false",
symbol: "is not",
db: '=',
uis: ["checkbox"],
),
"netrue" => new Operator(
name: "netrue",
label: "Not equals true",
symbol: "!=",
db: '$ne',
uis: ["checkbox"],
),
"nefalse" => new Operator(
name: "nefalse",
label: "Not equals false",
symbol: "!=",
db: '$ne',
uis: ["checkbox"],
),
"in" => new Operator(
name: "in",
label: "In list",
symbol: "in",
db: 'in',
uis: ["id", "text", "textarea", "url", "color", "date", "datetime"],
),
"innum" => new Operator(
name: "innum",
label: "In list of numbers",
symbol: "in",
db: '$in',
uis: ["number"],
),
"nin" => new Operator(
name: "nin",
label: "Not in list",
symbol: "not in",
db: 'nin',
uis: ["id", "text", "textarea", "url", "color", "date", "datetime"],
),
"ninnum" => new Operator(
name: "ninnum",
label: "Not In list of numbers",
symbol: "not in",
db: '$nin',
uis: ["number"],
),
"lt" => new Operator(
name: "lt",
label: "Less than",
symbol: "<",
db: '<',
uis: ["number", "date", "datetime"],
),
"lte" => new Operator(
name: "lte",
label: "Less than equals",
symbol: "<=",
db: '<=',
uis: ["number", "date", "datetime"],
),
"gt" => new Operator(
name: "gt",
label: "Greater than",
symbol: ">",
db: '>',
uis: ["number", "date", "datetime"],
),
"gte" => new Operator(
name: "gte",
label: "Greater than equals",
symbol: ">=",
db: '>=',
uis: ["number", "date", "datetime"],
),
"null" => new Operator(
name: "null",
label: "Is null",
symbol: "=",
db: '$eq',
uis: ["*"],
),
"nnull" => new Operator(
name: "nnull",
label: "Not null",
symbol: "!=",
db: '$ne',
uis: ["*"],
),
"exists" => new Operator(
name: "exists",
label: "Exists",
symbol: "exists",
db: '$exists',
uis: ["*"],
),
"nexists" => new Operator(
name: "nexists",
label: "Not exists",
symbol: "not exists",
db: '$exists',
uis: ["*"],
),
];
}
}
+292
View File
@@ -0,0 +1,292 @@
<?php
namespace Lucent\Query;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Lucent\Channel\ChannelService;
use Lucent\Edge\Edge;
use Lucent\Primitive\Collection;
use Lucent\Record\InputFormatter;
use Lucent\Record\QueryRecord;
use Lucent\Record\Record;
final class Query
{
public Filter $filter;
public QueryOptions $options;
public function __construct(
public readonly ChannelService $channelService,
public readonly InputFormatter $inputFormatter,
)
{
$this->options = new QueryOptions();
}
public function filter(array $filterArguments): Query
{
$this->filter = new Filter($filterArguments);
return $this;
}
public function run(): Graph
{
$resultsRecords = $this->mainQuery();
$ids = array_map(function ($rec) {
return $rec->id;
}, $resultsRecords);
$resultChildrenEdgesTargetIds = [];
$resultChildrenEdges = [];
if ($this->options->childrenDepth > 0 && !empty($ids)) {
$resultChildrenEdges = $this->getChildren($ids);
$resultChildrenEdgesTargetIds = array_map(fn($e) => $e->target, $resultChildrenEdges);
}
$resultParentSourceTargetIds = [];
$resultParentEdges = [];
if ($this->options->parentsDepth > 0 && !empty($ids)) {
$resultParentEdges = $this->getParents($ids);
$resultParentSourceTargetIds = array_map(fn($e) => $e->source, $resultParentEdges);
}
$edgesIds = collect($resultParentSourceTargetIds)->merge($resultChildrenEdgesTargetIds)->unique()->values()->toArray();
$edgeRecords = [];
if (!empty($edgesIds)) {
$edgeRecords = DB::table('records')
->whereIn("id", $edgesIds)
->whereIn("_sys->status", $this->options->status)
->get()->toArray();
}
$resultsRecordsUnique = collect(array_merge($resultsRecords, $edgeRecords))->unique("id")->values()->toArray();
$resultEdges = collect(array_merge($resultChildrenEdges, $resultParentEdges))
->unique(fn($edge) => $edge->source . $edge->target . $edge->field)
->toArray();
return $this->formatRecords($resultsRecordsUnique, $resultEdges);
}
private function formatRecords(array $records, array $edges): Graph
{
$queryRecords = collect($records)->map(function ($recordData) {
$record = Record::fromDB($recordData);
$record->data = $this->inputFormatter->fill($record->_sys->schema, $record->data);
$queryRecord = QueryRecord::fromRecord($record);
$queryRecord->isRoot = data_get($recordData, "isRoot") === true;
return $queryRecord;
})->toArray();
$queryEdges = collect($edges)->map(function ($edgeData) {
return Edge::fromArray((array)$edgeData);
})->sortBy("rank")->values()->toArray();
return new Graph(
new Collection($queryRecords),
new Collection($queryEdges),
);
}
public function tree(): Collection
{
return $this->run()->tree();
}
/**
* @throws SubqueryNoResultException
*/
private function parseFilters(Builder $query): Builder
{
$filters = $this->filter->run(new Query($this->channelService, $this->inputFormatter));
$ignoredFilters = [];
foreach ($filters as $filter) {
if (in_array($filter["field"], $ignoredFilters)) {
continue;
} else if ($filter["operator"] == "in") {
$query->whereIn($filter["field"], $filter["value"]);
} else if ($filter["operator"] == "nin") {
$query->whereNotIn($filter["field"], $filter["value"]);
} elseif ($filter["operator"] == "eqobject") {
$object = $filter["value"];
// unset related filters used here
$addToIgnored = collect($filters)
->filter(fn($f) => str_starts_with($f["field"], $object))
->values()
->map(fn($f) => $f["field"])
->toArray();
$ignoredFilters = array_merge($ignoredFilters, $addToIgnored);
$objectFilters = collect($filters)
->filter(fn($f) => str_starts_with($f["field"], $object))
->values()
->reduce(function ($c, $f) use ($object) {
$field = str_replace($object . "->", "", $f["field"]);
$c[$field] = $f["value"];
return $c;
});
// target result
// filter[data.previousNames_object]=previousNames&filter[previousNames.name_eq]=alpha&filter[previousNames.id_eqnum]=24
// $query->whereJsonContains("data->previousNames", [["name" => "alpha", "id" => 24]]);
// $query->whereJsonContains($filter["field"], [$objectFilters]);
} else {
$query->where($filter["field"], $filter["operator"], $filter["value"]);
}
}
$query->whereIn("_sys->status", $this->options->status);
return $query;
}
/**
* @throws SubqueryNoResultException
*/
private function mainQuery(): array
{
$query = DB::table("records");
$query = $this->parseFilters($query);
$query->limit($this->options->limit);
$query->offset($this->options->skip);
$query = $this->orderByQuery($query);
return $query->get()->map(function ($r) {
$r->isRoot = true;
return $r;
})->toArray();
}
private
function getChildren(array $ids): array
{
$subquery = DB::table('edges AS g')
->select(DB::raw('g.source,g.target,g.rank,"g"."sourceSchema","g"."targetSchema",g.field, 0 as depth '))
->whereIn('source', $ids)
->limit($this->options->childrenLimit)
->unionAll(
DB::table(DB::raw("edges AS g, search_graph AS sg "))
->selectRaw('g.source,g.target,g.rank,"g"."sourceSchema","g"."targetSchema",g.field,sg.depth + 1 as depth')
->whereRaw("g.source = sg.target")
->where("sg.depth", "<=", $this->options->childrenDepth)
->orderBy("rank")
);
return DB::table('search_graph')
// ->select(DB::raw("*, 1 as depth "))
->withRecursiveExpression('search_graph', $subquery)
->get()->toArray();
}
private
function getParents(array $ids): array
{
$subquery = DB::table('edges AS g')
->select(DB::raw('g.source,g.target,g.rank,"g"."sourceSchema","g"."targetSchema",g.field, 0 as depth '))
->limit($this->options->parentsLimit)
->whereIn('g.target', $ids)
->unionAll(
DB::table(DB::raw("edges AS g, search_graph AS sg "))
->selectRaw('g.source,g.target,g.rank,"g"."sourceSchema","g"."targetSchema",g.field,sg.depth + 1 as depth')
->whereRaw("g.target = sg.source")
->where("sg.depth", "<=", $this->options->parentsDepth)
->orderBy("rank")
);
return DB::table('search_graph')
// ->select(DB::raw('sg.source,sg.target,sg.rank,sg."sourceSchema",sg."targetSchema",sg.field,sg.depth'))
->withRecursiveExpression('search_graph', $subquery)
->get()->toArray();
}
/**
* @throws SubqueryNoResultException
*/
public
function runWithCount(): Graph
{
$query = DB::table("records");
$query = $this->parseFilters($query);
$graph = $this->run();
$graph->total = $query->count();
return $graph;
}
public
function limit(int $limit): Query
{
$this->options->limit = $limit;
return $this;
}
public
function skip(int $skip): Query
{
$this->options->skip = $skip;
return $this;
}
public function childrenDepth(int $depth): Query
{
$this->options->childrenDepth = $depth;
return $this;
}
public function childrenLimit(int $limit): Query
{
$this->options->childrenLimit = $limit;
return $this;
}
public function parentsDepth(int $depth): Query
{
$this->options->parentsDepth = $depth;
return $this;
}
public function parentsLimit(int $limit): Query
{
$this->options->parentsLimit = $limit;
return $this;
}
public function sort(string $sort): Query
{
$this->options->sort[] = $sort;
return $this;
}
public function status(array $status): Query
{
$this->options->status = $status;
return $this;
}
public
function orderByQuery(Builder $query): Builder
{
foreach ($this->options->sort as $item) {
$field = str_replace(".", "->", ltrim($item, '-'));
$dir = str_starts_with($item, '-') ? "desc" : "asc";
if ($field) {
$query->orderBy($field, $dir);
}
}
return $query;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Lucent\Query;
final class QueryOptions
{
public function __construct(
public int $limit = 20,
public int $skip = 0,
public int $childrenDepth = -1,
public int $parentsDepth = -1,
public int $childrenLimit = -1,
public int $parentsLimit = -1,
public array $sort = [],
public array $status = ["published", "draft"]
)
{
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Lucent\Query;
use Lucent\Edge\Edge;
use Lucent\Primitive\Collection;
use Lucent\Record\QueryRecord;
final class QueryResult
{
/**
* @param Collection<QueryRecord> $records
* @param Collection<Edge> $edges
* @param int|null $total
*/
public function __construct(
public Collection $records,
public Collection $edges,
public ?int $total = null,
)
{
}
public function getTotal(): ?int
{
return $this->total;
}
public function hasResults(): bool
{
return !empty($this->records);
}
public function graph(): Graph
{
return new Graph($this->records, $this->edges);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace Lucent\Query;
use Exception;
final class SubqueryNoResultException extends Exception
{
// Redefine the exception so message isn't optional
public function __construct(string $message, int $code = 0, Exception $previous = null)
{
// make sure everything is assigned properly
parent::__construct($message, $code, $previous);
}
}