Skip to content
Closed
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
.idea/
.vscode/

# llm
.claude
docs/superpowers

# os
.DS_Store

Expand Down
211 changes: 130 additions & 81 deletions src/Migration/History/LogGroupingService.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public function __construct(
/**
* @throws Exception
*
* @return array{total: int, items: array<int, array{code: string, entityName: string|null, fieldName: string|null, count: int, fixCount: int}>, levelCounts: array{error: int, warning: int, info: int}}
* @return array{total: int, items: array<int, array{code: string, entityName: string|null, fieldName: string|null, profileName: string, gatewayName: string, count: int, fixCount: int, isPreviouslyFixed: bool}>, levelCounts: array{error: int, warning: int, info: int}}
*/
public function getGroupedLogsByCodeAndEntity(
string $runUuid,
Expand Down Expand Up @@ -123,15 +123,19 @@ public function getGroupedLogsByCodeAndEntity(

$additionalWhere = $whereConditions !== [] ? ' AND ' . \implode(' AND ', $whereConditions) : '';

// determine if we need the fix join for status filtering
$includeFixJoin = $validatedFilterStatus !== null;
// for the previously fixed part of the UNION, entity/field conditions reference different columns
$additionalWherePreviouslyFixed = \str_replace(
['l.entity_name', 'l.field_name'],
['f.entity_name', 'f.path'],
$additionalWhere
);

$sql = $this->buildMainQuery(
$additionalWhere,
$additionalWherePreviouslyFixed,
$validatedFilterStatus,
$orderColumn,
$orderDirection,
$includeFixJoin
);

$result = $this->connection->executeQuery($sql, $params, [
Expand All @@ -145,8 +149,8 @@ public function getGroupedLogsByCodeAndEntity(
$levelCounts = $this->getLogLevelCounts(
$params,
$additionalWhere,
$additionalWherePreviouslyFixed,
$validatedFilterStatus,
$includeFixJoin
);

return [
Expand Down Expand Up @@ -298,89 +302,109 @@ private function validateFilterStatus(?string $filterStatus): ?string
}

/**
* builds the main grouped logs query.
* builds the unified grouped-logs query.
*
* Uses UNION ALL to combine current-run log groups with previously fixed groups
* (fixes that exist for this connection but have no log in the current run).
* A window function replaces the old count subquery for simpler pagination totals.
*
* Filter status is applied as an outer WHERE rather than HAVING on each branch,
* which naturally excludes previously fixed groups when filtering for 'unresolved'
* (they always have count = fix_count) and includes them for 'resolved'.
*/
private function buildMainQuery(
string $additionalWhere,
string $additionalWherePreviouslyFixed,
?string $filterStatus,
string $orderColumn,
string $orderDirection,
bool $includeFixJoin,
): string {
// build HAVING clause from validated filter status
$havingClause = match ($filterStatus) {
'resolved' => 'HAVING COUNT(DISTINCT l.id) > 0 AND COUNT(DISTINCT l.id) = COUNT(DISTINCT f.id)',
'unresolved' => 'HAVING COUNT(DISTINCT l.id) > 0 AND COUNT(DISTINCT l.id) != COUNT(DISTINCT f.id)',
default => '',
};

// build count subquery components
$countSubqueryFixJoin = $includeFixJoin
? 'LEFT JOIN swag_migration_fix f2 ON (
f2.connection_id = :connectionId
AND f2.entity_name = l2.entity_name
AND f2.path = l2.field_name
AND f2.entity_id = l2.entity_id
)'
: '';
/*
* @SECURITY $orderColumn is validated against ALLOWED_SORT_COLUMNS before reaching here.
* The outer ORDER BY uses unqualified aliases, so strip the 'l.' table qualifier.
*/
$outerOrderColumn = \str_replace('l.', '', $orderColumn);

$countSubqueryHaving = match ($filterStatus) {
'resolved' => 'HAVING COUNT(DISTINCT l2.id) > 0 AND COUNT(DISTINCT l2.id) = COUNT(DISTINCT f2.id)',
'unresolved' => 'HAVING COUNT(DISTINCT l2.id) > 0 AND COUNT(DISTINCT l2.id) != COUNT(DISTINCT f2.id)',
$outerWhereClause = match ($filterStatus) {
'resolved' => 'WHERE `count` > 0 AND `count` = fix_count',
'unresolved' => 'WHERE `count` > 0 AND `count` != fix_count',
default => '',
};

$countSubqueryWhere = \str_replace('l.', 'l2.', $additionalWhere);

/*
* MAIN QUERY STRUCTURE:
* this query groups logs by code, entity_name, and field_name, counting occurrences
* and tracking fix status. The subquery calculates total count for pagination.
*/
return "
SELECT
l.code,
l.entity_name,
l.field_name,
l.gateway_name,
l.profile_name,
COUNT(DISTINCT l.id) as count,
(
SELECT COUNT(*)
FROM (
SELECT 1
FROM swag_migration_logging l2
{$countSubqueryFixJoin}
WHERE l2.run_id = :runId
AND l2.level = :level
AND l2.user_fixable = 1
{$countSubqueryWhere}
GROUP BY l2.code, l2.entity_name, l2.field_name
{$countSubqueryHaving}
) as grouped_logs
) as total,
COUNT(DISTINCT f.id) as fix_count
FROM swag_migration_logging l
LEFT JOIN swag_migration_fix f ON (
f.connection_id = :connectionId
AND f.entity_name = l.entity_name
AND f.path = l.field_name
AND f.entity_id = l.entity_id
)
WHERE l.run_id = :runId
AND l.level = :level
AND l.user_fixable = 1
{$additionalWhere}
GROUP BY l.code, l.entity_name, l.field_name
{$havingClause}
ORDER BY {$orderColumn} {$orderDirection}, l.code ASC, l.entity_name ASC, l.field_name ASC
code,
entity_name,
field_name,
gateway_name,
profile_name,
count,
fix_count,
is_previously_fixed,
COUNT(*) OVER() AS total
FROM (
SELECT
l.code,
l.entity_name,
l.field_name,
l.gateway_name,
l.profile_name,
COUNT(DISTINCT l.id) AS count,
0 AS fix_count,
0 AS is_previously_fixed
FROM swag_migration_logging l
LEFT JOIN swag_migration_fix f ON (
f.connection_id = :connectionId
AND f.entity_name = l.entity_name
AND f.path = l.field_name
AND f.entity_id = l.entity_id
)
WHERE l.run_id = :runId
AND l.level = :level
AND l.user_fixable = 1
AND f.id IS NULL
{$additionalWhere}
GROUP BY l.code, l.entity_name, l.field_name

UNION ALL

SELECT
l.code,
f.entity_name,
f.path AS field_name,
MIN(l.gateway_name) AS gateway_name,
MIN(l.profile_name) AS profile_name,
COUNT(DISTINCT f.entity_id) AS count,
COUNT(DISTINCT f.entity_id) AS fix_count,
1 AS is_previously_fixed
FROM swag_migration_fix f
JOIN swag_migration_logging l ON (
l.entity_id = f.entity_id
AND l.entity_name = f.entity_name
AND l.field_name = f.path
AND l.user_fixable = 1
AND l.level = :level
)
JOIN swag_migration_run r ON (
l.run_id = r.id
AND r.connection_id = :connectionId
)
WHERE f.connection_id = :connectionId
{$additionalWherePreviouslyFixed}
GROUP BY l.code, f.entity_name, f.path
) AS unified
{$outerWhereClause}
ORDER BY {$outerOrderColumn} {$orderDirection}, code ASC, entity_name ASC, field_name ASC
LIMIT :limit OFFSET :offset
";
}

/**
* gets log counts grouped by level for the filter badge counts.
*
* Uses the same UNION ALL structure as buildMainQuery so that previously fixed
* groups are reflected in the badge counts alongside current-run groups.
*
* @param array<string, mixed> $params
*
* @throws Exception
Expand All @@ -390,25 +414,47 @@ private function buildMainQuery(
private function getLogLevelCounts(
array $params,
string $additionalWhere,
string $additionalWherePreviouslyFixed,
?string $filterStatus,
bool $includeFixJoin,
): array {
$joinClause = $includeFixJoin
? 'LEFT JOIN swag_migration_fix f ON (
$joinClause = 'LEFT JOIN swag_migration_fix f ON (
f.connection_id = :connectionId
AND f.entity_name = l.entity_name
AND f.path = l.field_name
AND f.entity_id = l.entity_id
)'
: '';
)';

$havingClause = $includeFixJoin
? match ($filterStatus) {
'resolved' => 'HAVING COUNT(DISTINCT l.id) > 0 AND COUNT(DISTINCT l.id) = COUNT(DISTINCT f.id)',
'unresolved' => 'HAVING COUNT(DISTINCT l.id) > 0 AND COUNT(DISTINCT l.id) != COUNT(DISTINCT f.id)',
default => '',
}
: '';
$havingClause = match ($filterStatus) {
'resolved' => 'HAVING COUNT(DISTINCT l.id) > 0 AND COUNT(DISTINCT l.id) = COUNT(DISTINCT f.id)',
'unresolved' => 'HAVING COUNT(DISTINCT l.id) > 0 AND COUNT(DISTINCT l.id) != COUNT(DISTINCT f.id)',
default => '',
};

$previouslyFixedUnion = $filterStatus !== 'unresolved'
? "
UNION ALL

SELECT
l.level,
l.code,
f.entity_name,
f.path AS field_name
FROM swag_migration_fix f
JOIN swag_migration_logging l ON (
l.entity_id = f.entity_id
AND l.entity_name = f.entity_name
AND l.field_name = f.path
AND l.user_fixable = 1
)
JOIN swag_migration_run r ON (
l.run_id = r.id
AND r.connection_id = :connectionId
)
WHERE f.connection_id = :connectionId
{$additionalWherePreviouslyFixed}
GROUP BY l.level, l.code, f.entity_name, f.path
"
: '';

$sql = "
SELECT
Expand All @@ -424,9 +470,11 @@ private function getLogLevelCounts(
{$joinClause}
WHERE l.run_id = :runId
AND l.user_fixable = 1
AND f.id IS NULL
{$additionalWhere}
GROUP BY l.level, l.code, l.entity_name, l.field_name
{$havingClause}
{$previouslyFixedUnion}
) as filtered_logs
GROUP BY level
";
Expand Down Expand Up @@ -456,7 +504,7 @@ private function getConnectionIdForRun(string $runIdBytes): string
/**
* @param array<array<string, mixed>> $rows
*
* @return array<int, array{code: string, entityName: string|null, fieldName: string|null, profileName: string, gatewayName: string, count: int, fixCount: int}>
* @return array<int, array{code: string, entityName: string|null, fieldName: string|null, profileName: string, gatewayName: string, count: int, fixCount: int, isPreviouslyFixed: bool}>
*/
private function mapLogsFromRows(array $rows): array
{
Expand All @@ -469,6 +517,7 @@ private function mapLogsFromRows(array $rows): array
'gatewayName' => $row['gateway_name'],
'count' => (int) $row['count'],
'fixCount' => (int) $row['fix_count'],
'isPreviouslyFixed' => (bool) $row['is_previously_fixed'],
],
$rows
);
Expand Down
Loading
Loading