Skip to content

feat!: narrow indexer surfaces with more than four keys - #832

Merged
vbreuss merged 2 commits into
mainfrom
feat/narrow-indexer-surface-above-four-keys
Jul 29, 2026
Merged

feat!: narrow indexer surfaces with more than four keys#832
vbreuss merged 2 commits into
mainfrom
feat/narrow-indexer-surface-above-four-keys

Conversation

@vbreuss

@vbreuss vbreuss commented Jul 29, 2026

Copy link
Copy Markdown
Member

The accessor narrowing introduced for indexers (#830) stopped at four keys: the narrowed setup/verify types only ship with the library for arities 1-4, so a five-key get-only indexer still offered OnSet and Verify[...].Set(...), which compiled and silently did nothing.

Since the setup types for indexers with more than four keys are generated per-compilation anyway, generate the narrowed surface there too:

  • IndexerSetups.g.cs now emits IIndexerGetterOnlySetup / IIndexerSetterOnlySetup interface hierarchies (builders included) for each affected arity, implements them on the generated IndexerSetup class, and adds the Forever/OnlyOnce extensions for the narrowed when-builders.
  • Narrowed VerificationIndexerGetterResult / -SetterResult classes are generated in Mockolate.Verify, dispatching through the predicate-based MockRegistry.IndexerGot/IndexerSet overloads that the full VerificationIndexerResult already uses above four keys. Each takes only the member id and predicate of the accessor it exposes.
  • The facade selection in the mock emission now uses the plain accessor classification for every arity; the narrowed hierarchies are only generated for arities that need them, keyed on the same Getter/Setter nullity that guards body emission.

BREAKING CHANGE: on an indexer with more than four keys that the mock reads or writes but not both, Setup[...].OnSet/OnGet, Setup[...].Returns/Throws/InitializeWith and Verify[...].Set(...)/Got() no longer compile for the absent accessor. Each previously compiled and silently had no effect. This closes the gap the four-key narrowing left; the shipped library API is unchanged.

The accessor narrowing introduced for indexers (#830) stopped at four
keys: the narrowed setup/verify types only ship with the library for
arities 1-4, so a five-key get-only indexer still offered OnSet and
Verify[...].Set(...), which compiled and silently did nothing.

Since the setup types for indexers with more than four keys are
generated per-compilation anyway, generate the narrowed surface there
too:

- IndexerSetups.g.cs now emits IIndexerGetterOnlySetup /
  IIndexerSetterOnlySetup interface hierarchies (builders included) for
  each affected arity, implements them on the generated IndexerSetup
  class, and adds the Forever/OnlyOnce extensions for the narrowed
  when-builders.
- Narrowed VerificationIndexerGetterResult / -SetterResult classes are
  generated in Mockolate.Verify, dispatching through the predicate-based
  MockRegistry.IndexerGot/IndexerSet overloads that the full
  VerificationIndexerResult already uses above four keys. Each takes
  only the member id and predicate of the accessor it exposes.
- The facade selection in the mock emission now uses the plain accessor
  classification for every arity; the narrowed hierarchies are only
  generated for arities that need them, keyed on the same Getter/Setter
  nullity that guards body emission.

BREAKING CHANGE: on an indexer with more than four keys that the mock
reads or writes but not both, Setup[...].OnSet/OnGet,
Setup[...].Returns/Throws/InitializeWith and Verify[...].Set(...)/Got()
no longer compile for the absent accessor. Each previously compiled and
silently had no effect. This closes the gap the four-key narrowing
left; the shipped library API is unchanged.
@vbreuss vbreuss self-assigned this Jul 29, 2026
@vbreuss vbreuss added the enhancement New feature or request label Jul 29, 2026
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Test Results

    24 files  ±  0      24 suites  ±0   11m 31s ⏱️ + 1m 37s
 4 318 tests + 22   4 316 ✅ + 22  2 💤 ±0  0 ❌ ±0 
27 558 runs  +150  27 554 ✅ +150  4 💤 ±0  0 ❌ ±0 

Results for commit 85643c1. ± Comparison against base commit d2f56ed.

This pull request removes 2 and adds 24 tests. Note that renamed tests count towards both.
Mockolate.SourceGenerators.Tests.MockTests+ClassTests+IndexerTests ‑ IndexerWithMoreThanFourKeys_ShouldKeepTheFullSurface(indexer: "string this[int a, int b, int c, int d, int e] { g"···)
Mockolate.SourceGenerators.Tests.MockTests+ClassTests+IndexerTests ‑ IndexerWithMoreThanFourKeys_ShouldKeepTheFullSurface(indexer: "string this[int a, int b, int c, int d, int e] { s"···)
Mockolate.SourceGenerators.Tests.MockTests+ClassTests+IndexerTests ‑ IndexerSurface_ShouldOnlyExposeTheAccessorsTheMockIntercepts(indexer: "string this[int a, int b, int c, int d, int e] { g"···, expectedSetupType: "Setup.IIndexerGetterOnlySetup<string, int, int, in"···, expectedVerifyType: "Verify.VerificationIndexerGetterResult<IMockVerify"···, unexpectedSetupType: "Setup.IndexerSetup<string, int, int, int, int, int"···, unexpectedVerifyType: "Verify.VerificationIndexerResult<IMockVerifyForIMy"···)
Mockolate.SourceGenerators.Tests.MockTests+ClassTests+IndexerTests ‑ IndexerSurface_ShouldOnlyExposeTheAccessorsTheMockIntercepts(indexer: "string this[int a, int b, int c, int d, int e] { g"···, expectedSetupType: "Setup.IndexerSetup<string, int, int, int, int, int"···, expectedVerifyType: "Verify.VerificationIndexerResult<IMockVerifyForIMy"···, unexpectedSetupType: "Setup.IIndexerGetterOnlySetup<string, int, int, in"···, unexpectedVerifyType: "Verify.VerificationIndexerGetterResult<IMockVerify"···)
Mockolate.SourceGenerators.Tests.MockTests+ClassTests+IndexerTests ‑ IndexerSurface_ShouldOnlyExposeTheAccessorsTheMockIntercepts(indexer: "string this[int a, int b, int c, int d, int e] { s"···, expectedSetupType: "Setup.IIndexerSetterOnlySetup<string, int, int, in"···, expectedVerifyType: "Verify.VerificationIndexerSetterResult<IMockVerify"···, unexpectedSetupType: "Setup.IndexerSetup<string, int, int, int, int, int"···, unexpectedVerifyType: "Verify.VerificationIndexerResult<IMockVerifyForIMy"···)
Mockolate.Tests.MockIndexers.SetupIndexerTests+AccessorRestrictedTests ‑ GetOnlyFiveKeyIndexer_ChainedReturns_ShouldStayOnGetterOnlySurface
Mockolate.Tests.MockIndexers.SetupIndexerTests+AccessorRestrictedTests ‑ GetOnlyFiveKeyIndexer_InitializeWithGenerator_ShouldSeedTheReadValue
Mockolate.Tests.MockIndexers.SetupIndexerTests+AccessorRestrictedTests ‑ GetOnlyFiveKeyIndexer_InitializeWith_ShouldSeedTheReadValue
Mockolate.Tests.MockIndexers.SetupIndexerTests+AccessorRestrictedTests ‑ GetOnlyFiveKeyIndexer_OnGetChain_ShouldStayOnGetterOnlySurface
Mockolate.Tests.MockIndexers.SetupIndexerTests+AccessorRestrictedTests ‑ GetOnlyFiveKeyIndexer_OnGetDoWithCounter_ShouldReceiveTheAccessCounter
Mockolate.Tests.MockIndexers.SetupIndexerTests+AccessorRestrictedTests ‑ GetOnlyFiveKeyIndexer_OnGetFor_ShouldRepeatCallbackTheGivenNumberOfTimes
Mockolate.Tests.MockIndexers.SetupIndexerTests+AccessorRestrictedTests ‑ GetOnlyFiveKeyIndexer_OnGetInParallel_ShouldInvokeParallelCallbacksAlways
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

🚀 Benchmark Results

Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 3.50GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v4

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Event Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 295.1 ns 2.75 ns 2.57 ns 1.27 1.7 KB 1.00
Mockolate 232.0 ns 1.41 ns 1.25 ns 1.00 1.7 KB 1.00
Imposter 998.7 ns 2.69 ns 2.52 ns 4.30 8.8 KB 5.17
TUnitMocks 135.0 ns 0.69 ns 0.62 ns 0.58 1.34 KB 0.79
Moq 10,580.8 ns 55.66 ns 52.06 ns 45.60 12.51 KB 7.34
NSubstitute 3,816.3 ns 13.45 ns 12.58 ns 16.45 9.05 KB 5.31
FakeItEasy 184,614.1 ns 773.11 ns 723.16 ns 795.70 15.26 KB 8.96
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 2.60GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Indexer N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 828.7 ns 19.01 ns 16.85 ns 0.82 3.77 KB 1.00
Mockolate 1 1,004.6 ns 59.71 ns 55.85 ns 1.00 3.77 KB 1.00
Imposter 1 859.0 ns 17.09 ns 15.99 ns 0.86 5.16 KB 1.37
Moq 1 170,320.0 ns 623.06 ns 552.33 ns 170.03 20.45 KB 5.43
NSubstitute 1 8,393.3 ns 59.72 ns 52.94 ns 8.38 12.84 KB 3.41
FakeItEasy 1 9,707.1 ns 80.81 ns 75.59 ns 9.69 13.63 KB 3.62
baseline* 10 2,295.8 ns 20.10 ns 17.82 ns 0.88 4.82 KB 1.00
Mockolate 10 2,606.4 ns 25.11 ns 20.96 ns 1.00 4.82 KB 1.00
Imposter 10 2,048.7 ns 43.17 ns 40.38 ns 0.79 7.97 KB 1.65
Moq 10 183,666.0 ns 688.23 ns 610.10 ns 70.47 29.97 KB 6.22
NSubstitute 10 22,618.3 ns 109.93 ns 102.83 ns 8.68 25.63 KB 5.32
FakeItEasy 10 22,294.4 ns 138.74 ns 122.99 ns 8.55 32.97 KB 6.84
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 2.87GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Property N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 314.0 ns 1.48 ns 1.24 ns 0.59 2.41 KB 1.00
Mockolate 1 530.3 ns 11.20 ns 10.48 ns 1.00 2.41 KB 1.00
Imposter 1 441.8 ns 6.22 ns 5.81 ns 0.83 3.13 KB 1.29
TUnitMocks 1 460.4 ns 4.05 ns 3.59 ns 0.87 1.64 KB 0.68
Moq 1 10,539.8 ns 51.28 ns 47.97 ns 19.88 10.39 KB 4.30
NSubstitute 1 6,995.2 ns 72.12 ns 63.93 ns 13.19 11.45 KB 4.74
FakeItEasy 1 7,319.3 ns 171.64 ns 160.55 ns 13.81 11.24 KB 4.66
baseline* 10 774.6 ns 5.64 ns 5.27 ns 0.76 2.91 KB 1.00
Mockolate 10 1,014.9 ns 9.39 ns 8.79 ns 1.00 2.91 KB 1.00
Imposter 10 1,065.8 ns 8.98 ns 8.40 ns 1.05 4.67 KB 1.61
TUnitMocks 10 1,612.7 ns 24.18 ns 22.61 ns 1.59 3.94 KB 1.35
Moq 10 16,372.7 ns 63.31 ns 59.22 ns 16.13 18.28 KB 6.29
NSubstitute 10 16,511.1 ns 116.48 ns 108.95 ns 16.27 21.08 KB 7.25
FakeItEasy 10 17,631.9 ns 224.68 ns 199.18 ns 17.37 30.81 KB 10.60
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 2.60GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

CreateMock Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 19.30 ns 1.309 ns 1.224 ns 1.07 160 B 1.00
Mockolate 18.03 ns 0.211 ns 0.187 ns 1.00 160 B 1.00
Imposter 290.81 ns 3.358 ns 3.141 ns 16.13 2248 B 14.05
TUnitMocks 38.20 ns 0.305 ns 0.285 ns 2.12 200 B 1.25
Moq 1,415.81 ns 7.444 ns 6.599 ns 78.53 2096 B 13.10
NSubstitute 1,916.00 ns 29.869 ns 27.940 ns 106.27 5048 B 31.55
FakeItEasy 1,937.68 ns 62.874 ns 58.812 ns 107.47 2763 B 17.27
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Callback Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 309.4 ns 1.51 ns 1.18 ns 0.93 1.57 KB 1.00
Mockolate 333.5 ns 13.59 ns 12.71 ns 1.00 1.57 KB 1.00
Imposter 448.6 ns 17.34 ns 16.22 ns 1.35 2.38 KB 1.52
TUnitMocks 525.9 ns 9.43 ns 8.82 ns 1.58 1.99 KB 1.27
Moq 100,988.4 ns 548.10 ns 512.69 ns 303.23 8.96 KB 5.71
NSubstitute 4,711.4 ns 63.53 ns 59.43 ns 14.15 7.71 KB 4.91
FakeItEasy 5,074.5 ns 86.60 ns 76.77 ns 15.24 6.81 KB 4.33
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 2.60GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Method N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 260.8 ns 4.48 ns 3.97 ns 0.75 1.00 0.00
Mockolate 1 348.6 ns 4.07 ns 3.81 ns 1.00 1.93 KB 1.00
Imposter 1 525.0 ns 4.03 ns 3.57 ns 1.51 4.04 KB 2.09
TUnitMocks 1 475.4 ns 2.91 ns 2.72 ns 1.36 2.02 KB 1.04
Moq 1 135,613.1 ns 756.23 ns 707.37 ns 389.09 14.81 KB 7.67
NSubstitute 1 5,384.4 ns 37.48 ns 35.06 ns 15.45 9.06 KB 4.70
FakeItEasy 1 5,540.4 ns 49.48 ns 46.28 ns 15.90 8.05 KB 4.17
baseline* 10 581.4 ns 44.47 ns 41.60 ns 0.93 1.00 0.00
Mockolate 10 622.3 ns 4.58 ns 4.28 ns 1.00 2.14 KB 1.00
Imposter 10 1,074.3 ns 4.09 ns 3.42 ns 1.73 5.52 KB 2.58
TUnitMocks 10 1,390.0 ns 8.76 ns 7.77 ns 2.23 3.73 KB 1.74
Moq 10 138,160.6 ns 1,448.29 ns 1,283.87 ns 222.04 18.7 KB 8.74
NSubstitute 10 7,970.9 ns 96.52 ns 85.56 ns 12.81 11.52 KB 5.38
FakeItEasy 10 8,552.6 ns 71.51 ns 66.89 ns 13.75 15.42 KB 7.20

baseline* rows show the corresponding Mockolate benchmark from the most recent successful main branch build with results, for regression comparison.

The generated VerificationIndexerSetterResult for indexers with more than four
keys described the expected value with It.Is(value, value?.ToString() ?? "null").
That renders strings unquoted, formats IFormattable values with the current
culture instead of the invariant one, and builds the description even when the
verification passes.

It.IsValue is the matcher the generator already emits for by-value parameters
elsewhere, and it exists precisely because generated code cannot see the
internal CallerArgumentExpression polyfill the shipped result relies on. It
defers formatting to the failure path and renders quoted, invariant values, so a
literal now reads the same above four keys as it does on the shipped one-to-four
key surface.

Also:

- Pass the collected IndexerSetupKey array straight into Sources.IndexerSetups
  instead of rebuilding a Dictionary in the source-output callback. The sorted
  order from CollectIndexerSetupKeys becomes the only ordering in play, and the
  repeated tuple type disappears from both files. Generated output is unchanged.
- Cover the generated narrowed delegations at five keys: Throws, InitializeWith,
  SkippingBaseClass, the return-builder When, and OnGet/OnSet with OnlyOnce,
  When, InParallel, For, the access-counter Do overload and TransitionTo on both
  facades, plus the rendered expectation string.
- Drop the doc sentence about where the narrowed types come from.
@vbreuss
vbreuss enabled auto-merge (squash) July 29, 2026 13:34
@sonarqubecloud

sonarqubecloud Bot commented Jul 29, 2026

Copy link
Copy Markdown

@vbreuss
vbreuss merged commit e7d8f71 into main Jul 29, 2026
17 checks passed
@vbreuss
vbreuss deleted the feat/narrow-indexer-surface-above-four-keys branch July 29, 2026 13:41
github-actions Bot added a commit that referenced this pull request Jul 29, 2026
github-actions Bot added a commit that referenced this pull request Jul 29, 2026
@github-actions

Copy link
Copy Markdown

This is addressed in release v3.4.0.

@github-actions github-actions Bot added the state: released The issue is released label Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request state: released The issue is released

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant