Skip to content

Commit c12f2bb

Browse files
authored
feat(router): support append duplicate policy (#22)
* feat(router): support appended handlers per route slot * refactor(router): model route slots as extension types * perf(router): inline single-entry slots before append upgrade * refactor(router): represent duplicate slots as linked nodes * refactor(router): reduce router implementation below 1000 lines * perf(router): simplify core matcher below 900 lines * perf(router): optimize matchAll traversal * Enable additional lint rules Add public_member_api_docs, prefer_final_fields, and prefer_final_locals * perf(router): eliminate recursion and lazy params * perf(router): shrink matchAll result materialization * perf(router): optimize route registration hot path * refactor(router): shrink implementation without perf regressions * refactor(router): reduce core router to 902 lines * refactor(router): reduce core router to 800 lines * perf(router): streamline traversal hot paths * docs(router): document public API * style(bench): format matchAll benchmark * fix(router): snapshot matchAll params and reject empty segments
1 parent 9c30d57 commit c12f2bb

File tree

6 files changed

+1036
-865
lines changed

6 files changed

+1036
-865
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
- Method-aware multi-match lookups now include both `ANY` and exact-method
88
matches, with `ANY` ordered first at the same scope.
99
- Add configurable duplicate route registration via `DuplicatePolicy.reject`,
10-
`DuplicatePolicy.replace`, and `DuplicatePolicy.keepFirst`.
10+
`DuplicatePolicy.replace`, `DuplicatePolicy.keepFirst`, and
11+
`DuplicatePolicy.append`.
12+
- Duplicate slots retained with `DuplicatePolicy.append` now preserve
13+
registration order in `matchAll(...)`, while `match(...)` continues to
14+
return the first retained entry in the winning slot.
1115

1216
## 0.2.0
1317

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Available policies:
6060
- `DuplicatePolicy.reject` keeps the current default and throws on duplicates
6161
- `DuplicatePolicy.replace` keeps the latest retained entry
6262
- `DuplicatePolicy.keepFirst` keeps the earliest retained entry
63+
- `DuplicatePolicy.append` retains all entries in registration order
6364

6465
Per-call overrides are also supported:
6566

@@ -71,6 +72,19 @@ router.add(
7172
);
7273
```
7374

75+
To retain multiple handlers in the same normalized slot:
76+
77+
```dart
78+
final router = Router<String>(duplicatePolicy: DuplicatePolicy.append);
79+
router.add('/*', 'global-logger');
80+
router.add('/*', 'root-scope-middleware');
81+
82+
print(router.match('/users/42')?.data); // global-logger
83+
print(
84+
router.matchAll('/users/42').map((match) => match.data),
85+
); // (global-logger, root-scope-middleware)
86+
```
87+
7488
Parameter-name drift remains a hard error under all policies. For example,
7589
`/users/:id` and `/users/:name` still conflict.
7690

@@ -108,7 +122,9 @@ For `matchAll(...)`:
108122

109123
`matchAll(...)` returns every matching route from less specific to more
110124
specific. When a `method` is provided, both `ANY` and exact-method entries
111-
participate, with `ANY` ordered first at the same scope.
125+
participate, with `ANY` ordered first at the same scope. When duplicate slots
126+
are retained via `DuplicatePolicy.append`, entries from the same slot stay in
127+
registration order.
112128

113129
## Benchmarks
114130

analysis_options.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
include: package:lints/recommended.yaml
2+
linter:
3+
rules:
4+
- public_member_api_docs
5+
- prefer_final_fields
6+
- prefer_final_locals

bench/match_all_compare.dart

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import 'package:benchmark_harness/perf_benchmark_harness.dart';
2+
import 'package:roux/roux.dart';
3+
4+
const _defaultQueryCount = 1000;
5+
var _sink = 0;
6+
7+
enum _Scenario {
8+
staticAny('MatchAll', 'StaticAny'),
9+
dynamicAny('MatchAll', 'DynamicAny'),
10+
staticMethod('MatchAll', 'StaticMethod'),
11+
dynamicAppend('MatchAll', 'DynamicAppend');
12+
13+
const _Scenario(this.group, this.name);
14+
final String group;
15+
final String name;
16+
}
17+
18+
class _CollectingEmitter extends ScoreEmitterV2 {
19+
@override
20+
void emit(
21+
String testName,
22+
double value, {
23+
String metric = 'RunTime',
24+
String unit = 'us',
25+
}) => print([testName, metric, value.toStringAsFixed(1), unit].join(';'));
26+
}
27+
28+
abstract class _MatchAllBenchmark extends PerfBenchmarkBase {
29+
_MatchAllBenchmark(this.scenario, this.queryCount, _CollectingEmitter emitter)
30+
: super(
31+
[scenario.group, scenario.name, 'x$queryCount', 'Roux'].join(';'),
32+
emitter: emitter,
33+
);
34+
35+
final _Scenario scenario;
36+
final int queryCount;
37+
late final Router<int> _router = buildRouter();
38+
late final List<String> _queries = buildQueries(queryCount);
39+
40+
Router<int> buildRouter();
41+
42+
List<String> buildQueries(int queryCount);
43+
44+
String? get method => null;
45+
46+
@override
47+
void exercise() => run();
48+
49+
@override
50+
void run() {
51+
final method = this.method;
52+
for (final path in _queries) {
53+
final matches = _router.matchAll(path, method: method);
54+
for (final match in matches) {
55+
_sink ^= match.data;
56+
_sink ^= match.params?.length ?? 0;
57+
}
58+
}
59+
}
60+
}
61+
62+
class _StaticAnyBenchmark extends _MatchAllBenchmark {
63+
_StaticAnyBenchmark(int queryCount, _CollectingEmitter emitter)
64+
: super(_Scenario.staticAny, queryCount, emitter);
65+
66+
@override
67+
Router<int> buildRouter() => Router<int>(
68+
routes: {'/*': 1, '/api/*': 2, '/api/users/*': 3, '/api/users/all': 4},
69+
);
70+
71+
@override
72+
List<String> buildQueries(int queryCount) =>
73+
List<String>.filled(queryCount, '/api/users/all', growable: false);
74+
}
75+
76+
class _DynamicAnyBenchmark extends _MatchAllBenchmark {
77+
_DynamicAnyBenchmark(int queryCount, _CollectingEmitter emitter)
78+
: super(_Scenario.dynamicAny, queryCount, emitter);
79+
80+
@override
81+
Router<int> buildRouter() => Router<int>(
82+
routes: {
83+
'/*': 1,
84+
'/api/*': 2,
85+
'/api/:resource/*': 3,
86+
'/api/:resource/:id/*': 4,
87+
'/api/:resource/:id/details': 5,
88+
},
89+
);
90+
91+
@override
92+
List<String> buildQueries(int queryCount) => List<String>.generate(
93+
queryCount,
94+
(i) => '/api/users/user_$i/details',
95+
growable: false,
96+
);
97+
}
98+
99+
class _StaticMethodBenchmark extends _MatchAllBenchmark {
100+
_StaticMethodBenchmark(int queryCount, _CollectingEmitter emitter)
101+
: super(_Scenario.staticMethod, queryCount, emitter);
102+
103+
@override
104+
Router<int> buildRouter() {
105+
final router = Router<int>();
106+
router.add('/*', 1);
107+
router.add('/api/*', 2);
108+
router.add('/api/users/*', 3);
109+
router.add('/api/users/all', 4);
110+
router.add('/api/*', 5, method: 'GET');
111+
router.add('/api/users/*', 6, method: 'GET');
112+
router.add('/api/users/all', 7, method: 'GET');
113+
return router;
114+
}
115+
116+
@override
117+
List<String> buildQueries(int queryCount) =>
118+
List<String>.filled(queryCount, '/api/users/all', growable: false);
119+
120+
@override
121+
String get method => 'GET';
122+
}
123+
124+
class _DynamicAppendBenchmark extends _MatchAllBenchmark {
125+
_DynamicAppendBenchmark(int queryCount, _CollectingEmitter emitter)
126+
: super(_Scenario.dynamicAppend, queryCount, emitter);
127+
128+
@override
129+
Router<int> buildRouter() {
130+
final router = Router<int>(duplicatePolicy: DuplicatePolicy.append);
131+
router.add('/*', 1);
132+
router.add('/*', 2);
133+
router.add('/api/*', 3);
134+
router.add('/api/*', 4);
135+
router.add('/api/:resource/*', 5);
136+
router.add('/api/:resource/*', 6);
137+
router.add('/api/:resource/:id/*', 7);
138+
router.add('/api/:resource/:id/*', 8);
139+
router.add('/api/:resource/:id/details', 9);
140+
router.add('/api/:resource/:id/details', 10);
141+
return router;
142+
}
143+
144+
@override
145+
List<String> buildQueries(int queryCount) => List<String>.generate(
146+
queryCount,
147+
(i) => '/api/users/user_$i/details',
148+
growable: false,
149+
);
150+
}
151+
152+
void main(List<String> args) {
153+
final queryCount = _parseArg(args, 0, _defaultQueryCount);
154+
155+
print('matchAll benchmark (benchmark_harness/perf_benchmark_harness)');
156+
print('queryCount=$queryCount');
157+
print('format=test;metric;value;unit');
158+
print('lower is better (us)');
159+
160+
final emitter = _CollectingEmitter();
161+
for (final benchmark in <_MatchAllBenchmark>[
162+
_StaticAnyBenchmark(queryCount, emitter),
163+
_DynamicAnyBenchmark(queryCount, emitter),
164+
_StaticMethodBenchmark(queryCount, emitter),
165+
_DynamicAppendBenchmark(queryCount, emitter),
166+
]) {
167+
benchmark.report();
168+
}
169+
print('sink=$_sink');
170+
}
171+
172+
int _parseArg(List<String> args, int index, int fallback) {
173+
if (index >= args.length) {
174+
return fallback;
175+
}
176+
return int.tryParse(args[index]) ?? fallback;
177+
}

0 commit comments

Comments
 (0)