[TikiWiki-commits] [Git][tikiwiki/tiki][master] [NEW] Add transitive relations module
Benoit Grégoire (@benoitg) via TikiWiki-cvs <[email protected]>
| Newsgroups | gmane.comp.cms.tiki.cvs |
|---|---|
| Message-ID | <698622d7274b2_3b184bb06863@gitlab-sidekiq-low-urgency-cpu-bound-v2-79798dbbcd-5xwx4.mail> |
Benoit Grégoire pushed to branch master at Tiki Wiki CMS Groupware / Tiki
Commits:
60467fac by Moïse Nturubika at 2026-02-06T17:11:28+00:00
[NEW] Add transitive relations module
---
* [FIX] Tracker Fields: Handle missing options and optimize relations
* [ENH] Optimize transitive relations
* [ENH] Switch to optimized batch title lookup
* [NEW] Add transitive relations module
See merge request tikiwiki/tiki!9317
- - - - -
6 changed files:
- doc/devtools/codesniffer/standards/TikiIgnore/ignore_list.json
- lib/attributes/relationlib.php
- lib/core/Tracker/Field/AbstractTrackerField.php
- lib/trackers/trackerlib.php
- + modules/mod-func-relations_transitive.php
- + templates/modules/mod-relations_transitive.tpl
Changes:
=====================================
doc/devtools/codesniffer/standards/TikiIgnore/ignore_list.json
=====================================
@@ -4081,6 +4081,7 @@
"RelationLib::get_relation_id": true,
"RelationLib::get_relations_by_prefix": true,
"RelationLib::get_relation_count": true,
+ "RelationLib::get_transitive_relations": true,
"RelationLib::relation_exists": true,
"RelationLib::get_relation": true,
"RelationLib::remove_relation": true,
=====================================
lib/attributes/relationlib.php
=====================================
@@ -202,6 +202,113 @@ class RelationLib extends TikiDb_Bridge
return $this->table->fetchAll($fields, $cond, $max, -1, $orderBy);
}
+ /**
+ * Get transitive (multi-hop) relations via iterative traversal.
+ *
+ * @param string $type starting object type
+ * @param string $object starting object ID
+ * @param string $relation optional relation filter (wildcard supported)
+ * @param int $maxDepth max hops (1-10)
+ * @param array $excludeLevels levels to exclude (e.g. [1])
+ * @param int $maxPerLevel limit per level
+ *
+ * @return array relations grouped by depth: [depth => [relations]]
+ */
+ public function get_transitive_relations(
+ string $type,
+ string $object,
+ string $relation = '',
+ int $maxDepth = 3,
+ array $excludeLevels = [],
+ int $maxPerLevel = 50
+ ): array {
+ // Validate parameters
+ $maxDepth = max(1, min(10, (int)$maxDepth));
+ $maxPerLevel = max(1, min(500, (int)$maxPerLevel));
+ $excludeLevels = array_flip($excludeLevels);
+
+ // Prepare relation filter
+ $relation = TikiFilter::get('attribute_type')->filter($relation);
+ $relationFilter = $relation;
+ if ($relation && str_ends_with($relation, '.')) {
+ $relationFilter .= '%';
+ } elseif (! $relation) {
+ $relationFilter = '%';
+ }
+
+ // Recursive CTE Query
+ $query = "
+ WITH RECURSIVE transitive_relations (depth, target_type, target_itemId, relation, relationId, path) AS (
+ SELECT
+ 1,
+ target_type,
+ target_itemId,
+ relation,
+ relationId,
+ CONCAT('|', source_type, ':', source_itemId, '|', target_type, ':', target_itemId, '|')
+ FROM tiki_object_relations
+ WHERE source_type = ? AND source_itemId = ? AND relation LIKE ?
+
+ UNION DISTINCT
+
+ SELECT
+ tr.depth + 1,
+ r.target_type,
+ r.target_itemId,
+ r.relation,
+ r.relationId,
+ CONCAT(tr.path, r.target_type, ':', r.target_itemId, '|')
+ FROM tiki_object_relations r
+ INNER JOIN transitive_relations tr ON r.source_type = tr.target_type AND r.source_itemId = tr.target_itemId
+ WHERE tr.depth < ?
+ AND r.relation LIKE ?
+ AND INSTR(tr.path, CONCAT('|', r.target_type, ':', r.target_itemId, '|')) = 0
+ )
+ SELECT * FROM transitive_relations ORDER BY depth ASC, relationId ASC
+ ";
+
+ $bindVars = [
+ $type,
+ $object,
+ $relationFilter,
+ $maxDepth,
+ $relationFilter
+ ];
+
+ $rows = $this->fetchAll($query, $bindVars);
+
+ $results = [];
+ $counts = [];
+
+ foreach ($rows as $row) {
+ $depth = (int)$row['depth'];
+
+ // Skip excluded levels
+ if (isset($excludeLevels[$depth])) {
+ continue;
+ }
+
+ // Enforce maxPerLevel limit in PHP
+ if (! isset($counts[$depth])) {
+ $counts[$depth] = 0;
+ }
+ if ($counts[$depth] >= $maxPerLevel) {
+ continue;
+ }
+
+ // Format result to match previous output structure
+ $results[$depth][] = [
+ 'type' => $row['target_type'],
+ 'itemId' => $row['target_itemId'],
+ 'relation' => $row['relation'],
+ 'relationId' => $row['relationId'],
+ ];
+ $counts[$depth]++;
+ }
+
+ return $results;
+ }
+
/**
* The relation must contain at least two dots and only lowercase letters.
* NAMESPACE management and relation naming.
=====================================
lib/core/Tracker/Field/AbstractTrackerField.php
=====================================
@@ -59,7 +59,8 @@ abstract class AbstractTrackerField
public static function getFromTrackerAndId(\Tracker_Definition $trackerDefinition, int $fieldId)
{
global $tikilib;
- $row = $tikilib->getOne("SELECT fieldId, trackerId, name , permName FROM tiki_tracker_fields WHERE fieldId=?", [$fieldId]);
+ $result = $tikilib->query("SELECT fieldId, trackerId, name, permName, options, type FROM tiki_tracker_fields WHERE fieldId=?", [$fieldId]);
+ $row = $result->fetchRow();
return static::getInstanceFromTrackerAndRow($trackerDefinition, $row);
}
@@ -71,6 +72,10 @@ abstract class AbstractTrackerField
}
$itemFieldClass = Tracker_Field_Factory::getTrackerItemFieldClassFromType($fieldRow['type']);
$class = $itemFieldClass::getTrackerFieldClass();
+ if (! isset($fieldRow['options'])) {
+ throw new \InvalidArgumentException("Field row is missing required 'options' key for field ID: " . $fieldRow['fieldId']);
+ }
+
$field = $trackerDefinition->getFieldInstanceFromCache($fieldRow['fieldId']);
if (! $field) {
$field = new $class($trackerDefinition, $fieldRow);
=====================================
lib/trackers/trackerlib.php
=====================================
@@ -4120,7 +4120,7 @@ class TrackerLib extends TikiLib
return $cache[$cacheKey];
}
}
- $query = "SELECT tif.`value`, tf.`type`, tf.`fieldId`, i.`trackerId`
+ $query = "SELECT tif.`value`, tf.`type`, tf.`fieldId`, tf.`options`, i.`trackerId`
FROM `tiki_tracker_item_fields` tif
JOIN `tiki_tracker_items` i ON i.`itemId` = tif.`itemId`
JOIN `tiki_tracker_fields` tf ON tf.`fieldId` = tif.`fieldId`
@@ -4141,6 +4141,7 @@ class TrackerLib extends TikiLib
'fieldId' => $row['fieldId'],
'trackerId' => $trackerId,
'type' => $row['type'],
+ 'options' => $row['options'],
'value' => $value
];
=====================================
modules/mod-func-relations_transitive.php
=====================================
@@ -0,0 +1,167 @@
+<?php
+
+// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project
+//
+// All Rights Reserved. See copyright.txt for details and a complete list of authors.
+// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.
+
+/**
+ * @return array
+ */
+function module_relations_transitive_info()
+{
+ return [
+ 'name' => tra('Transitive Relations'),
+ 'description' => tra('Shows objects related through multi-hop connections (relations of relations).'),
+ 'prefs' => [],
+ 'params' => [
+ 'type' => [
+ 'required' => false,
+ 'name' => tra('Object Type'),
+ 'description' => tra('Type of object to show relations for (e.g. trackeritem). If not provided, uses current page context.'),
+ 'filter' => 'text',
+ 'default' => '',
+ ],
+ 'object' => [
+ 'required' => false,
+ 'name' => tra('Object ID'),
+ 'description' => tra('ID of the object to show relations for. If not provided, uses current page context.'),
+ 'filter' => 'text',
+ 'default' => '',
+ ],
+ 'relation' => [
+ 'required' => false,
+ 'name' => tra('Relation'),
+ 'description' => tra('Relation qualifier to filter by (supports wildcard with trailing dot). Leave empty for all relations.'),
+ 'filter' => 'text',
+ 'default' => '',
+ ],
+ 'maxdepth' => [
+ 'required' => false,
+ 'name' => tra('Maximum Depth'),
+ 'description' => tra('Maximum number of hops to traverse (1-10).'),
+ 'filter' => 'int',
+ 'default' => 3,
+ ],
+ 'excludelevels' => [
+ 'required' => false,
+ 'name' => tra('Exclude Levels'),
+ 'description' => tra('Comma-separated list of depth levels to exclude. For example, "1" excludes direct (1st-level) relations.'),
+ 'filter' => 'text',
+ 'default' => '',
+ ],
+ 'maxperlevel' => [
+ 'required' => false,
+ 'name' => tra('Max Per Level'),
+ 'description' => tra('Maximum number of results to show per depth level.'),
+ 'filter' => 'int',
+ 'default' => 50,
+ ],
+ ],
+ 'common_params' => ['nonums', 'rows']
+ ];
+}
+
+/**
+ * @param $mod_reference
+ * @param $module_params
+ */
+function module_relations_transitive($mod_reference, $module_params)
+{
+ $smarty = TikiLib::lib('smarty');
+ $relationlib = TikiLib::lib('relation');
+ $objectlib = TikiLib::lib('object');
+
+ // Determine context object
+ $objectType = ! empty($module_params['type']) ? $module_params['type'] : '';
+ $objectId = ! empty($module_params['object']) ? $module_params['object'] : '';
+
+ if (empty($objectType) || empty($objectId)) {
+ $object = current_object();
+ if (! empty($object)) {
+ $objectType = $object['type'];
+ $objectId = $object['object'];
+ }
+ }
+
+ if (empty($objectType) || empty($objectId)) {
+ $smarty->assign('mod_transitive_relations', []);
+ $smarty->assign('mod_transitive_has_results', false);
+ $smarty->assign('mod_transitive_error', tra('No object specified. Use type and object parameters or view this module on an object page.'));
+ return;
+ }
+
+
+ $relation = isset($module_params['relation']) ? $module_params['relation'] : '';
+ $maxDepth = isset($module_params['maxdepth']) ? (int)$module_params['maxdepth'] : 3;
+ $maxPerLevel = isset($module_params['maxperlevel']) ? (int)$module_params['maxperlevel'] : 50;
+
+
+ $excludeLevels = [];
+ if (! empty($module_params['excludelevels'])) {
+ $excludeLevels = array_map('intval', array_map('trim', explode(',', $module_params['excludelevels'])));
+ }
+
+ // Get transitive relations
+ $transitiveRelations = $relationlib->get_transitive_relations(
+ $objectType,
+ $objectId,
+ $relation,
+ $maxDepth,
+ $excludeLevels,
+ $maxPerLevel
+ );
+
+ // Collect all objects to fetch titles in batch
+ $objectsToFetch = [];
+ foreach ($transitiveRelations as $depth => $relations) {
+ foreach ($relations as $rel) {
+ $objectsToFetch[] = [
+ 'type' => $rel['type'],
+ 'id' => $rel['itemId'],
+ ];
+ }
+ }
+
+ $titles = $objectlib->get_titles($objectsToFetch, '');
+
+ // Format for display
+ $enrichedResults = [];
+ $totalCount = 0;
+
+ foreach ($transitiveRelations as $depth => $relations) {
+ $enrichedLevel = [];
+
+ foreach ($relations as $rel) {
+ $key = $rel['type'] . ':' . $rel['itemId'];
+ $title = $titles[$key] ?? '';
+
+ $enrichedLevel[] = [
+ 'type' => $rel['type'],
+ 'itemId' => $rel['itemId'],
+ 'title' => $title,
+ 'relation' => $rel['relation'],
+ 'relationId' => $rel['relationId'],
+ ];
+
+ $totalCount++;
+ }
+
+ if (! empty($enrichedLevel)) {
+ $enrichedResults[$depth] = [
+ 'level' => $depth,
+ 'label' => $depth == 1 ? tra('Direct Relations') :
+ ($depth == 2 ? tra('2nd Level Relations') :
+ ($depth == 3 ? tra('3rd Level Relations') :
+ tr('%0th Level Relations', $depth))),
+ 'items' => $enrichedLevel,
+ 'count' => count($enrichedLevel),
+ ];
+ }
+ }
+
+ $smarty->assign('mod_transitive_relations', $enrichedResults);
+ $smarty->assign('mod_transitive_has_results', $totalCount > 0);
+ $smarty->assign('mod_transitive_total_count', $totalCount);
+ $smarty->assign('mod_transitive_max_depth', $maxDepth);
+}
=====================================
templates/modules/mod-relations_transitive.tpl
=====================================
@@ -0,0 +1,28 @@
+{if $mod_transitive_has_results}
+ {tikimodule error=$module_params.error title=$tpl_module_title name="relations_transitive" flip=$module_params.flip decorations=$module_params.decorations nobox=$module_params.nobox notitle=$module_params.notitle}
+ <div class="mod-transitive-relations">
+ {if ($nonums eq 'y')}<ul class="list-unstyled">{else}<ol class="list-unstyled">{/if}
+ {foreach from=$mod_transitive_relations item=level key=depth}
+ {foreach from=$level.items item=item}
+ <li class="transitive-item mb-1">
+ {object_link type=$item.type id=$item.itemId title=$item.title}
+ {if $depth > 1}
+ <span class="badge rounded-pill bg-light text-dark border small fw-normal ms-1" style="font-size: 0.75em; vertical-align: middle;">
+ {tr}Indirect{/tr}
+ </span>
+ {/if}
+ </li>
+ {/foreach}
+ {/foreach}
+ {if ($nonums eq 'y')}</ul>{else}</ol>{/if}
+
+ {if $mod_transitive_total_count > 0}
+ <div class="text-muted small mt-2">
+ {tr _0=$mod_transitive_total_count _1=$mod_transitive_max_depth}Found %0 relations across %1 levels{/tr}
+ </div>
+ {/if}
+ </div>
+ {/tikimodule}
+{else}
+ {* Only show module if there are results *}
+{/if}
View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/60467fac3113df58082975d9d0aa6adfddcdb6de
--
View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/60467fac3113df58082975d9d0aa6adfddcdb6de
You're receiving this email because of your account on gitlab.com.
_______________________________________________
TikiWiki-cvs mailing list
[email protected]
https://lists.sourceforge.net/lists/listinfo/tikiwiki-cvs