Skip to content

Commit 2e8f35c

Browse files
github-actions[bot]CopilotCopilotsergey-tihon
authored
[Repo Assist] perf: reduce DLL-emit time for large schemas (shared ToString helper + Dictionary lookup) (#356)
* perf: reduce DLL-emit time for large schemas via reflection-based ToString() and Dictionary lookup - Replace O(N) embedded-field-values ToString() with O(1) reflection-based implementation in both v2 and v3 DefinitionCompiler. For a type with N properties, the old approach embedded N string constants (property names) and N field-get expressions into the generated method body IL. For schemas with hundreds of types (e.g. Stripe API, #150), this significantly inflated the IL emitted in ProvidedTypes.fs phase 3 (emit member code). The new approach generates a fixed-size method body that discovers property names at runtime via reflection — same output format, much less IL to emit. - Change pathToSchema / definitionToSchemaObject from F# Map (O(log n) lookup) to Dictionary (O(1) lookup) for consistency with pathToType / definitionToType, which already used Dictionary. All 261 unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: trigger checks * fix: correct FS3033 type-mismatch in ToString invokeCode; add regression tests Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/27fbd8a4-857d-462a-9ab0-33eae4d62d93 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> * fix: deterministic precompile order and stable ToString output (DeclaredOnly + sort by Name) Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/19676c20-b619-48b3-85bf-b5311d34e3cb Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> * refactor: move ToString impl to shared RuntimeHelpers.formatObject with property cache Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/7cca4988-6729-451d-ba87-60703f84f0d8 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com>
1 parent 042f748 commit 2e8f35c

4 files changed

Lines changed: 144 additions & 82 deletions

File tree

src/SwaggerProvider.DesignTime/v2/DefinitionCompiler.fs

Lines changed: 16 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,14 @@ and NamespaceAbstraction(name: string) =
151151

152152
/// Object for compiling definitions.
153153
type DefinitionCompiler(schema: SwaggerObject, provideNullable, useDateOnly: bool) as this =
154-
let definitionToSchemaObject = Map.ofSeq schema.Definitions
154+
let definitionToSchemaObject =
155+
let dict = Collections.Generic.Dictionary<string, SchemaObject>()
156+
157+
for name, schemaObj in schema.Definitions do
158+
dict.Add(name, schemaObj)
159+
160+
dict
161+
155162
let definitionToType = Collections.Generic.Dictionary<_, _>()
156163
let nsRoot = NamespaceAbstraction("Root")
157164
let nsOps = nsRoot.GetOrCreateNamespace "OperationTypes"
@@ -196,14 +203,14 @@ type DefinitionCompiler(schema: SwaggerObject, provideNullable, useDateOnly: boo
196203
match definitionToType.TryGetValue tyDefName with
197204
| true, ty -> ty :> Type
198205
| false, _ ->
199-
match definitionToSchemaObject.TryFind tyDefName with
200-
| Some(def) ->
206+
match definitionToSchemaObject.TryGetValue tyDefName with
207+
| true, def ->
201208
let ns, tyName = tyDefName |> DefinitionPath.Parse |> nsRoot.Resolve
202209
let ty = compileSchemaObject ns tyName def true (registerInNsAndInDef tyDefName ns)
203210
ty :> Type
204-
| None when tyDefName.StartsWith("#/definitions/") ->
211+
| false, _ when tyDefName.StartsWith("#/definitions/") ->
205212
failwithf $"Cannot find definition '%s{tyDefName}' in schema definitions %A{definitionToType.Keys |> Seq.toArray}"
206-
| None -> failwithf $"Cannot find definition '%s{tyDefName}' (references to relative documents are not supported yet)"
213+
| _ -> failwithf $"Cannot find definition '%s{tyDefName}' (references to relative documents are not supported yet)"
207214

208215
and compileSchemaObject (ns: NamespaceAbstraction) tyName (schemaObj: SchemaObject) isRequired registerNew =
209216
let compileNewObject(properties: DefinitionProperty[]) =
@@ -283,6 +290,8 @@ type DefinitionCompiler(schema: SwaggerObject, provideNullable, useDateOnly: boo
283290
)
284291

285292
// Override `.ToString()`
293+
// Delegates to the shared RuntimeHelpers.formatObject helper so that
294+
// each generated type's method body is a single static call (O(1) IL).
286295
let toStr =
287296
ProvidedMethod(
288297
"ToString",
@@ -292,39 +301,8 @@ type DefinitionCompiler(schema: SwaggerObject, provideNullable, useDateOnly: boo
292301
invokeCode =
293302
fun args ->
294303
let this = args[0]
295-
296-
let pNames, pValues =
297-
Array.ofList members
298-
|> Array.map(fun (pField, pProp) ->
299-
let pValObj = Expr.FieldGet(this, pField)
300-
pProp.Name, Expr.Coerce(pValObj, typeof<obj>))
301-
|> Array.unzip
302-
303-
let pValuesArr = Expr.NewArray(typeof<obj>, List.ofArray pValues)
304-
305-
<@@
306-
let values = (%%pValuesArr: array<obj>)
307-
308-
let rec formatValue(v: obj) =
309-
if isNull v then
310-
"null"
311-
else
312-
let vTy = v.GetType()
313-
314-
if vTy = typeof<string> then
315-
String.Format("\"{0}\"", v)
316-
elif vTy.IsArray then
317-
let elements = (v :?> seq<_>) |> Seq.map formatValue
318-
String.Format("[{0}]", String.Join("; ", elements))
319-
else
320-
v.ToString()
321-
322-
let strs =
323-
values
324-
|> Array.mapi(fun i v -> String.Format("{0}={1}", pNames[i], formatValue v))
325-
326-
String.Format("{{{0}}}", String.Join("; ", strs))
327-
@@>
304+
let thisObj = Expr.Coerce(this, typeof<obj>)
305+
<@@ RuntimeHelpers.formatObject(%%thisObj: obj) @@>
328306
)
329307

330308
toStr.SetMethodAttrs(MethodAttributes.Public ||| MethodAttributes.Virtual)

src/SwaggerProvider.DesignTime/v3/DefinitionCompiler.fs

Lines changed: 19 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,13 @@ and NamespaceAbstraction(name: string) =
167167
/// Object for compiling definitions.
168168
type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: bool) as this =
169169
let pathToSchema =
170-
if isNull schema.Components then
171-
Map.empty
172-
else
173-
schema.Components.Schemas
174-
|> Seq.map(fun kv -> DefinitionPath.DefinitionPrefix + kv.Key, kv.Value)
175-
|> Map.ofSeq
170+
let dict = Collections.Generic.Dictionary<string, IOpenApiSchema>()
171+
172+
if not(isNull schema.Components) then
173+
for kv in schema.Components.Schemas do
174+
dict.Add(DefinitionPath.DefinitionPrefix + kv.Key, kv.Value)
175+
176+
dict
176177

177178
let pathToType = Collections.Generic.Dictionary<_, Type>()
178179
let nsRoot = NamespaceAbstraction "Root"
@@ -220,14 +221,14 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b
220221
match pathToType.TryGetValue tyPath with
221222
| true, ty -> ty
222223
| false, _ ->
223-
match pathToSchema.TryFind tyPath with
224-
| Some def ->
224+
match pathToSchema.TryGetValue tyPath with
225+
| true, def ->
225226
let ns, tyName = tyPath |> DefinitionPath.Parse |> nsRoot.Resolve
226227
let ty = compileBySchema ns tyName def true (registerInNsAndInDef tyPath ns) true
227228
ty :> Type
228-
| None when tyPath.StartsWith DefinitionPath.DefinitionPrefix ->
229+
| false, _ when tyPath.StartsWith DefinitionPath.DefinitionPrefix ->
229230
failwithf $"Cannot find definition '%s{tyPath}' in schema definitions %A{pathToType.Keys |> Seq.toArray}"
230-
| None -> failwithf $"Cannot find definition '%s{tyPath}' (references to relative documents are not supported yet)"
231+
| _ -> failwithf $"Cannot find definition '%s{tyPath}' (references to relative documents are not supported yet)"
231232

232233
and compileBySchema (ns: NamespaceAbstraction) tyName (schemaObj: IOpenApiSchema) isRequired registerNew fromByPathCompiler =
233234
let compileNewObject() =
@@ -385,6 +386,8 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b
385386
)
386387

387388
// Override `.ToString()`
389+
// Delegates to the shared RuntimeHelpers.formatObject helper so that
390+
// each generated type's method body is a single static call (O(1) IL).
388391
let toStr =
389392
ProvidedMethod(
390393
"ToString",
@@ -394,39 +397,8 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b
394397
invokeCode =
395398
fun args ->
396399
let this = args[0]
397-
398-
let pNames, pValues =
399-
Array.ofList members
400-
|> Array.map(fun (pField, pProp) ->
401-
let pValObj = Expr.FieldGet(this, pField)
402-
pProp.Name, Expr.Coerce(pValObj, typeof<obj>))
403-
|> Array.unzip
404-
405-
let pValuesArr = Expr.NewArray(typeof<obj>, List.ofArray pValues)
406-
407-
<@@
408-
let values = %%pValuesArr: array<obj>
409-
410-
let rec formatValue(v: obj) =
411-
if isNull v then
412-
"null"
413-
else
414-
let vTy = v.GetType()
415-
416-
if vTy = typeof<string> then
417-
String.Format("\"{0}\"", v)
418-
elif vTy.IsArray then
419-
let elements = (v :?> seq<_>) |> Seq.map formatValue
420-
String.Format("[{0}]", String.Join("; ", elements))
421-
else
422-
v.ToString()
423-
424-
let strs =
425-
values
426-
|> Array.mapi(fun i v -> String.Format("{0}={1}", pNames[i], formatValue v))
427-
428-
String.Format("{{{0}}}", String.Join("; ", strs))
429-
@@>
400+
let thisObj = Expr.Coerce(this, typeof<obj>)
401+
<@@ RuntimeHelpers.formatObject(%%thisObj: obj) @@>
430402
)
431403

432404
toStr.SetMethodAttrs(MethodAttributes.Public ||| MethodAttributes.Virtual)
@@ -570,7 +542,10 @@ type DefinitionCompiler(schema: OpenApiDocument, provideNullable, useDateOnly: b
570542
tyType
571543

572544
// Precompile types defined in the `definitions` part of the schema
573-
do pathToSchema |> Seq.iter(fun kv -> compileByPath kv.Key |> ignore)
545+
do
546+
pathToSchema.Keys
547+
|> Seq.sort
548+
|> Seq.iter(fun key -> compileByPath key |> ignore)
574549

575550
/// Namespace that represent provided type space
576551
member _.Namespace = nsRoot

src/SwaggerProvider.Runtime/RuntimeHelpers.fs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,57 @@ module RuntimeHelpers =
137137
| :? Option<Guid> as x -> x |> toStrOpt name
138138
| _ -> [ name, obj.ToString() ]
139139

140+
/// Cache of sorted declared public instance properties per type, to avoid repeated
141+
/// reflection and sorting overhead when formatObject is called frequently.
142+
let private propCache =
143+
Collections.Concurrent.ConcurrentDictionary<Type, Reflection.PropertyInfo[]>()
144+
145+
let private getProperties(t: Type) =
146+
propCache.GetOrAdd(
147+
t,
148+
fun ty ->
149+
ty.GetProperties(
150+
Reflection.BindingFlags.Public
151+
||| Reflection.BindingFlags.Instance
152+
||| Reflection.BindingFlags.DeclaredOnly
153+
)
154+
|> Array.sortBy(fun p -> p.Name)
155+
)
156+
157+
/// Formats a generated API object as a string in the form `{Prop1=value1; Prop2=value2}`.
158+
/// Only declared public instance properties are included, sorted alphabetically by name.
159+
/// Used by the emitted ToString() override to keep the generated method body O(1) in size.
160+
let formatObject(obj: obj) : string =
161+
let props = getProperties(obj.GetType())
162+
163+
let strs =
164+
props
165+
|> Array.map(fun p ->
166+
let v = p.GetValue(obj)
167+
168+
let s =
169+
if isNull v then
170+
"null"
171+
else
172+
let vTy = v.GetType()
173+
174+
if vTy = typeof<string> then
175+
String.Format("\"{0}\"", v)
176+
elif vTy.IsArray then
177+
let elements =
178+
(v :?> Array)
179+
|> Seq.cast<obj>
180+
|> Seq.map(fun x -> if isNull x then "null" else x.ToString())
181+
|> Array.ofSeq
182+
183+
String.Format("[{0}]", String.Join("; ", elements))
184+
else
185+
v.ToString()
186+
187+
String.Format("{0}={1}", p.Name, s))
188+
189+
String.Format("{{{0}}}", String.Join("; ", strs))
190+
140191
let getPropertyNameAttribute name =
141192
{ new Reflection.CustomAttributeData() with
142193
member _.Constructor =

tests/SwaggerProvider.Tests/v3/Schema.V2SchemaCompilationTests.fs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ module SwaggerProvider.Tests.v3_Schema_V2SchemaCompilationTests
88
/// OpenApiClientProvider to become the single supported provider for v2 and v3.
99
1010
open System
11+
open System.Reflection
12+
open Microsoft.FSharp.Quotations
1113
open Microsoft.OpenApi.Reader
1214
open SwaggerProvider.Internal.v3.Compilers
1315
open Xunit
@@ -182,3 +184,59 @@ let ``v2 schema with integer enum property compiles``() =
182184
let codeProp = getProp statusType "Code"
183185
// integer enum — Microsoft.OpenApi maps this to the integer base type
184186
codeProp.PropertyType |> shouldEqual typeof<int32>
187+
188+
// ── ToString tests ───────────────────────────────────────────────────────────
189+
190+
[<Fact>]
191+
let ``v2 compiled object type declares ToString override``() =
192+
let types = compileV2Schema minimalPetstoreV2
193+
let petType = types |> List.find(fun t -> t.Name = "Pet")
194+
195+
let toStr =
196+
petType.GetMethods(
197+
BindingFlags.Public
198+
||| BindingFlags.Instance
199+
||| BindingFlags.DeclaredOnly
200+
)
201+
|> Array.tryFind(fun m -> m.Name = "ToString" && m.GetParameters().Length = 0)
202+
203+
toStr.IsSome |> shouldEqual true
204+
toStr.Value.ReturnType |> shouldEqual typeof<string>
205+
206+
[<Fact>]
207+
let ``v2 compiled object type ToString invokeCode does not throw for concrete provided type``() =
208+
// This test guards against the FS3033 regression where splicing a concrete provided-type
209+
// expression into a quotation expecting obj caused "Type mismatch when splicing expression".
210+
let types = compileV2Schema minimalPetstoreV2
211+
let petType = types |> List.find(fun t -> t.Name = "Pet")
212+
213+
let toStr =
214+
petType.GetMethods(
215+
BindingFlags.Public
216+
||| BindingFlags.Instance
217+
||| BindingFlags.DeclaredOnly
218+
)
219+
|> Array.find(fun m -> m.Name = "ToString" && m.GetParameters().Length = 0)
220+
221+
let providedMethod = toStr :?> ProviderImplementation.ProvidedTypes.ProvidedMethod
222+
223+
// Access GetInvokeCode via reflection to work around internal accessibility
224+
let invokeCodeProp =
225+
providedMethod.GetType().GetProperty("GetInvokeCode", BindingFlags.Instance ||| BindingFlags.NonPublic)
226+
227+
if isNull invokeCodeProp then
228+
failwith "GetInvokeCode property not found on ProvidedMethod"
229+
230+
let invokeCodeOpt =
231+
invokeCodeProp.GetValue(providedMethod) :?> (Expr list -> Expr) option
232+
233+
match invokeCodeOpt with
234+
| None -> failwith "ToString has no invoke code"
235+
| Some invokeCode ->
236+
let thisVar = Var("this", petType)
237+
let thisExpr = Expr.Var thisVar
238+
// Must not throw; original bug threw FS3033 here because %%this was
239+
// typed as FileDescription/Pet rather than obj inside the quotation body
240+
let body = invokeCode [ thisExpr ]
241+
// Expr is a value type; just verifying invokeCode did not throw is sufficient
242+
body.Type |> shouldEqual typeof<string>

0 commit comments

Comments
 (0)