MySQL query to calculate based on start and stop ticket records
I have a MySQL database with ticket records that include a ticket number, event type (start or stop), location, and date. For each ticket number, there are two records representing a start location and an end location.
I need to:
- Search for all records on a specific date
- For each “Stop” record, find the associated “Start” record with the same ticket number
- Perform calculations based on the start and stop information
I’m also questioning whether my database design is optimal, as I currently have separate rows for each ticket’s start and stop events. I’m considering if it would be better to have a single entry that gets updated with the stop data when received.
I’m implementing this solution in PHP.
To calculate based on start and stop ticket records in MySQL, you’ll need to join the ticket records with themselves to match start and stop events for the same ticket number. The optimal approach uses a self-join with conditional matching to pair start and stop records.
Contents
- Basic Query Solution
- Database Design Considerations
- Advanced Query Techniques
- PHP Implementation
- Performance Optimization
- Best Practices
Basic Query Solution
The most straightforward approach uses a self-join to match start and stop records:
SELECT
stop.ticket_number,
stop.date AS stop_date,
stop.location AS stop_location,
start.date AS start_date,
start.location AS start_location,
TIMESTAMPDIFF(HOUR, start.date, stop.date) AS duration_hours,
TIMESTAMPDIFF(MINUTE, start.date, stop.date) AS duration_minutes
FROM
tickets stop
INNER JOIN
tickets start ON stop.ticket_number = start.ticket_number
AND stop.event_type = 'Stop'
AND start.event_type = 'Start'
WHERE
stop.date = '2024-01-15'
AND start.date = '2024-01-15'
This query joins the tickets table with itself, matching records where:
- The ticket numbers are the same
- One record has
event_type = 'Stop'and the other hasevent_type = 'Start' - Both records are from the specified date
Database Design Considerations
Your current design with separate rows for start and stop events has both advantages and disadvantages:
Current Design (Separate Rows):
- Pros:
- Historical tracking of all events
- Easy to audit complete ticket lifecycle
- Simple to add new event types
- Cons:
- Requires joins for basic calculations
- More complex queries
- Potential for orphaned records (start without stop)
Alternative Design (Single Updated Record):
CREATE TABLE tickets (
ticket_number INT PRIMARY KEY,
start_location VARCHAR(100),
start_date DATETIME,
stop_location VARCHAR(100),
stop_date DATETIME,
duration_hours INT,
status ENUM('active', 'completed', 'cancelled'),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
When to choose which design:
- Use separate rows if you need comprehensive audit trails
- Use single records if performance is critical and audit trails can be handled separately
- Consider a hybrid approach with both designs
Advanced Query Techniques
Using ROW_NUMBER() for More Complex Matching
For scenarios where you need more sophisticated matching logic:
WITH numbered_tickets AS (
SELECT
ticket_number,
location,
date,
event_type,
CASE
WHEN event_type = 'Start' THEN 1
WHEN event_type = 'Stop' THEN 2
END AS event_order,
ROW_NUMBER() OVER (PARTITION BY ticket_number ORDER BY date, event_type) AS rn
FROM tickets
WHERE date = '2024-01-15'
)
SELECT
t1.ticket_number,
t1.location AS start_location,
t1.date AS start_date,
t2.location AS stop_location,
t2.date AS stop_date,
TIMESTAMPDIFF(HOUR, t1.date, t2.date) AS duration_hours
FROM numbered_tickets t1
JOIN numbered_tickets t2 ON t1.ticket_number = t2.ticket_number
AND t1.event_order = 1
AND t2.event_order = 2
AND t1.rn = t2.rn - 1
Handling Missing Records
To handle cases where start or stop records might be missing:
SELECT
COALESCE(stop.ticket_number, start.ticket_number) AS ticket_number,
stop.location AS stop_location,
stop.date AS stop_date,
start.location AS start_location,
start.date AS start_date,
CASE
WHEN stop.ticket_number IS NOT NULL AND start.ticket_number IS NOT NULL
THEN TIMESTAMPDIFF(HOUR, start.date, stop.date)
ELSE NULL
END AS duration_hours
FROM
(SELECT * FROM tickets WHERE event_type = 'Stop' AND date = '2024-01-15') stop
FULL OUTER JOIN
(SELECT * FROM tickets WHERE event_type = 'Start' AND date = '2024-01-15') start
ON stop.ticket_number = start.ticket_number
PHP Implementation
Here’s a complete PHP implementation for your solution:
<?php
class TicketCalculator {
private $pdo;
public function __construct($pdo) {
$this->pdo = $pdo;
}
/**
* Calculate ticket durations for a specific date
*/
public function calculateDailyDurations($date) {
$sql = "SELECT
stop.ticket_number,
stop.date AS stop_date,
stop.location AS stop_location,
start.date AS start_date,
start.location AS start_location,
TIMESTAMPDIFF(HOUR, start.date, stop.date) AS duration_hours,
TIMESTAMPDIFF(MINUTE, start.date, stop.date) AS duration_minutes
FROM tickets stop
INNER JOIN tickets start
ON stop.ticket_number = start.ticket_number
AND stop.event_type = 'Stop'
AND start.event_type = 'Start'
WHERE
stop.date = :date
AND start.date = :date";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(['date' => $date]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Generate daily report with statistics
*/
public function generateDailyReport($date) {
$tickets = $this->calculateDailyDurations($date);
$totalTickets = count($tickets);
$totalHours = array_sum(array_column($tickets, 'duration_hours'));
$averageDuration = $totalTickets > 0 ? $totalHours / $totalTickets : 0;
return [
'date' => $date,
'total_tickets' => $totalTickets,
'total_hours' => $totalHours,
'average_duration_hours' => round($averageDuration, 2),
'tickets' => $tickets
];
}
/**
* Check for missing start or stop records
*/
public function findIncompleteTickets($date) {
// Find tickets with stop but no start
$sql = "SELECT DISTINCT t1.ticket_number, 'missing_start' AS issue
FROM tickets t1
LEFT JOIN tickets t2 ON t1.ticket_number = t2.ticket_number
AND t1.event_type = 'Stop'
AND t2.event_type = 'Start'
WHERE t1.event_type = 'Stop'
AND t1.date = :date
AND t2.ticket_number IS NULL
UNION
SELECT DISTINCT t2.ticket_number, 'missing_stop' AS issue
FROM tickets t2
LEFT JOIN tickets t1 ON t2.ticket_number = t1.ticket_number
AND t2.event_type = 'Start'
AND t1.event_type = 'Stop'
WHERE t2.event_type = 'Start'
AND t2.date = :date
AND t1.ticket_number IS NULL";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(['date' => $date]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}
// Usage example
try {
$pdo = new PDO('mysql:host=localhost;dbname=tickets_db', 'username', 'password');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$calculator = new TicketCalculator($pdo);
// Generate report for January 15, 2024
$report = $calculator->generateDailyReport('2024-01-15');
echo "Daily Report for {$report['date']}:";
echo "Total Tickets: {$report['total_tickets']}";
echo "Total Hours: {$report['total_hours']}";
echo "Average Duration: {$report['average_duration_hours']} hours";
// Check for incomplete tickets
$incomplete = $calculator->findIncompleteTickets('2024-01-15');
if (!empty($incomplete)) {
echo "Incomplete tickets found:";
print_r($incomplete);
}
} catch (PDOException $e) {
echo "Database error: " . $e->getMessage();
}
?>
Performance Optimization
Indexing Strategy
For optimal performance, ensure you have proper indexes:
-- Create indexes for the most common query patterns
CREATE INDEX idx_ticket_number ON tickets(ticket_number);
CREATE INDEX idx_event_type ON tickets(event_type);
CREATE INDEX idx_date ON tickets(date);
CREATE INDEX idx_ticket_event_date ON tickets(ticket_number, event_type, date);
-- Composite index for the most common query
CREATE INDEX idx_composite_query ON tickets(date, event_type, ticket_number);
Query Optimization Techniques
-
Use EXPLAIN to analyze query execution plans:
sqlEXPLAIN SELECT stop.ticket_number FROM tickets stop INNER JOIN tickets start ON stop.ticket_number = start.ticket_number WHERE stop.date = '2024-01-15'; -
Limit results when working with large datasets:
sql-- Add LIMIT for testing, then remove for production SELECT ... LIMIT 1000; -
Consider materialized views for frequently accessed reports:
sqlCREATE TABLE daily_ticket_stats ( report_date DATE PRIMARY KEY, total_tickets INT, total_hours DECIMAL(10,2), generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );
Best Practices
-
Data Validation: Ensure data integrity at the application level
php// Validate that start date comes before stop date if ($startDate >= $stopDate) { throw new InvalidArgumentException("Stop date must be after start date"); } -
Transaction Management: Use transactions for critical operations
php$pdo->beginTransaction(); try { // Insert both start and stop records $pdo->commit(); } catch (Exception $e) { $pdo->rollBack(); throw $e; } -
Error Handling: Implement comprehensive error handling
phpprivate function validateTicketData($data) { $required = ['ticket_number', 'event_type', 'location', 'date']; foreach ($required as $field) { if (empty($data[$field])) { throw new InvalidArgumentException("Missing required field: {$field}"); } } } -
Caching: Cache frequent queries to reduce database load
php// Example using file-based caching $cacheFile = "cache/daily_report_{$date}.json"; if (file_exists($cacheFile) && time() - filemtime($cacheFile) < 3600) { return json_decode(file_get_contents($cacheFile), true); } -
Monitoring: Set up monitoring for query performance and data quality
sql-- Monitor query execution times SELECT * FROM performance_schema.events_statements_summary_by_digest WHERE digest_text LIKE '%calculateDailyDurations%' ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;
Your current database design with separate rows for start and stop events is actually quite common and provides good auditability. The performance impact of the joins is typically minimal unless you’re dealing with millions of records. For most use cases, the provided solution will work efficiently with proper indexing.
Sources
- MySQL Join Made Easy For Beginners
- MySQL JOIN - GeeksforGeeks
- MySQL JOIN types - INNER JOIN, SELF JOIN, CROSS JOIN, FULL OUTER
- MySQL 8.0 Reference Manual - SELECT Statement
- Database Administrators - How to avoid deadlocks when generating consecutive ticket numbers
- Stack Overflow - Making a MySQL query return/stop executing after finding the first match
Conclusion
To effectively calculate based on start and stop ticket records in MySQL:
- Use self-joins to match start and stop records by ticket number and event types
- Implement proper indexing on ticket_number, event_type, and date columns for optimal performance
- Consider your database design - separate rows provide auditability while single records offer simplicity
- Use PHP classes to encapsulate business logic and improve code maintainability
- Handle edge cases like missing records and data validation
- Monitor performance and optimize queries as your dataset grows
The solution provided balances performance with data integrity and provides a solid foundation for your ticket management system. Start with the basic query approach and gradually implement more advanced features as your requirements evolve.