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
12 changes: 12 additions & 0 deletions ProcessMaker/Http/Controllers/Api/TaskController.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,18 @@ public function index(Request $request, $getTotal = false, User $user = null)

$query = $this->indexBaseQuery($request);

// Get fields from request (sent by frontend)
// If not provided, don't apply select() to maintain backward compatibility (returns all columns)
$fields = $request->input('fields', '');
if ($fields) {
$selectedFields = explode(',', $fields);
// Ensure 'id' is always included for internal logic (e.g., inOverdueQuery at line ~186)
if (!in_array('id', $selectedFields)) {
$selectedFields[] = 'id';
}
$query = $query->select($selectedFields);
}

$this->applyFilters($query, $request);

$this->excludeNonVisibleTasks($query, $request);
Expand Down
3 changes: 1 addition & 2 deletions ProcessMaker/Models/ProcessRequestToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -782,8 +782,7 @@ public function valueAliasStatus($value, $expression, $callback = null, User $us

$query->whereIn('process_request_tokens.id', $selfServiceTaskIds);
} elseif ($user) {
$taskIds = $user->availableSelfServiceTaskIds();
$query->whereIn('process_request_tokens.id', $taskIds);
$query->whereIn('process_request_tokens.id', $user->availableSelfServiceTasksQuery());
} else {
$query->where('process_request_tokens.is_self_service', 1);
}
Expand Down
11 changes: 8 additions & 3 deletions ProcessMaker/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -466,11 +466,11 @@ public function removeFromGroups()
$this->groups()->detach();
}

public function availableSelfServiceTaskIds()
public function availableSelfServiceTasksQuery()
{
$groupIds = $this->groups()->pluck('groups.id');

$taskQuery = ProcessRequestToken::select(['id'])
$taskQuery = ProcessRequestToken::select(['process_request_tokens.id'])
->where([
'is_self_service' => true,
'status' => 'ACTIVE',
Expand All @@ -490,7 +490,12 @@ public function availableSelfServiceTaskIds()
$query->orWhereJsonContains('self_service_groups->users', (string) $this->id);
});

return $taskQuery->pluck('id');
return $taskQuery;
}

public function availableSelfServiceTaskIds()
{
return $this->availableSelfServiceTasksQuery()->pluck('id');
}

/**
Expand Down
2 changes: 1 addition & 1 deletion ProcessMaker/Traits/TaskControllerIndexMethods.php
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ private function applyForCurrentUser($query, $user)

$query->where(function ($query) use ($user) {
$query->where('user_id', $user->id)
->orWhereIn('id', $user->availableSelfServiceTaskIds());
->orWhereIn('id', $user->availableSelfServiceTasksQuery());
});
}

Expand Down
22 changes: 21 additions & 1 deletion resources/js/requests/components/RequestDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -184,13 +184,33 @@ export default {
this.status
}&per_page=${
this.perPage
}${this.getSortParam()}`,
}${this.getSortParam()}${this.getColumnsParam()}`,
)
.then((response) => {
this.data = this.transform(response.data);
this.loading = false;
});
},
/**
* Get the fields parameter for the API request
* @returns {string} The fields parameter for the API request
*/
getColumnsParam() {
const fields = [
'id',
'element_id', // Required by assignableUsers relationship (TokenAssignableUsers::match uses element_id)
'element_name',
'user_id',
'process_id',
'process_request_id',
'status',
'due_at',
'is_self_service',
'is_actionbyemail',
'self_service_groups', // Required by Task resource's addAssignableUsers() method when recalculating assignable users
];
return `&fields=${fields.join(',')}`;
},
getSortParam() {
if (this.sortOrder instanceof Array && this.sortOrder.length > 0) {
return (
Expand Down
181 changes: 181 additions & 0 deletions tests/Feature/SelfServiceOptimizationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use ProcessMaker\Models\Group;
use ProcessMaker\Models\ProcessRequestToken;
use ProcessMaker\Models\User;
use Tests\TestCase;

class SelfServiceOptimizationTest extends TestCase
{
use RefreshDatabase;

/**
* BULLETPROOF TEST: Verifies that the Subquery approach is 100% equivalent
* to the Array approach across all possible edge cases and legacy formats.
*/
public function test_subquery_optimization_is_bulletproof()
{
// 1. Setup Environment
$user = User::factory()->create();
$groupA = Group::factory()->create(['name' => 'Group A']);
$groupB = Group::factory()->create(['name' => 'Group B']);
$user->groups()->attach([$groupA->id, $groupB->id]);

$otherUser = User::factory()->create();
$otherGroup = Group::factory()->create(['name' => 'Other Group']);

// 2. CREATE SCENARIOS

// Scenario 1: New Format - Int ID in groups array
$t1 = $this->createSelfServiceTask(['groups' => [$groupA->id]]);

// Scenario 2: New Format - String ID in groups array (Legacy/JSON inconsistency)
$t2 = $this->createSelfServiceTask(['groups' => [(string) $groupB->id]]);

// Scenario 3: Old Format - Direct ID in array (Very old processes)
$t3 = $this->createSelfServiceTask([$groupA->id]);

// Scenario 4: Direct User Assignment (Int)
$t4 = $this->createSelfServiceTask(['users' => [$user->id]]);

// Scenario 5: Direct User Assignment (String)
$t5 = $this->createSelfServiceTask(['users' => [(string) $user->id]]);

// --- NEGATIVE SCENARIOS (Should NEVER be returned) ---

// Scenario 6: Task for another group
$t6 = $this->createSelfServiceTask(['groups' => [$otherGroup->id]]);

// Scenario 7: Task for another user
$t7 = $this->createSelfServiceTask(['users' => [$otherUser->id]]);

// Scenario 8: Task is not ACTIVE
$t8 = $this->createSelfServiceTask(['users' => [$user->id]], 'COMPLETED');

// Scenario 9: Task is already assigned to someone
$t9 = $this->createSelfServiceTask(['users' => [$user->id]], 'ACTIVE', $otherUser->id);

// 3. THE COMPARISON ENGINE

// Method A: Array Pluck (Memory intensive, prone to crash)
$oldWayIds = $user->availableSelfServiceTaskIds()->sort()->values()->toArray();

// Method B: Subquery (Optimized, safe)
$newWayQuery = $user->availableSelfServiceTasksQuery();
$resultsNewWay = ProcessRequestToken::whereIn('id', $newWayQuery)
->orderBy('id')
->pluck('id')
->toArray();

// 4. ASSERTIONS

// A. Integrity check: Both lists must be identical
$this->assertEquals($oldWayIds, $resultsNewWay, 'FATAL: Subquery results differ from Array results!');

// B. Coverage check: Ensure all positive scenarios are present
$expectedIds = [$t1->id, $t2->id, $t3->id, $t4->id, $t5->id];
sort($expectedIds);
$this->assertEquals($expectedIds, $resultsNewWay, 'Subquery missed one of the valid scenarios.');

// C. Exclusion check: Ensure none of the negative scenarios leaked in
$forbiddenIds = [$t6->id, $t7->id, $t8->id, $t9->id];
foreach ($forbiddenIds as $id) {
$this->assertNotContains($id, $resultsNewWay, "Security breach: Task $id should not be visible.");
}

// D. Performance Logic check: Subquery must be an instance of Eloquent Builder
$this->assertInstanceOf(\Illuminate\Database\Eloquent\Builder::class, $newWayQuery);
}

/**
* STRESS TEST: Demonstrates the performance and stability gap.
* This test creates 10,000 tasks to show how the old way struggles vs the new way.
*/
public function test_large_data_performance_and_stability()
{
$user = User::factory()->create();
$group = Group::factory()->create();
$user->groups()->attach($group);

// Crear dependencias reales para evitar errores de Foreign Key
$process = \ProcessMaker\Models\Process::factory()->create();
$request = \ProcessMaker\Models\ProcessRequest::factory()->create([
'process_id' => $process->id,
]);

echo "\n--- STRESS TEST (10,000 Self-Service Tasks) ---\n";

// 1. Seed 10,000 tasks efficiently using bulk insert
$count = 10000;
$now = now()->toDateTimeString();
$chunkSize = 1000;

for ($i = 0; $i < $count / $chunkSize; $i++) {
$tasks = [];
for ($j = 0; $j < $chunkSize; $j++) {
$tasks[] = [
'process_id' => $process->id,
'process_request_id' => $request->id,
'element_id' => 'node_1',
'element_type' => 'task',
'status' => 'ACTIVE',
'is_self_service' => 1,
'self_service_groups' => json_encode(['groups' => [$group->id]]),
'created_at' => $now,
'updated_at' => $now,
];
}
DB::table('process_request_tokens')->insert($tasks);
}

// 2. Measure OLD WAY (Array of IDs)
$startMemOld = memory_get_usage();
$startTimeOld = microtime(true);

$ids = $user->availableSelfServiceTaskIds();
$resultOld = ProcessRequestToken::whereIn('id', $ids)->count();

$timeOld = microtime(true) - $startTimeOld;
$memOld = (memory_get_usage() - $startMemOld) / 1024 / 1024;

// 3. Measure NEW WAY (Subquery)
$startMemNew = memory_get_usage();
$startTimeNew = microtime(true);

$query = $user->availableSelfServiceTasksQuery();
$resultNew = ProcessRequestToken::whereIn('id', $query)->count();

$timeNew = microtime(true) - $startTimeNew;
$memNew = (memory_get_usage() - $startMemNew) / 1024 / 1024;

// OUTPUT RESULTS
echo 'OLD WAY (Array): Time: ' . number_format($timeOld, 4) . 's | Mem: ' . number_format($memOld, 2) . "MB | Found: $resultOld\n";
echo 'NEW WAY (Subquery): Time: ' . number_format($timeNew, 4) . 's | Mem: ' . number_format($memNew, 2) . "MB | Found: $resultNew\n";

// ASSERTIONS
$this->assertEquals($resultOld, $resultNew, 'Results must be identical!');

// En base de datos reales (no en memoria), la subconsulta suele ser más rápida
// Pero lo más importante es que no tiene límites de placeholders
$this->assertTrue($resultNew > 0);

echo "----------------------------------------------\n";
if ($timeNew > 0) {
echo 'Optimization: ' . number_format(($timeOld / $timeNew), 1) . "x faster\n";
}
}

private function createSelfServiceTask($groups, $status = 'ACTIVE', $userId = null)
{
return ProcessRequestToken::factory()->create([
'is_self_service' => true,
'status' => $status,
'user_id' => $userId,
'self_service_groups' => $groups,
]);
}
}
8 changes: 4 additions & 4 deletions tests/unit/ProcessMaker/FilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -227,10 +227,10 @@ public function testTaskStatusSelfservice()
],
], ProcessRequestToken::class);

$this->assertEquals(
"select * from `process_request_tokens` where ((`process_request_tokens`.`id` in ({$selfServiceTask->id})))",
$sql
);
$this->assertStringContainsString('select * from `process_request_tokens` where ((`process_request_tokens`.`id` in (select `process_request_tokens`.`id` from `process_request_tokens` where', $sql);
$this->assertStringContainsString('`is_self_service` = 1', $sql);
$this->assertStringContainsString("`status` = 'ACTIVE'", $sql);
$this->assertStringContainsString('json_contains(`self_service_groups`', $sql);
}

public function testTaskStatusActive()
Expand Down
94 changes: 94 additions & 0 deletions tests/unit/ProcessMaker/Models/SelfServiceComparisonTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

namespace Tests\Unit\ProcessMaker\Models;

use Illuminate\Foundation\Testing\RefreshDatabase;
use ProcessMaker\Models\Group;
use ProcessMaker\Models\ProcessRequestToken;
use ProcessMaker\Models\User;
use Tests\TestCase;

class SelfServiceComparisonTest extends TestCase
{
use RefreshDatabase;

/**
* Verifies that the results obtained using the ID list (Array)
* and the subquery (Query Builder) are exactly the same.
*
* @return void
*/
public function test_self_service_results_are_identical_between_array_and_subquery()
{
// 1. Scenario configuration
$user = User::factory()->create();
$group = Group::factory()->create();
$user->groups()->attach($group);

// Task 1: Available by GROUP (Should appear)
$task1 = ProcessRequestToken::factory()->create([
'is_self_service' => true,
'status' => 'ACTIVE',
'user_id' => null,
'self_service_groups' => ['groups' => [$group->id]],
]);

// Task 2: Available by direct USER (Should appear)
$task2 = ProcessRequestToken::factory()->create([
'is_self_service' => true,
'status' => 'ACTIVE',
'user_id' => null,
'self_service_groups' => ['users' => [$user->id]],
]);

// Task 3: NOT available (Another group)
ProcessRequestToken::factory()->create([
'is_self_service' => true,
'status' => 'ACTIVE',
'user_id' => null,
'self_service_groups' => ['groups' => [9999]],
]);

// Task 4: NOT available (Already completed)
ProcessRequestToken::factory()->create([
'is_self_service' => true,
'status' => 'COMPLETED',
'user_id' => null,
'self_service_groups' => ['groups' => [$group->id]],
]);

// 2. Execution of both methods

// Method A: Using the IDs array (Preserved behavior)
$idsArray = $user->availableSelfServiceTaskIds();
$resultsFromArray = ProcessRequestToken::whereIn('id', $idsArray)
->pluck('id')
->sort()
->values()
->toArray();

// Method B: Using the subquery (New optimization)
$subqueryBuilder = $user->availableSelfServiceTasksQuery();
$resultsFromSubquery = ProcessRequestToken::whereIn('id', $subqueryBuilder)
->pluck('id')
->sort()
->values()
->toArray();

// 3. Verification

// Check that tasks were found
$this->assertCount(2, $resultsFromArray, 'Exactly 2 tasks should have been found with the old method.');

// Check that both methods return exactly the same results
$this->assertEquals(
$resultsFromArray,
$resultsFromSubquery,
'Task IDs found by both methods MUST be identical.'
);

// Verify specific IDs are correct
$this->assertContains($task1->id, $resultsFromSubquery);
$this->assertContains($task2->id, $resultsFromSubquery);
}
}
Loading