@@ -214,6 +214,19 @@ def _recurse_over_model_tree(treeobj: Any, path: list[str]) -> Any:
214214 return _recurse_over_model_tree (Schema , [])
215215
216216
217+ def _path_tuple_to_str (pathtuple : tuple ) -> str :
218+ """Convert a path tuple to a dotted string, replacing sentinels with [] / {} indexing."""
219+ str_parts = []
220+ for part in pathtuple :
221+ if part is SEQ_INDEX_SENTINEL :
222+ str_parts .append ("[]" )
223+ elif part is DICT_INDEX_SENTINEL :
224+ str_parts .append ("{}" )
225+ else :
226+ str_parts .append (part )
227+ return "." .join (str_parts )
228+
229+
217230def _serialize_diffable_arrays (
218231 obj : dict [tuple , Any ],
219232) -> dict [str , dict [str , Any ]]:
@@ -233,24 +246,19 @@ def _serialize_diffable_arrays(
233246 else :
234247 shape = tuple (shape )
235248
236- # Replace sentinel values with indexing syntax
237- str_parts = []
238- for part in pathtuple :
239- if part is SEQ_INDEX_SENTINEL :
240- str_parts .append ("[]" )
241- elif part is DICT_INDEX_SENTINEL :
242- str_parts .append ("{}" )
243- else :
244- str_parts .append (part )
245-
246- serialized ["." .join (str_parts )] = {
249+ serialized [_path_tuple_to_str (pathtuple )] = {
247250 "shape" : shape ,
248251 "dtype" : dtype ,
249252 }
250253
251254 return serialized
252255
253256
257+ def _serialize_field_order (obj : dict [tuple , list [str ]]) -> dict [str , list [str ]]:
258+ """Convert a dict {path_tuple: [field_names]} to a dict {path_str: [field_names]}."""
259+ return {_path_tuple_to_str (p ): fields for p , fields in obj .items ()}
260+
261+
254262def create_apply_schema (
255263 InputSchema : type [BaseModel ], OutputSchema : type [BaseModel ]
256264) -> tuple [type [BaseModel ], type [BaseModel ]]:
@@ -266,6 +274,11 @@ def create_apply_schema(
266274 OutputSchema , filter_fn = is_differentiable
267275 )
268276
277+ # Snapshot field declaration order for every (nested) model, since JSON object key order is
278+ # not guaranteed to be preserved by downstream clients (e.g. postgres).
279+ input_field_order = get_field_order (InputSchema )
280+ output_field_order = get_field_order (OutputSchema )
281+
269282 InputSchema = apply_function_to_model_tree (
270283 InputSchema ,
271284 lambda x , _ : x ,
@@ -287,9 +300,15 @@ class ApplyInputSchema(BaseModel):
287300 differentiable_arrays : ClassVar [dict [str , dict [str , Any ]]] = (
288301 _serialize_diffable_arrays (diffable_input_paths )
289302 )
303+ field_order : ClassVar [dict [str , list [str ]]] = _serialize_field_order (
304+ input_field_order
305+ )
290306 model_config = ConfigDict (
291307 extra = "forbid" ,
292- json_schema_extra = {"differentiable_arrays" : differentiable_arrays },
308+ json_schema_extra = {
309+ "differentiable_arrays" : differentiable_arrays ,
310+ "field_order" : field_order ,
311+ },
293312 )
294313
295314 class ApplyOutputSchema (RootModel ):
@@ -299,8 +318,14 @@ class ApplyOutputSchema(RootModel):
299318 differentiable_arrays : ClassVar [dict [str , dict [str , Any ]]] = (
300319 _serialize_diffable_arrays (diffable_output_paths )
301320 )
321+ field_order : ClassVar [dict [str , list [str ]]] = _serialize_field_order (
322+ output_field_order
323+ )
302324 model_config = ConfigDict (
303- json_schema_extra = {"differentiable_arrays" : differentiable_arrays }
325+ json_schema_extra = {
326+ "differentiable_arrays" : differentiable_arrays ,
327+ "field_order" : field_order ,
328+ },
304329 )
305330
306331 return ApplyInputSchema , ApplyOutputSchema
@@ -382,6 +407,25 @@ def add_path(obj: T, path: tuple) -> T:
382407 return path_to_type
383408
384409
410+ def get_field_order (schema : type [BaseModel ]) -> dict [tuple , list [str ]]:
411+ """Return a mapping {model_path: [field_names_in_declaration_order]} for every BaseModel in the tree.
412+
413+ The root model is keyed by the empty tuple. Field names are collected as a side effect while
414+ traversing leaf paths: any string at position `i` in a leaf path is a field of the model at
415+ prefix `path[:i]`.
416+ """
417+ field_order_by_path : dict [tuple , dict [str , None ]] = {}
418+
419+ def add_field (obj : T , path : tuple ) -> T :
420+ for i , part in enumerate (path ):
421+ if isinstance (part , str ):
422+ field_order_by_path .setdefault (path [:i ], {})[part ] = None
423+ return obj
424+
425+ apply_function_to_model_tree (schema , add_field )
426+ return {p : list (fields ) for p , fields in field_order_by_path .items ()}
427+
428+
385429def _path_to_pattern (path : Sequence [str | object ]) -> str :
386430 """Return a type describing valid paths for all passed paths."""
387431 # Check if path includes sequence or dict indexing -- in this case, we can't use the
0 commit comments