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
16 changes: 11 additions & 5 deletions src/dba/AbstractModelFactory.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,7 @@ public function getFromDB($pk): ?AbstractModel {
* @return AbstractModel[]|AbstractModel Returns a list of matching objects or Null
*/
private function filterWithJoin(array $options): array|AbstractModel {
$vals = array();
$joins = $this->getJoins($options);
$factories = array($this);
$query = "SELECT " . Util::createPrefixedString($this->getMappedModelTable(), self::getMappedModelKeys($this->getNullObject()));
Expand All @@ -580,11 +581,14 @@ private function filterWithJoin(array $options): array|AbstractModel {
}
$match1 = self::getMappedModelKey($localFactory->getNullObject(), $join->getMatch1());
$match2 = self::getMappedModelKey($joinFactory->getNullObject(), $join->getMatch2());
$query .= " INNER JOIN " . $joinFactory->getMappedModelTable() . " ON " . $localFactory->getMappedModelTable() . "." . $match1 . "=" . $joinFactory->getMappedModelTable() . "." . $match2 . " ";
$query .= " " . $join->getJoinType() . " JOIN " . $joinFactory->getMappedModelTable() . " ON " . $localFactory->getMappedModelTable() . "." . $match1 . "=" . $joinFactory->getMappedModelTable() . "." . $match2 . " ";
$joinQueryFilters = $join->getQueryFilters();
if (count($joinQueryFilters) > 0) {
$query .= $this->applyFilters($vals, $joinQueryFilters, true) ;
}
}

// Apply all normal filter to this query
$vals = array();
if (array_key_exists("filter", $options)) {
$query .= $this->applyFilters($vals, $options['filter']);
}
Expand All @@ -605,7 +609,6 @@ private function filterWithJoin(array $options): array|AbstractModel {
if (array_key_exists("limit", $options)) {
$query .= $this->applyLimit($options['limit']);
}

$dbh = self::getDB();
$stmt = $dbh->prepare($query);
$stmt->execute($vals);
Expand Down Expand Up @@ -709,7 +712,7 @@ public function filter(array $options, bool $single = false) {
* @param $filters Filter|Filter[]
* @return string
*/
private function applyFilters(&$vals, Filter|array $filters): string {
private function applyFilters(&$vals, Filter|array $filters, bool $isJoinFilter = false): string {
$parts = array();
if (!is_array($filters)) {
$filters = array($filters);
Expand All @@ -730,6 +733,9 @@ private function applyFilters(&$vals, Filter|array $filters): string {
$vals[] = $v;
}
}
if ($isJoinFilter) {
return " AND " . implode(" AND ", $parts);
}
return " WHERE " . implode(" AND ", $parts);
}

Expand Down Expand Up @@ -758,7 +764,7 @@ private function applyJoins($joins): string {
}
$match1 = self::getMappedModelKey($localFactory->getNullObject(), $join->getMatch1());
$match2 = self::getMappedModelKey($joinFactory->getNullObject(), $join->getMatch2());
$query .= " INNER JOIN " . $joinFactory->getMappedModelTable() . " ON " . $localFactory->getMappedModelTable() . "." . $match1 . "=" . $joinFactory->getMappedModelTable() . "." . $match2 . " ";
$query .= " " . $join->getJoinType() . " JOIN " . $joinFactory->getMappedModelTable() . " ON " . $localFactory->getMappedModelTable() . "." . $match1 . "=" . $joinFactory->getMappedModelTable() . "." . $match2 . " ";
}
return $query;
}
Expand Down
18 changes: 18 additions & 0 deletions src/dba/CoalesceOrderFilter.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace DBA;

class CoalesceOrderFilter extends Order {
// The columns to do the COALESCE function on
private $columns;
private $type;

function __construct($columns, $type) {
$this->columns = $columns;
$this->type = $type;
}

function getQueryString(AbstractModelFactory $factory, bool $includeTable = false): string {
return "COALESCE(" . implode(", ", $this->columns) . ") " . $this->type;
Comment on lines +15 to +16
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential SQL injection vulnerability. The COALESCE column names are not being escaped or validated before being included in the SQL query string. Column names should be validated against a whitelist or properly escaped to prevent SQL injection.

Copilot uses AI. Check for mistakes.
}
}
10 changes: 10 additions & 0 deletions src/dba/Join.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,14 @@ abstract function getMatch1();
* @return string
*/
abstract function getMatch2();

/**
* @return string
*/
abstract function getJoinType();

/**
* @return QueryFilter[] array of queryfilters that have to be perfromed on the join
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spelling error: "perfromed" should be "performed"

Suggested change
* @return QueryFilter[] array of queryfilters that have to be perfromed on the join
* @return QueryFilter[] array of queryfilters that have to be performed on the join

Copilot uses AI. Check for mistakes.
*/
abstract function getQueryFilters();
}
32 changes: 31 additions & 1 deletion src/dba/JoinFilter.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,31 @@ class JoinFilter extends Join {
* @var AbstractModelFactory
*/
private $overrideOwnFactory;

/**
* @var string
*/
private $joinType;

/**
* @var QueryFilter[] array of queryfilters that have to be perfromed on the join
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spelling error: "perfromed" should be "performed"

Suggested change
* @var QueryFilter[] array of queryfilters that have to be perfromed on the join
* @var QueryFilter[] array of queryfilters that have to be performed on the join

Copilot uses AI. Check for mistakes.
*/
private $queryFilters;

/**
* JoinFilter constructor.
* @param $otherFactory AbstractModelFactory
* @param $matching1 string
* @param $matching2 string
* @param $overrideOwnFactory AbstractModelFactory
* @param $joinType string is normally inner, left or right
*/
function __construct($otherFactory, $matching1, $matching2, $overrideOwnFactory = null) {
function __construct($otherFactory, $matching1, $matching2, $overrideOwnFactory = null, $joinType = "inner", $queryFilters = []) {
$this->otherFactory = $otherFactory;
$this->match1 = $matching1;
$this->match2 = $matching2;
$this->joinType = $joinType;
$this->queryFilters = $queryFilters;

$this->otherTableName = $this->otherFactory->getMappedModelTable();
$this->overrideOwnFactory = $overrideOwnFactory;
Expand All @@ -62,6 +75,23 @@ function getMatch2() {
function getOtherTableName() {
return $this->otherTableName;
}

function getJoinType() {
return $this->joinType;
}

function setJoinType($joinType) {
$this->joinType = $joinType;
}


Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing whitespace on blank lines. Lines 87 and 95 appear to have unnecessary blank lines or trailing whitespace that should be removed for code cleanliness.

Copilot uses AI. Check for mistakes.
function getQueryFilters() {
return $this->queryFilters;
}

function setQueryFilters(array $queryFilters) {
$this->queryFilters = $queryFilters;
}

/**
* @return AbstractModelFactory
Expand Down
8 changes: 8 additions & 0 deletions src/dba/OrderFilter.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ function __construct($by, $type, $overrideFactory = null) {
$this->type = $type;
$this->overrideFactory = $overrideFactory;
}

function getBy(): string {
return $this->by;
}

function getType(): string {
return $this->type;
}

function getQueryString(AbstractModelFactory $factory, bool $includeTable = false): string {
if ($this->overrideFactory != null) {
Expand Down
1 change: 1 addition & 0 deletions src/dba/init.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
require_once(dirname(__FILE__) . "/Aggregation.class.php");
require_once(dirname(__FILE__) . "/Filter.class.php");
require_once(dirname(__FILE__) . "/Order.class.php");
require_once(dirname(__FILE__) . "/CoalesceOrderFilter.class.php");
require_once(dirname(__FILE__) . "/Join.class.php");
require_once(dirname(__FILE__) . "/Group.class.php");
require_once(dirname(__FILE__) . "/Limit.class.php");
Expand Down
57 changes: 52 additions & 5 deletions src/inc/apiv2/common/AbstractBaseAPI.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ protected function getFeatures(): array {
}
return $features;
}

/**
* Get features based on DBA model features
*
* @param string $dbaClass is the dba class to get the features from
*/
//TODO doesnt retrieve features based on form fields, could be done by adding api class in relationship objects
final protected function getFeaturesOther(string $dbaClass): array {
return call_user_func($dbaClass . '::getFeatures');
}

protected function getUpdateHandlers($id, $current_user): array {
return [];
Expand Down Expand Up @@ -174,6 +184,11 @@ public function getAliasedFeatures(): array {
$features = $this->getFeatures();
return $this->mapFeatures($features);
}

public function getAliasedFeaturesOther($dbaClass): array {
$features = $this->getFeaturesOther($dbaClass);
return $this->mapFeatures($features);
}

final protected function mapFeatures($features): array {
$mappedFeatures = [];
Expand All @@ -183,6 +198,18 @@ final protected function mapFeatures($features): array {
}
return $mappedFeatures;
}

public static function getToOneRelationships(): array {
return [];
}

public static function getToManyRelationships(): array {
return [];
}

public function getAllRelationships(): array {
return array_merge($this->getToOneRelationships(), $this->getToManyRelationships());
}

/**
* Retrieve currently logged-in user
Expand Down Expand Up @@ -1145,19 +1172,39 @@ protected function makeOrderFilterTemplates(Request $request, array $features, s
$orderings = $this->getQueryParameterAsList($request, 'sort');
$contains_primary_key = false;
foreach ($orderings as $order) {
if (preg_match('/^(?P<operator>[-])?(?P<key>[_a-zA-Z]+)$/', $order, $matches)) {
$factory = null;
$joinKey = null;
$features_sort = $features;
if (preg_match('/^(?P<operator>[-])?(?P<key>[_a-zA-Z.]+)$/', $order, $matches)) {
// Special filtering of _id to use for uniform access to model primary key
$cast_key = $matches['key'] == 'id' ? $this->getPrimaryKey() : $matches['key'];
if ($cast_key == $this->getPrimaryKey()) {
$contains_primary_key = true;
}
if (array_key_exists($cast_key, $features)) {
$remappedKey = $features[$cast_key]['dbname'];
if (strpos($cast_key, ".")) {
$parts = explode(".", $cast_key);
if (count($parts) == 2) { // Only relations of 1 deep allowed ex. task.keyspace
$relationString = $parts[0];
//currently getting all relationships, but its probably only possible to sort on 1 to 1 relations
$relations = $this->getAllRelationships();
if (array_key_exists($relationString, $relations)) {
$relationClass = $relations[$relationString]['relationType'];
$relationFeatures = $this->getAliasedFeaturesOther($relationClass);
$factory = $this->getModelFactory($relationClass);
$joinKey = $relations[$relationString]['relationKey'];
$key = $relations[$relationString]['key'];
$features_sort = $relationFeatures;
$cast_key = $parts[1];
}
}
}
if (array_key_exists($cast_key, $features_sort)) {
$remappedKey = $features_sort[$cast_key]['dbname'];
$type = ($matches['operator'] == '-') ? "DESC" : "ASC";
if ($reverseSort) {
$type = ($type == "ASC") ? "DESC" : "ASC";
}
$orderTemplates[] = ['by' => $remappedKey, 'type' => $type];
$orderTemplates[] = ['by' => $remappedKey, 'type' => $type, 'factory' => $factory, 'joinKey' => $joinKey, 'key' => $key];
}
else {
throw new HttpForbidden("Ordering parameter '" . $order . "' is not valid");
Expand All @@ -1170,7 +1217,7 @@ protected function makeOrderFilterTemplates(Request $request, array $features, s

//when no primary key has been added in the sort parameter, add the default case of sorting on primary key as last sort
if (!$contains_primary_key) {
$orderTemplates[] = ['by' => $this->getPrimaryKey(), 'type' => $defaultSort];
$orderTemplates[] = ['by' => $this->getPrimaryKey(), 'type' => $defaultSort, 'factory' => null, 'joinKey' => null];
}

return $orderTemplates;
Expand Down
52 changes: 29 additions & 23 deletions src/inc/apiv2/common/AbstractModelAPI.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,6 @@ abstract protected function createObject(array $data): int;

abstract protected function deleteObject(object $object): void;

public static function getToOneRelationships(): array {
return [];
}

public static function getToManyRelationships(): array {
return [];
}

/**
* Available 'expand' parameters on $object
*/
Expand Down Expand Up @@ -126,16 +118,6 @@ public function getFeaturesWithoutFormfields(): array {
return $this->mapFeatures($features);
}

/**
* Get features based on DBA model features
*
* @param string $dbaClass is the dba class to get the features from
*/
//TODO doesnt retrieve features based on form fields, could be done by adding api class in relationship objects
final protected function getFeaturesOther(string $dbaClass): array {
return call_user_func($dbaClass . '::getFeatures');
}

/**
* Find primary key for another DBA object
* A little bit hacky because the getPrimaryKey function in dbaClass is not static
Expand Down Expand Up @@ -446,7 +428,7 @@ final protected function ResourceRecordArrayToUpdateArray($data, $parentId): arr
}
return $updates;
}

protected static function calculate_next_cursor(string|int $cursor, bool $ascending=true) {
if (is_int($cursor)) {
if ($ascending) {
Expand Down Expand Up @@ -547,16 +529,23 @@ protected static function compare_keys($key1, $key2, $isNegativeSort) {

protected static function getMinMaxCursor($apiClass, string $sort, array $filters, $request, $aliasedfeatures) {
$filters[Factory::LIMIT] = new LimitFilter(1);

$primaryKey = $apiClass->getPrimaryKey();
// Descending queries are used to retrieve the last element. For this all sorts have to be reversed, since
// if all order quereis are reversed and limit to 1, you will retrieve the last element.
$reverseSort = ($sort == "DESC") ? true : false;
$orderTemplates = $apiClass->makeOrderFilterTemplates($request, $aliasedfeatures, $sort, $reverseSort);
$orderFilters = [];
$joinFilters = [];
foreach ($orderTemplates as $orderTemplate) {
$orderFilters[] = new OrderFilter($orderTemplate['by'], $orderTemplate['type']);
$orderFilters[] = new OrderFilter($orderTemplate['by'], $orderTemplate['type'], $orderTemplate['factory']);
if ($orderTemplate['factory'] !== null){
// if factory of ordertemplate is not null, sort is happening on joined table
$otherFactory = $orderTemplate['factory'];
$joinFilters[] = new JoinFilter($otherFactory, $orderTemplate['joinKey'], $apiClass->getPrimaryKeyOther($otherFactory->getNullObject()::class));
}
}
$filters[Factory::ORDER] = $orderFilters;
$filters[Factory::JOIN] = $joinFilters;
$factory = $apiClass->getFactory();
$result = $factory->filter($filters);
//handle joined queries
Expand All @@ -569,6 +558,14 @@ protected static function getMinMaxCursor($apiClass, string $sort, array $filter
return $result[0];
}

/**
* overridable function to parse filters, currently only needed for taskWrapper endpoint
* to handle the taskwrapper -> task relation, to be able to treat it as a to one relationship
*/
protected function parseFilters(array $filters) {
return $filters;
}

/**
* API entry point for requesting multiple objects
* @throws HttpError
Expand Down Expand Up @@ -661,23 +658,32 @@ public static function getManyResources(object $apiClass, Request $request, Resp
} else {
$defaultSort = "ASC";
}
$primaryKey = $apiClass->getPrimaryKey();

$orderTemplates = $apiClass->makeOrderFilterTemplates($request, $aliasedfeatures, $defaultSort);
$orderTemplates[0]["type"] = $defaultSort;
$primaryFilter = $orderTemplates[0]['by'];
$orderFilters = [];
$joinFilters = [];

// Build actual order filters
foreach ($orderTemplates as $orderTemplate) {
// $aFs[Factory::ORDER][] = new OrderFilter($orderTemplate['by'], $orderTemplate['type']);
$orderFilters[] = new OrderFilter($orderTemplate['by'], $orderTemplate['type']);
$orderFilters[] = new OrderFilter($orderTemplate['by'], $orderTemplate['type'], $orderTemplate['factory']);
if ($orderTemplate['factory'] !== null) {
// if factory of ordertemplate is not null, sort is happening on joined table
$otherFactory = $orderTemplate['factory'];
$joinFilters[] = new JoinFilter($otherFactory, $orderTemplate['joinKey'], $orderTemplate['key']);
}
}

$aFs[Factory::ORDER] = $orderFilters;
$aFs[Factory::JOIN] = $joinFilters;

/* Include relation filters */
$finalFs = array_merge($aFs, $relationFs);
$finalFs = $apiClass->parseFilters($finalFs);

$primaryKey = $apiClass->getPrimaryKey();
//TODO it would be even better if its possible to see if the primary filter is unique, instead of primary key.
//But this probably needs to be added in getFeatures() then.
$primaryKeyIsNotPrimaryFilter = $primaryFilter != $primaryKey;
Expand Down
Loading