[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
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.