Repository: pydantic/pydantic

Issue:
Properties order v1 -> v2
related https://github.com/tiangolo/fastapi/discussions/9709#discussioncomment-6239747

```Python
class Payload(BaseModel):
    username: Optional[str] = None
    password: Optional[str] = None
```

when I generate schema the order of the fields changes:

```
{
  "properties": {
    "password": {
         ...
    },
    "username": {
         ...
    }
  },
  "title": "Payload",
  "type": "object"
}
```
(first goes password, instead of username)

this was working fine on v1


### Python, Pydantic & OS Version

```Text
pydantic version: 2.0b3
        pydantic-core version: 0.39.0 release build profile
                 install path: /private/tmp/fastapitest/.venv/lib/python3.9/site-packages/pydantic
               python version: 3.9.15 (main, Dec  6 2022, 21:16:57)  [Clang 14.0.0 (clang-1400.0.29.202)]
                     platform: macOS-13.4-arm64-arm-64bit
     optional deps. installed: ['typing-extensions']
```



Discussion:
I don't think it's a bug per say, since it was introduced on purpose on #6043, but since we don't provide a way to customize it, I think we should revert it.

What do you think @adriangb ?
> I don't think it's a bug per say

Yeah, technically it is not , but from UI perspective it's a nightmare :D
I think that the intent in #6043 was to be deterministic and decouple the attributes order in JSON Schema from internal implementation changes. IMO, this doesn't contradict preserving user-defined attributes order.

Patch:
diff --git a/pydantic/json_schema.py b/pydantic/json_schema.py
--- a/pydantic/json_schema.py
+++ b/pydantic/json_schema.py
@@ -1656,11 +1656,14 @@ def _make_json_hashable(value: _Json) -> _HashableJson:
         return value
 
 
-def _sort_json_schema(value: JsonSchemaValue) -> JsonSchemaValue:
+def _sort_json_schema(value: JsonSchemaValue, parent_key: str | None = None) -> JsonSchemaValue:
     if isinstance(value, dict):  # type: ignore
         sorted_dict: dict[str, JsonSchemaValue] = {}
-        for key in sorted(value.keys()):
-            sorted_dict[key] = _sort_json_schema(value[key])
+        keys = value.keys()
+        if parent_key != 'properties':
+            keys = sorted(keys)
+        for key in keys:
+            sorted_dict[key] = _sort_json_schema(value[key], parent_key=key)
         return sorted_dict  # type: ignore
     elif isinstance(value, list):  # type: ignore
         sorted_list: list[JsonSchemaValue] = []


Test patch:
diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py
--- a/tests/test_json_schema.py
+++ b/tests/test_json_schema.py
@@ -116,7 +116,7 @@ class ApplePie(BaseModel):
         },
         'required': ['Snap'],
     }
-    assert list(ApplePie.model_json_schema(by_alias=True)['properties'].keys()) == ['Crackle', 'Snap']
+    assert list(ApplePie.model_json_schema(by_alias=True)['properties'].keys()) == ['Snap', 'Crackle']
     assert list(ApplePie.model_json_schema(by_alias=False)['properties'].keys()) == ['a', 'b']
 
 
@@ -4774,3 +4774,37 @@ class Model(BaseModel):
             'input': 'abc',
         }
     ]
+
+
+def test_json_schema_keys_sorting() -> None:
+    """We sort all keys except those under a 'property' parent key"""
+
+    class Model(BaseModel):
+        b: int
+        a: str
+
+    class OuterModel(BaseModel):
+        inner: List[Model]
+
+    # verify the schema contents
+    # this is just to get a nicer error message / diff if it fails
+    expected = {
+        '$defs': {
+            'Model': {
+                'properties': {'b': {'title': 'B', 'type': 'integer'}, 'a': {'title': 'A', 'type': 'string'}},
+                'required': ['b', 'a'],
+                'title': 'Model',
+                'type': 'object',
+            }
+        },
+        'properties': {'inner': {'items': {'$ref': '#/$defs/Model'}, 'title': 'Inner', 'type': 'array'}},
+        'required': ['inner'],
+        'title': 'OuterModel',
+        'type': 'object',
+    }
+    actual = OuterModel.model_json_schema()
+    assert actual == expected
+
+    # verify order
+    # dumping to json just happens to be a simple way to verify the order
+    assert json.dumps(actual, indent=2) == json.dumps(expected, indent=2)