Skip to content

Commit 1f800b2

Browse files
committed
feat(moonapi): sub-dependencies in the DI container.
A dependency can now depend on another. Provider factories are handed the request Scope, so a factory resolves its own dependencies through it — FastAPI's nested Depends, where Depends(a) itself declares Depends(b). provide_using registers a scope-aware factory; provide stays a leaf that ignores the scope, so existing wiring is unchanged. Sub-dependencies share the request's one-shot cache and unwind LIFO with the rest (a sub-dependency built during its dependent is torn down after it), and a cycle is broken with None rather than looping — the resolving key is tracked while its factory runs. 122 tests on all backends; the scope-sharing is mutation-checked (handing the factory a fresh scope breaks both sharing and cycle detection). Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
1 parent f9ae612 commit 1f800b2

2 files changed

Lines changed: 102 additions & 9 deletions

File tree

di.mbt

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,38 @@
77
// that type; for several, `V` is a user-defined sum type wrapping them — the
88
// exhaustive, type-safe stand-in for Python's dynamic `Any` (cf. axum's typemap
99
// + downcast, Go's `interface{}` + type assertion). Everything else — the
10-
// registry, request-scoped one-shot resolution, `yield`-style teardown, and
11-
// `dependency_overrides` — is modelled faithfully.
10+
// registry, request-scoped one-shot resolution, sub-dependencies (a factory
11+
// resolving others through the scope, with cycle detection), `yield`-style
12+
// teardown, and `dependency_overrides` — is modelled faithfully.
1213

1314
///|
1415
/// A provider: a keyed factory that builds a request-scoped dependency value,
1516
/// with an optional teardown run after the handler (FastAPI's `yield`
1617
/// dependencies, whose post-`yield` body is cleanup). The `factory` runs at most
17-
/// once per request scope; the `teardown` receives the produced value.
18+
/// once per request scope; the `teardown` receives the produced value. The factory
19+
/// is handed the `Scope` so it can resolve *sub-dependencies* through it — FastAPI's
20+
/// `Depends(a)` where `a` itself declares `Depends(b)`.
1821
pub(all) struct Provider[V] {
19-
factory : () -> V
22+
factory : (Scope[V]) -> V
2023
teardown : (V) -> Unit
2124
}
2225

2326
///|
24-
/// Build a provider. `teardown` defaults to a no-op — the common "plain value,
25-
/// nothing to release" case.
27+
/// Build a leaf provider whose factory needs nothing else. `teardown` defaults to
28+
/// a no-op — the common "plain value, nothing to release" case.
2629
pub fn[V] Provider::new(
2730
factory : () -> V,
2831
teardown? : (V) -> Unit = _v => (),
32+
) -> Provider[V] {
33+
{ factory: _scope => factory(), teardown }
34+
}
35+
36+
///|
37+
/// Build a provider whose factory resolves other dependencies through the request
38+
/// `Scope` it is handed — the sub-dependency case (FastAPI's nested `Depends`).
39+
pub fn[V] Provider::scoped(
40+
factory : (Scope[V]) -> V,
41+
teardown? : (V) -> Unit = _v => (),
2942
) -> Provider[V] {
3043
{ factory, teardown }
3144
}
@@ -59,6 +72,20 @@ pub fn[V] Container::provide(
5972
self
6073
}
6174

75+
///|
76+
/// Register a base provider whose factory resolves sub-dependencies through the
77+
/// request scope it is handed (FastAPI's nested `Depends`). Otherwise like
78+
/// `provide`.
79+
pub fn[V] Container::provide_using(
80+
self : Container[V],
81+
key : String,
82+
factory : (Scope[V]) -> V,
83+
teardown? : (V) -> Unit = _v => (),
84+
) -> Container[V] {
85+
self.providers[key] = Provider::scoped(factory, teardown~)
86+
self
87+
}
88+
6289
///|
6390
/// Register a dependency override for `key` — FastAPI's
6491
/// `app.dependency_overrides[dep] = fake`. Takes precedence over the base
@@ -105,13 +132,14 @@ fn[V] Container::resolve(self : Container[V], key : String) -> Provider[V]? {
105132
pub struct Scope[V] {
106133
container : Container[V]
107134
cache : Map[String, V]
135+
building : Map[String, Bool]
108136
teardowns : Array[() -> Unit]
109137
}
110138

111139
///|
112140
/// Open a fresh request scope over this container.
113141
pub fn[V] Container::open_scope(self : Container[V]) -> Scope[V] {
114-
{ container: self, cache: Map([]), teardowns: [] }
142+
{ container: self, cache: Map([]), building: Map([]), teardowns: [] }
115143
}
116144

117145
///|
@@ -122,17 +150,25 @@ pub fn[V] Container::open_scope(self : Container[V]) -> Scope[V] {
122150
pub fn[V] Scope::get(self : Scope[V], key : String) -> V? {
123151
match self.cache.get(key) {
124152
Some(v) => Some(v)
125-
None =>
153+
None => {
154+
// A key already mid-build has been re-entered: a circular dependency.
155+
// Break it with `None` rather than looping (FastAPI raises here).
156+
if self.building.get(key) is Some(_) {
157+
return None
158+
}
126159
match self.container.resolve(key) {
127160
None => None
128161
Some(prov) => {
129-
let v = (prov.factory)()
162+
self.building[key] = true
163+
let v = (prov.factory)(self)
164+
self.building.remove(key)
130165
self.cache[key] = v
131166
let td = prov.teardown
132167
self.teardowns.push(() => td(v))
133168
Some(v)
134169
}
135170
}
171+
}
136172
}
137173
}
138174

di_wbtest.mbt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,63 @@ test "DI: a dependency is built once per request scope (cached)" {
9797
assert_eq(log.length(), 1)
9898
}
9999

100+
///|
101+
test "DI: a dependency resolves a sub-dependency through the scope, LIFO teardown" {
102+
let log : Array[String] = []
103+
let c : Container[Dep] = Container::new()
104+
c.provide(
105+
"prefix",
106+
() => {
107+
log.push("build prefix")
108+
Greeting("Hi")
109+
},
110+
teardown=_v => log.push("td prefix"),
111+
)
112+
|> ignore
113+
c.provide_using(
114+
"full",
115+
scope => {
116+
log.push("build full")
117+
let p = match scope.get("prefix") {
118+
Some(Greeting(s)) => s
119+
_ => "?"
120+
}
121+
Greeting(p + ", there")
122+
},
123+
teardown=_v => log.push("td full"),
124+
)
125+
|> ignore
126+
let scope = c.open_scope()
127+
// Resolving "full" pulls in "prefix" through the scope.
128+
assert_eq(scope.get("full") == Some(Greeting("Hi, there")), true)
129+
// "prefix" was built once during "full"; asking again returns the cache.
130+
assert_eq(scope.get("prefix") == Some(Greeting("Hi")), true)
131+
scope.close()
132+
// prefix built while full resolved (before full); teardown LIFO -> full, then prefix.
133+
assert_eq(log == ["build full", "build prefix", "td full", "td prefix"], true)
134+
}
135+
136+
///|
137+
test "DI: a circular sub-dependency breaks with None instead of looping" {
138+
let c : Container[Dep] = Container::new()
139+
c.provide_using("a", scope => {
140+
let _ = scope.get("b")
141+
Greeting("a")
142+
})
143+
|> ignore
144+
c.provide_using("b", scope => {
145+
let _ = scope.get("a")
146+
Greeting("b")
147+
})
148+
|> ignore
149+
let scope = c.open_scope()
150+
// get(a) -> builds b -> b asks for a (mid-build) -> None breaks the cycle,
151+
// so both still resolve without looping.
152+
assert_eq(scope.get("a") == Some(Greeting("a")), true)
153+
assert_eq(scope.get("b") == Some(Greeting("b")), true)
154+
scope.close()
155+
}
156+
100157
///|
101158
test "DI: dependency_overrides shadow the base provider, then restore" {
102159
let c : Container[Dep] = Container::new()

0 commit comments

Comments
 (0)