Skip to content

Commit b108b4d

Browse files
committed
Fix implicit search priority for async providers in tagless-final
Add AppContextAsyncProviderLookup to ensure correct priority ordering: 1. AppContextAsyncProviders in scope (highest) - from InAppContext 2. Local AppContextProvider (sync) converted to async 3. Companion-defined AppContextAsyncProvider (lowest) This mirrors the AppContextProviderLookup pattern from core module, ensuring that local providers (sync or async) take precedence over companion-defined providers. Changes: - Add AppContextAsyncProviderLookup with three priority levels - Add AppContextAsyncProviders.of() for explicit provider creation - Update InAppContext.get to use the lookup mechanism - Add AsyncImplicitsSearchOrderTest with 5 test cases
1 parent addf257 commit b108b4d

4 files changed

Lines changed: 229 additions & 3 deletions

File tree

tagless-final/shared/src/main/scala/com/github/rssh/appcontext/AppContextAsyncProvider.scala

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,56 @@ object AppContextAsyncProvider extends AppContextAsyncProviderLowLevelImplicits
4141
summon[AppContextAsyncProvidersSearch[F, Xs]].getProvider[T, N]
4242

4343

44+
}
45+
46+
47+
/**
48+
* AppContextAsyncProviderLookup is a helper trait used by InAppContext to search for async providers
49+
* with correct priority:
50+
* 1. AppContextAsyncProviders in enclosing scope (highest)
51+
* 2. Local AppContextProvider (sync) converted via fromSyncProvider
52+
* 3. AppContextAsyncProvider[F, T] defined in T's companion object (lowest)
53+
*
54+
* This works because implicit search looks in the companion of the result type (T),
55+
* but AppContextAsyncProviderLookup[F, T]'s companion is AppContextAsyncProviderLookup, not T.
56+
* So givens defined here have priority over T's companion.
57+
*/
58+
trait AppContextAsyncProviderLookup[F[_], T] {
59+
def get: F[T]
60+
}
61+
62+
trait AppContextAsyncProviderLookupLowPriority {
63+
/**
64+
* Lowest priority fallback: delegate to AppContextAsyncProvider[F, T].
65+
* This picks up companion-defined async providers.
66+
*/
67+
given fromAsyncProvider[F[_], T](using provider: AppContextAsyncProvider[F, T]): AppContextAsyncProviderLookup[F, T] with {
68+
def get: F[T] = provider.get
69+
}
70+
}
71+
72+
trait AppContextAsyncProviderLookupMidPriority extends AppContextAsyncProviderLookupLowPriority {
73+
/**
74+
* Mid priority: convert sync AppContextProvider to async.
75+
* Local sync providers win over companion-defined async providers.
76+
*/
77+
given fromSyncProvider[F[_]: AppContextPure, T](using syncProvider: AppContextProvider[T]): AppContextAsyncProviderLookup[F, T] with {
78+
def get: F[T] = summon[AppContextPure[F]].pure(syncProvider.get)
79+
}
80+
}
4481

82+
object AppContextAsyncProviderLookup extends AppContextAsyncProviderLookupMidPriority {
4583

84+
/**
85+
* Highest priority: lookup from AppContextAsyncProviders in scope.
86+
* This given is in AppContextAsyncProviderLookup companion, so it's found before
87+
* any AppContextAsyncProvider[F, X] in X's companion.
88+
*/
89+
given fromProviders[F[_], Xs <: NonEmptyTuple, X, N <: Int](
90+
using providers: AppContextAsyncProvidersSearch[F, Xs],
91+
idx: TupleIndex.OfSubtype[Xs, X, N]
92+
): AppContextAsyncProviderLookup[F, X] with {
93+
def get: F[X] = providers.getProvider[X, N].get
94+
}
4695

4796
}

tagless-final/shared/src/main/scala/com/github/rssh/appcontext/AppContextAsyncProviders.scala

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@ trait AppContextAsyncProviders[F[_],Xs <: NonEmptyTuple] extends AppContextAsync
1313

1414
object AppContextAsyncProviders {
1515

16+
/**
17+
* Accept a tuple of services and create an AppContextAsyncProviders for them.
18+
* The services are wrapped in pure effect.
19+
* ```
20+
* given pure: AppContextPure[F] = ...
21+
* val providers = AppContextAsyncProviders.of[F, (Service1, Service2)](service1, service2)
22+
* val depended = new Dependent(using providers)
23+
* ```
24+
*/
25+
def of[F[_], T <: NonEmptyTuple](values: T)(using pure: util.AppContextPure[F]): AppContextAsyncProviders[F, T] = {
26+
val arr = values.productIterator.map { v =>
27+
AppContextAsyncProvider.of[F, Any](pure.pure(v)): AppContextAsyncProvider[F, ?]
28+
}.toArray
29+
new DefaultAppContextAsyncProviders[F, T](arr)
30+
}
1631

1732
trait TryBuild[F[_], Xs<:NonEmptyTuple]
1833
case class TryBuildSuccess[F[_],Xs<:NonEmptyTuple](providers:AppContextAsyncProviders[F,Xs]) extends TryBuild[F,Xs]

tagless-final/shared/src/main/scala/com/github/rssh/appcontext/InAppContext.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ type InAppContext[Dependencies <: NonEmptyTuple] = [F[_]] =>> AppContextAsyncPro
77
object InAppContext {
88

99
class FProvider[F[_]]:
10-
def apply[T](using p:AppContextAsyncProvider[F,T]): F[T] =
10+
def apply[T](using p:AppContextAsyncProviderLookup[F,T]): F[T] =
1111
p.get
1212

13-
def get[F[_],T](using p: AppContextAsyncProvider[F,T]): F[T] =
13+
def get[F[_],T](using p: AppContextAsyncProviderLookup[F,T]): F[T] =
1414
p.get
1515

1616
def get1[F[_]](using p: AppContextAsyncProvider[F,?]): FProvider[F] =
@@ -20,7 +20,7 @@ object InAppContext {
2020

2121
extension(ac:AppContext.type)
2222

23-
def asyncGet[F[_],T](using p: AppContextAsyncProvider[F,T]): F[T] =
23+
def asyncGet[F[_],T](using p: AppContextAsyncProviderLookup[F,T]): F[T] =
2424
p.get
2525

2626

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package com.github.rssh.appcontext
2+
3+
import com.github.rssh.appcontext.util.AppContextPure
4+
import com.github.rssh.toymonad.ToyMonad
5+
import cps.*
6+
import cps.syntax.*
7+
8+
import scala.concurrent.ExecutionContext.Implicits.global
9+
10+
object AsyncImplicitsSearchOrderTestClasses {
11+
12+
case class Dependency1(name: String)
13+
14+
object Dependency1 {
15+
// Async provider in companion object - should have lower priority than InAppContext providers
16+
given [F[_]: CpsEffectMonad]: AppContextAsyncProvider[F, Dependency1] with {
17+
def get: F[Dependency1] = summon[CpsEffectMonad[F]].pure(Dependency1("Dependency1:From companion"))
18+
}
19+
}
20+
21+
case class Dependency2(name: String)
22+
23+
object Dependency2 {
24+
// No provider in companion - must be provided externally
25+
}
26+
27+
}
28+
29+
class AsyncImplicitsSearchOrderTest extends munit.FunSuite {
30+
31+
import AsyncImplicitsSearchOrderTestClasses.*
32+
33+
test("AISO001: AppContextAsyncProviders takes priority over companion-defined AppContextAsyncProvider") {
34+
// Dependency1 has AppContextAsyncProvider in companion returning "Dependency1:From companion"
35+
// But when we use InAppContext (AppContextAsyncProviders), the value from providers should take priority
36+
37+
class Component1[F[_]](using CpsEffectMonad[F], InAppContext[(Dependency1, Dependency2)][F]) {
38+
def getDependency1Name(): F[String] = {
39+
summon[CpsEffectMonad[F]].map(InAppContext.get[F, Dependency1])(_.name)
40+
}
41+
42+
def getDependency2Name(): F[String] = {
43+
summon[CpsEffectMonad[F]].map(InAppContext.get[F, Dependency2])(_.name)
44+
}
45+
}
46+
47+
val localDep1 = Dependency1("Dependency1:From InAppContext")
48+
val localDep2 = Dependency2("Dependency2:From InAppContext")
49+
50+
// Explicitly create providers with local values
51+
given providers: AppContextAsyncProviders[ToyMonad, (Dependency1, Dependency2)] =
52+
AppContextAsyncProviders.of[ToyMonad, (Dependency1, Dependency2)]((localDep1, localDep2))
53+
val c1 = new Component1[ToyMonad]
54+
55+
val resultFuture = ToyMonad.run {
56+
ToyMonad.CpsEffectToyMonad.flatMap(c1.getDependency1Name()) { name1 =>
57+
ToyMonad.CpsEffectToyMonad.map(c1.getDependency2Name()) { name2 =>
58+
(name1, name2)
59+
}
60+
}
61+
}
62+
63+
resultFuture.map { case (name1, name2) =>
64+
// Key assertion: InAppContext value should win over companion
65+
assert(name1 == "Dependency1:From InAppContext",
66+
s"Expected 'Dependency1:From InAppContext' but got '$name1'. " +
67+
"AppContextAsyncProviders (InAppContext) should take priority over companion-defined AppContextAsyncProvider.")
68+
assert(name2 == "Dependency2:From InAppContext")
69+
}
70+
}
71+
72+
test("AISO002: fallback to companion AppContextAsyncProvider when no InAppContext in scope") {
73+
// When there's no InAppContext (AppContextAsyncProviders) in scope,
74+
// we should fall back to the companion-defined provider
75+
76+
val resultFuture = ToyMonad.run {
77+
AppContext.asyncGet[ToyMonad, Dependency1]
78+
}
79+
80+
resultFuture.map { d1 =>
81+
assert(d1.name == "Dependency1:From companion",
82+
s"Expected 'Dependency1:From companion' but got '${d1.name}'. " +
83+
"Should fall back to companion-defined AppContextAsyncProvider when no InAppContext in scope.")
84+
}
85+
}
86+
87+
test("AISO003: InAppContext.get uses lookup with correct priority") {
88+
// Direct test of InAppContext.get ensuring it uses the lookup mechanism
89+
90+
class Service[F[_]](using CpsEffectMonad[F], InAppContext[(Dependency1 *: EmptyTuple)][F]) {
91+
def getDep1(): F[Dependency1] = InAppContext.get[F, Dependency1]
92+
}
93+
94+
val localDep1 = Dependency1("Dependency1:Local override")
95+
96+
// Explicitly create providers with local values
97+
given providers: AppContextAsyncProviders[ToyMonad, Dependency1 *: EmptyTuple] =
98+
AppContextAsyncProviders.of[ToyMonad, Dependency1 *: EmptyTuple](localDep1 *: EmptyTuple)
99+
val service = new Service[ToyMonad]
100+
101+
val resultFuture = ToyMonad.run(service.getDep1())
102+
103+
resultFuture.map { d1 =>
104+
assert(d1.name == "Dependency1:Local override",
105+
s"Expected 'Dependency1:Local override' but got '${d1.name}'. " +
106+
"InAppContext.get should use lookup that prioritizes InAppContext over companion.")
107+
}
108+
}
109+
110+
test("AISO004: Local sync AppContextProvider preferred over companion async provider") {
111+
// Test that local AppContextProvider (sync) takes priority over
112+
// AppContextAsyncProvider defined in companion object.
113+
// The lookup mechanism converts sync providers via fromSyncProvider at mid priority,
114+
// which wins over companion async providers at low priority.
115+
116+
val localDep1 = Dependency1("Dependency1:From local sync provider")
117+
118+
// Provide sync AppContextProvider - should win over companion async provider
119+
given AppContextProvider[Dependency1] = AppContextProvider.of(localDep1)
120+
121+
val resultFuture = ToyMonad.run {
122+
InAppContext.get[ToyMonad, Dependency1]
123+
}
124+
125+
resultFuture.map { d1 =>
126+
// Local sync provider should win over companion async provider
127+
assert(d1.name == "Dependency1:From local sync provider",
128+
s"Expected 'Dependency1:From local sync provider' but got '${d1.name}'. " +
129+
"Local AppContextProvider should take priority over companion-defined AppContextAsyncProvider.")
130+
}
131+
}
132+
133+
test("AISO005: Local async AppContextAsyncProvider preferred over global sync AppContextProvider") {
134+
// Test that local AppContextAsyncProvider (in InAppContext) takes priority over
135+
// global/outer scope AppContextProvider (sync).
136+
// The fromProviders (from InAppContext) has highest priority.
137+
138+
class Component[F[_]](using CpsEffectMonad[F], InAppContext[(Dependency2 *: EmptyTuple)][F]) {
139+
def getDependency2(): F[Dependency2] = InAppContext.get[F, Dependency2]
140+
}
141+
142+
// Global sync provider (lower priority)
143+
given globalSync: AppContextProvider[Dependency2] = AppContextProvider.of(Dependency2("Dependency2:From global sync"))
144+
145+
// Local async providers via InAppContext (higher priority)
146+
val localDep2 = Dependency2("Dependency2:From local async InAppContext")
147+
given providers: AppContextAsyncProviders[ToyMonad, Dependency2 *: EmptyTuple] =
148+
AppContextAsyncProviders.of[ToyMonad, Dependency2 *: EmptyTuple](localDep2 *: EmptyTuple)
149+
150+
val component = new Component[ToyMonad]
151+
152+
val resultFuture = ToyMonad.run(component.getDependency2())
153+
154+
resultFuture.map { d2 =>
155+
// Local async provider (via InAppContext) should win over global sync
156+
assert(d2.name == "Dependency2:From local async InAppContext",
157+
s"Expected 'Dependency2:From local async InAppContext' but got '${d2.name}'. " +
158+
"Local InAppContext (async) should take priority over global AppContextProvider (sync).")
159+
}
160+
}
161+
162+
}

0 commit comments

Comments
 (0)