Skip to content

Commit

Permalink
Create @scheduled with logics with CronExpression and CronScheduler
Browse files Browse the repository at this point in the history
  • Loading branch information
RicardoRB committed Feb 11, 2024
1 parent 8aede97 commit b9ac2e9
Show file tree
Hide file tree
Showing 12 changed files with 446 additions and 1 deletion.
4 changes: 4 additions & 0 deletions packages/dartness_server/lib/configuration.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/// Library that exposes the API for the configuration
library configuration;

export 'src/configuration/injectable.dart';
7 changes: 7 additions & 0 deletions packages/dartness_server/lib/schedule.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/// Library that exposes the API for the schedule system
library schedule;

export 'src/configuration/schedule/scheduled.dart';
export 'src/configuration/schedule/time_unit.dart';
export 'src/configuration/schedule/cron_expression.dart';
export 'src/configuration/schedule/cron_scheduler.dart';
24 changes: 24 additions & 0 deletions packages/dartness_server/lib/src/configuration/injectable.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/// A metadata annotation used to mark a class as available to the dependency injection system.
///
/// This annotation can be applied to classes which should be instantiated or managed by the DI framework.
/// Marking a class with `@Injectable` indicates to the DI framework that an instance of the class
/// can be created and provided to other parts of the application that require it.
///
/// Usage:
/// ```
/// @Injectable()
/// class MyService {
/// MyService();
/// }
/// ```
///
/// The DI framework will then manage the lifecycle of the instantiated `MyService` objects,
/// allowing for dependency management and injection into other classes that depend on `MyService`.
///
class Injectable {
/// Creates a new instance of `Injectable`.
///
/// This constructor is typically not invoked directly, but rather the annotation is placed on the class
/// definition to be managed by the DI framework.
const Injectable();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/// A class representing a cron expression, used for scheduling tasks.
class CronExpression {
final List<String> seconds;
final List<String> minutes;
final List<String> hours;
final List<String> days;
final List<String> months;
final List<String> weekdays;

CronExpression({
required this.seconds,
required this.minutes,
required this.hours,
required this.days,
required this.months,
required this.weekdays,
});

/// Parses a cron string into its components.
///
/// The cron string must have 6 parts, separated by spaces, representing:
/// seconds, minutes, hours, day of month, month, and day of week.
factory CronExpression.parse(String cronString) {
final parts = cronString.split(' ');
if (parts.length != 6) {
throw FormatException('Invalid cron string length');
}

// Validate and parse each field
return CronExpression(
seconds: _parseField(parts[0], 0, 59),
minutes: _parseField(parts[1], 0, 59),
hours: _parseField(parts[2], 0, 23),
days: _parseField(parts[3], 1, 31),
months: _parseField(parts[4], 1, 12),
weekdays: _parseField(parts[5], 0, 7),
);
}

static List<String> _parseField(String field, int min, int max) {
// Wildcard
if (field == '*') {
return [field];
}

// Step values
if (field.contains('/')) {
final stepParts = field.split('/');
if (stepParts[0] != '*' &&
!stepParts[0].contains('-') &&
int.tryParse(stepParts[1]) == null) {
throw FormatException('Unsupported or malformed step value');
}
if (stepParts[0] == '*' && int.parse(stepParts[1]) > max) {
throw FormatException('Step value out of range');
}
return [field];
}

// Range values
if (field.contains('-')) {
final rangeParts = field.split('-').map(int.parse).toList();
if (rangeParts[0] > rangeParts[1]) {
throw FormatException('Invalid range');
}
if (rangeParts.any((val) => val < min || val > max)) {
throw FormatException('Range value out of bounds');
}
return [field];
}

// List values
final listParts = field.split(',');
for (var part in listParts) {
final value = int.tryParse(part);
if (value == null || value < min || value > max) {
throw FormatException('Value out of bounds');
}
}
return listParts;
}

@override
String toString() {
return 'CronExpression(seconds: $seconds, minutes: $minutes, hours: $hours, days: $days, months: $months, weekdays: $weekdays)';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import 'dart:async';

import 'cron_expression.dart';
import 'system_time_provider.dart';
import 'time_provider.dart';

/// Scheduler class that uses a [CronExpression] to execute tasks periodically.
class CronScheduler {
final CronExpression cronExpression;
// TimeProvider allows for injecting a custom time source, facilitating testing.
final TimeProvider timeProvider;
Timer? _timer;

// Constructor accepts a cron expression and an optional custom TimeProvider.
// If no TimeProvider is provided, it defaults to the system clock.
CronScheduler(this.cronExpression, {TimeProvider? timeProvider})
: timeProvider = timeProvider ?? SystemTimeProvider();

/// Starts the periodic execution of a task based on the cron expression.
/// [task] is a function with no parameters that encapsulates
/// the invocation of the actual task function with its parameters.
void start(void Function() task) {
_timer = Timer.periodic(Duration(seconds: 1), (Timer t) {
var now = timeProvider.now();
// Checks if the current time matches the cron expression before executing the task.
if (_matches(now)) {
task();
}
});
}

/// Checks if the current time matches the cron expression.
bool _matches(final DateTime dateTime) {
return (cronExpression.seconds.contains('*') ||
cronExpression.seconds.contains(dateTime.second.toString())) &&
(cronExpression.minutes.contains('*') ||
cronExpression.minutes.contains(dateTime.minute.toString())) &&
(cronExpression.hours.contains('*') ||
cronExpression.hours.contains(dateTime.hour.toString())) &&
(cronExpression.days.contains('*') ||
cronExpression.days.contains(dateTime.day.toString())) &&
(cronExpression.months.contains('*') ||
cronExpression.months.contains(dateTime.month.toString())) &&
(cronExpression.weekdays.contains('*') ||
cronExpression.weekdays.contains(dateTime.weekday.toString()));
}

/// Stops the periodic execution of the task.
void stop() {
_timer?.cancel();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import 'package:dartness_server/src/configuration/schedule/time_unit.dart';

/// Represents an annotation for scheduling tasks with various parameters.
class Scheduled {
/// A cron-like expression defining the triggers for the scheduled method.
final String? cron;

/// The fixed delay between the end of the last invocation and the start of the next.
final int? fixedDelay;

/// The fixed delay between the end of the last invocation and the start of the next, parsed from a string.
final String? fixedDelayString;

/// The fixed rate of execution, specifying the period between invocations.
final int? fixedRate;

/// The fixed rate of execution, parsed from a string.
final String? fixedRateString;

/// The number of units of time to delay before the first execution of a fixed-rate or fixed-delay task.
final int? initialDelay;

/// The number of units of time to delay before the first execution, parsed from a string.
final String? initialDelayString;

/// A qualifier for determining a scheduler to run this scheduled method on.
final String? scheduler;

/// The time unit to use for fixedDelay, fixedDelayString, fixedRate, fixedRateString, initialDelay, and initialDelayString.
final TimeUnit? timeUnit;

/// A time zone for which the cron expression will be resolved.
final String? zone;

/// Constructor for the Scheduled annotation.
const Scheduled({
this.cron,
this.fixedDelay,
this.fixedDelayString,
this.fixedRate,
this.fixedRateString,
this.initialDelay,
this.initialDelayString,
this.scheduler,
this.timeUnit,
this.zone,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'time_provider.dart';

// Default implementation of TimeProvider using the system clock.
class SystemTimeProvider implements TimeProvider {
@override
DateTime now() => DateTime.now();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Abstract class defining the interface for a time provider.
abstract class TimeProvider {
// Method that should return the current time.
DateTime now();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/// A TimeUnit represents time durations at a given unit of granularity and provides utility methods
enum TimeUnit {
/// Time unit representing twenty four hours
days,

/// Time unit representing sixty minutes
hours,

/// Time unit representing one thousandth of a millisecond
microseconds,

/// Time unit representing one thousandth of a second
milliseconds,

/// Time unit representing sixty seconds
minutes,

/// Time unit representing one thousandth of a microsecond
nanoseconds,

/// Time unit representing one second
seconds,
}
3 changes: 2 additions & 1 deletion packages/dartness_server/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ dev_dependencies:
http: ^1.1.0
lints: ^3.0.0
test: ^1.24.9
build_runner: ^2.4.6
build_runner: ^2.4.6
fake_async: ^1.3.1
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import 'package:dartness_server/schedule.dart';
import 'package:test/test.dart';

void main() {
group('CronExpression Parsing', () {
test('parses wildcard expressions correctly', () {
final expression = CronExpression.parse('* * * * * *');
expect(expression.seconds, equals(['*']));
expect(expression.minutes, equals(['*']));
expect(expression.hours, equals(['*']));
expect(expression.days, equals(['*']));
expect(expression.months, equals(['*']));
expect(expression.weekdays, equals(['*']));
});

test('parses specific value expressions correctly', () {
final expression = CronExpression.parse('5 30 2 15 6 3');
expect(expression.seconds, equals(['5']));
expect(expression.minutes, equals(['30']));
expect(expression.hours, equals(['2']));
expect(expression.days, equals(['15']));
expect(expression.months, equals(['6']));
expect(expression.weekdays, equals(['3']));
});

test('parses list expressions correctly', () {
final expression = CronExpression.parse('1,5 10,20,30 * * * *');
expect(expression.seconds, equals(['1', '5']));
expect(expression.minutes, equals(['10', '20', '30']));
expect(expression.hours, contains('*'));
});

test('parses range expressions correctly', () {
final expression = CronExpression.parse('1-5 10-12 * * * *');
expect(expression.seconds, equals(['1-5']));
expect(expression.minutes, equals(['10-12']));
});

test('parses step values correctly', () {
final expression = CronExpression.parse('*/5 */10 * * * *');
expect(expression.seconds, equals(['*/5']));
expect(expression.minutes, equals(['*/10']));
});

test('throws FormatException for invalid cron string length', () {
expect(() => CronExpression.parse('5 4 3 2'),
throwsA(isA<FormatException>()));
});

test('throws FormatException for out-of-range values', () {
// Assuming seconds are 0-59, minutes are 0-59, and so on.
// This example checks for an out-of-range minute value.
expect(() => CronExpression.parse('0 60 * * * *'),
throwsA(isA<FormatException>()));
});

test('throws FormatException for invalid characters', () {
// This test assumes the parser does not support special characters or letters
expect(() => CronExpression.parse('* * * * * L'),
throwsA(isA<FormatException>()));
});

test('throws FormatException for invalid range', () {
// Example: Start of range is greater than end
expect(() => CronExpression.parse('30-5 * * * * *'),
throwsA(isA<FormatException>()));
});

test('throws FormatException for unsupported step values', () {
// Example: Step value is used where not supported or is malformed
expect(() => CronExpression.parse('*/60 * * * * *'),
throwsA(isA<FormatException>()));
});
});
}
Loading

0 comments on commit b9ac2e9

Please sign in to comment.