From ad2837996a2c7e8a6696abdf334f6b44439225aa Mon Sep 17 00:00:00 2001 From: Alexandre Roux Date: Mon, 14 Oct 2024 11:29:28 +0200 Subject: [PATCH] feat: add tags implementation --- lib/list_utils.dart | 1 + lib/src/list_utils_impl.dart | 6 + lib/src/tags_impl.dart | 274 +++++++++++++++++++++++++++++++++++ lib/tags.dart | 1 + test/tags_test.dart | 75 ++++++++++ 5 files changed, 357 insertions(+) create mode 100644 lib/src/tags_impl.dart create mode 100644 lib/tags.dart create mode 100644 test/tags_test.dart diff --git a/lib/list_utils.dart b/lib/list_utils.dart index 9f1ed98..caaeb32 100644 --- a/lib/list_utils.dart +++ b/lib/list_utils.dart @@ -11,6 +11,7 @@ export 'src/list_utils_impl.dart' TekartikCommonIterableExtension, TekartikCommonIterableIterableExtension, TekartikCommonListExtension, + TekartikCommonListListExtension, TekartikCommonListOrNullExtension; /// @Deprecated('User iterable extension TekartikIterableExt.firstOrNull') diff --git a/lib/src/list_utils_impl.dart b/lib/src/list_utils_impl.dart index 12a2a6f..ce19ce4 100644 --- a/lib/src/list_utils_impl.dart +++ b/lib/src/list_utils_impl.dart @@ -14,6 +14,12 @@ extension TekartikCommonListExtension on List { } } +/// Common list list extension. +extension TekartikCommonListListExtension on List> { + /// [[1], [2, 3]].flatten() => [1, 2, 3] + List flatten() => listFlatten(this); +} + /// Common list or null extension. extension TekartikCommonListOrNullExtension on List? { /// If empty return null diff --git a/lib/src/tags_impl.dart b/lib/src/tags_impl.dart new file mode 100644 index 0000000..d4d0342 --- /dev/null +++ b/lib/src/tags_impl.dart @@ -0,0 +1,274 @@ +import 'package:meta/meta.dart'; +import 'package:tekartik_common_utils/list_utils.dart'; +import 'package:tekartik_common_utils/string_utils.dart'; + +/// Tags are a list or trimmed string. +abstract class Tags { + /// Tags from a list of strings. + factory Tags.fromList({List? tags}) { + return _Tags(tags); + } + + /// Tags from a string (tags are comma separated). + factory Tags.fromText(String? text) { + if (text == null) { + return _Tags(null); + } else { + return _Tags(text.split(',').map((String tag) => tag.trim()).toList()); + } + } + + /// Tags as a list of strings. + List toList(); +} + +/// Tags extension. +extension TagsExt on Tags { + /// Tags as a string (tags are comma separated). + String toText() { + return toList().join(','); + } + + /// Tags as a string (tags are comma separated) or null if empty. + String? toTextOrNull() => toText().nonEmpty(); + + /// Tags as a list of strings or null if empty. + List? toListOrNull() => toList().nonEmpty(); + + /// Check if the tags contain a given tag. + bool has(String tag) { + return toList().contains(tag); + } + + /// Add a tag if not already present, return true if added. + bool add(String tag) { + if (has(tag)) { + return false; + } + toList().add(tag); + return true; + } + + /// Remove a tag if present, return true if removed. + bool remove(String tag) { + return toList().remove(tag); + } +} + +class _Tags implements Tags { + final List tags; + + _Tags(List? tags) : tags = List.of(tags ?? []); + + @override + List toList() => tags; + + @override + String toString() => 'Tags(${toText()})'; +} + +/// Tags condition (tag1 && tag2 && !tag3 || tag4 && (tag1 && tag4). +abstract class TagsCondition { + /// Parse a tags condition. + /// Don't assume precedence, use parenthesis. + factory TagsCondition(String expression) { + return _parseTagsCondition(expression.trim()); + } + + /// Check if the tags match the condition. + bool check(Tags tags); + + /// Text representation of the condition. + String toText(); + + String _toInnerText(); +} + +/// A condition is either single or multi +@visibleForTesting +abstract class TagsConditionSingle implements TagsCondition {} + +/// A condition is either single or multi +@visibleForTesting +abstract interface class TagsConditionMulti implements TagsCondition {} + +class _TagsConditionTag implements TagsConditionSingle { + final String tag; + + _TagsConditionTag(this.tag); + + @override + bool check(Tags tags) { + return tags.has(tag); + } + + @override + String toText() => tag; + + @override + String _toInnerText() => tag; +} + +mixin _TagsConditionMixin implements TagsCondition { + @override + String toString() => 'Condition(${toText()})'; +} + +abstract class _TagsConditionSingleBase + with _TagsConditionMixin + implements TagsConditionSingle { + final TagsCondition condition; + + _TagsConditionSingleBase(this.condition); + + @override + String toText() => _toInnerText(); + + @override + String _toInnerText() => condition._toInnerText(); +} + +abstract class _TagsConditionMultiBase + with _TagsConditionMixin + implements TagsConditionMulti { + List conditions; + + _TagsConditionMultiBase(this.conditions); + + List conditionTexts() => + conditions.map((condition) => condition._toInnerText()).toList(); + + @override + String _toInnerText() => '(${toText()})'; +} + +class _TagsConditionAny extends _TagsConditionMultiBase + implements _TagsConditionOr { + _TagsConditionAny(super.conditions); + + @override + bool check(Tags tags) { + for (var condition in conditions.toList()) { + if (condition.check(tags)) { + return true; + } + } + return false; + } + + @override + String toText() => conditionTexts().join(' || '); +} + +class _TagsConditionAll extends _TagsConditionMultiBase + implements _TagsConditionAnd { + _TagsConditionAll(super.conditions); + + @override + bool check(Tags tags) { + for (var condition in conditions.toList()) { + if (!condition.check(tags)) { + return false; + } + } + return true; + } + + @override + String toText() { + var conditionTexts = this.conditionTexts(); + return conditionTexts.join(' && '); + } +} + +abstract class _TagsConditionOr implements TagsConditionMulti { + factory _TagsConditionOr(TagsCondition condition1, TagsCondition condition2) { + return _TagsConditionAny([condition1, condition2]); + } +} + +abstract class _TagsConditionAnd implements TagsConditionMulti { + factory _TagsConditionAnd( + TagsCondition condition1, TagsCondition condition2) { + return _TagsConditionAll([condition1, condition2]); + } +} + +class _TagsConditionNot extends _TagsConditionSingleBase { + _TagsConditionNot(super.condition); + + @override + bool check(Tags tags) { + return !condition.check(tags); + } + + @override + String _toInnerText() => '!${condition._toInnerText()}'; +} + +const _or = '||'; +const _and = '&&'; +var _allOperators = [_or, _and]; + +/// Assumed trimmed +TagsCondition _parseTagsCondition(String expression) { + var parts = expression.splitFirst(' '); + var token = parts.first; + var firstChar = token[0]; + + var not = firstChar == '!'; + + TagsCondition wrapCondition(TagsCondition condition) { + if (not) { + return _TagsConditionNot(condition); + } + return condition; + } + + if (not) { + token = token.substring(1); + } + if (token.isEmpty) { + throw ArgumentError('Missing tag or expression after ! in "$expression"'); + } + late TagsCondition firstCondition; + late String afterFirstCondition; + if (firstChar == '(') { + var endIndex = expression.lastIndexOf(')'); + if (endIndex == -1) { + throw ArgumentError('Missing matching ) in "$expression"'); + } + firstCondition = wrapCondition( + _parseTagsCondition(expression.substring(1, endIndex).trim())); + afterFirstCondition = expression.substring(endIndex + 1).trim(); + } else if (_allOperators.contains(token)) { + throw ArgumentError('Unexpected operator "$token" found in "$expression"'); + } else { + firstCondition = _TagsConditionTag(token); + if (parts.length == 1) { + return wrapCondition(firstCondition); + } + afterFirstCondition = parts.last.trim(); + } + + if (afterFirstCondition.isEmpty) { + return wrapCondition(firstCondition); + } + + /// expect condition + parts = afterFirstCondition.splitFirst(' '); + var operator = parts.first; + var secondCondition = parts.last.trim(); + + if (!_allOperators.contains(operator)) { + throw ArgumentError('Missing operator in "$expression"'); + } + var subExpression = _parseTagsCondition(secondCondition); + if (operator == _or) { + return wrapCondition(_TagsConditionOr(firstCondition, subExpression)); + } else if (operator == _and) { + return wrapCondition(_TagsConditionAnd(firstCondition, subExpression)); + } else { + throw ArgumentError('Missing operators token "$operator" in "$expression"'); + } +} diff --git a/lib/tags.dart b/lib/tags.dart new file mode 100644 index 0000000..5823d07 --- /dev/null +++ b/lib/tags.dart @@ -0,0 +1 @@ +export 'src/tags_impl.dart' show Tags, TagsExt, TagsCondition; diff --git a/test/tags_test.dart b/test/tags_test.dart new file mode 100644 index 0000000..94acfc6 --- /dev/null +++ b/test/tags_test.dart @@ -0,0 +1,75 @@ +import 'package:tekartik_common_utils/src/tags_impl.dart' + show TagsConditionSingle, TagsConditionMulti; +import 'package:tekartik_common_utils/tags.dart'; +import 'package:test/test.dart' hide Tags; + +Tags _t(String text) => Tags.fromText(text); +TagsCondition _c(String expression) => TagsCondition(expression); + +Future main() async { + group('tags', () { + test('simple', () { + var tags = _t('test'); + expect(_c('test').check(tags), isTrue); + expect(_c('test1').check(tags), isFalse); + }); + test('and', () { + var tags = _t('test1,test2'); + expect(_c('test1 && test2').check(tags), isTrue); + expect(_c('test1 && test3').check(tags), isFalse); + expect(_c('test2 && test3').check(tags), isFalse); + }); + test('or', () { + var tags = _t('test1,test2'); + expect(_c('test1 || test2').check(tags), isTrue); + expect(_c('test1 || test3').check(tags), isTrue); + expect(_c('test2 || test3').check(tags), isTrue); + expect(_c('test3 || test4').check(tags), isFalse); + }); + test('parenthesis', () { + var tags = _t('test1'); + expect(_c('test3 || (test1 || test2)').check(tags), isTrue); + expect(_c('test3 || (test1 && test2)').check(tags), isFalse); + tags = _t('test1, test2'); + expect(_c('test3 || (test1 || test2)').check(tags), isTrue); + expect(_c('test1 && (test2 || test3)').check(tags), isTrue); + }); + test('conditions', () { + expect(_c('test1'), isA()); + expect(_c('!test1'), isA()); + expect(_c('(test1)'), isA()); + expect(_c('!(test1)'), isA()); + + expect(_c('test1 || test2'), isA()); + expect(_c('test1 && test2'), isA()); + expect(_c('(test1 && test2)'), isA()); + expect(_c('!(test1 && test2)'), isA()); + + void roundTrip(String expression) { + expect(_c(expression).toText(), expression); + } + + for (var expression in [ + 'test1', + '!test1', + 'test1 || test2', + 'test1 && test2', + 'test1 && (test2 || test3)', + '(test2 || test3) && test1', + ]) { + roundTrip(expression); + } + }); + + test('quick', () { + var tags = _t('test1, test2'); + var condition = _c('test1 && (test2 || test3)'); + // ignore: avoid_print + print(tags); + // ignore: avoid_print + print(condition); + // ignore: avoid_print + print(condition.check(tags)); + }, skip: true); + }); +}