Add a part 2 to FastAPI primer that has more advanced usage of QB#8872
Add a part 2 to FastAPI primer that has more advanced usage of QB#8872vpetrovykh wants to merge 8 commits intomasterfrom
Conversation
Docs preview deploy✅ Successfully deployed docs preview for commit c100f03: https://edgedb-docs-mlteks0s3-edgedb.vercel.app (Last updated: Jul 19, 2025, 02:18:09 UTC) |
scotttrinh
left a comment
There was a problem hiding this comment.
Some general feedback about picking a more RESTful style
| return team | ||
|
|
||
|
|
||
| To quite a team, we cannot just fetch the Person and set the ``team`` field to ``None``, because that field is computed and cannot be edited. Instead we need to fetch the team with the members list and remove the Person from there. The challenge is to do all this when given only the Person's name: |
There was a problem hiding this comment.
| To quite a team, we cannot just fetch the Person and set the ``team`` field to ``None``, because that field is computed and cannot be edited. Instead we need to fetch the team with the members list and remove the Person from there. The challenge is to do all this when given only the Person's name: | |
| To quit a team, we cannot just fetch the Person and set the ``team`` field to ``None``, because that field is computed and cannot be edited. Instead we need to fetch the team with the members list and remove the Person from there. The challenge is to do all this when given only the Person's name: |
| @app.post("/person/{pname}/add_friend", response_model=PersonWithFriends) | ||
| async def add_friend( | ||
| pname: str, | ||
| frname: str, | ||
| ): | ||
| db = g.client | ||
| # fetch the main person | ||
| person = await db.get( | ||
| default.Person.select( | ||
| # fetch all properties | ||
| '*', | ||
| # also fetch friends (with properties) | ||
| friends=True, | ||
| ).filter( | ||
| name=pname | ||
| ) | ||
| ) | ||
| # fetch the friend | ||
| friend = await db.get( | ||
| default.Person.filter( | ||
| name=frname | ||
| ) | ||
| ) | ||
| # append the new friend to existing friends | ||
| person.friends.append(friend) | ||
| await db.save(person) | ||
| return person |
There was a problem hiding this comment.
I'd like to propose that we stick to a more basic RESTful style rather than having some RESTful endpoints, and some more generic RPC style endpoints like this one. So, my concrete suggestion for this case would be that add_friend is a POST to the friends linked resource:
| @app.post("/person/{pname}/add_friend", response_model=PersonWithFriends) | |
| async def add_friend( | |
| pname: str, | |
| frname: str, | |
| ): | |
| db = g.client | |
| # fetch the main person | |
| person = await db.get( | |
| default.Person.select( | |
| # fetch all properties | |
| '*', | |
| # also fetch friends (with properties) | |
| friends=True, | |
| ).filter( | |
| name=pname | |
| ) | |
| ) | |
| # fetch the friend | |
| friend = await db.get( | |
| default.Person.filter( | |
| name=frname | |
| ) | |
| ) | |
| # append the new friend to existing friends | |
| person.friends.append(friend) | |
| await db.save(person) | |
| return person | |
| @app.post("/person/{pname}/friends", response_model=list[Person]) | |
| async def add_friend( | |
| pname: str, | |
| frname: str, | |
| ): | |
| db = g.client | |
| # fetch the main person | |
| person = await db.get( | |
| default.Person.select( | |
| # fetch all properties | |
| '*', | |
| # also fetch friends (with properties) | |
| friends=True, | |
| ).filter( | |
| name=pname | |
| ) | |
| ) | |
| # fetch the friend | |
| friend = await db.get( | |
| default.Person.filter( | |
| name=frname | |
| ) | |
| ) | |
| # append the new friend to existing friends | |
| person.friends.append(friend) | |
| await db.save(person) | |
| return person.friends |
| class BaseTeam(default.Team.__variants__.Base): | ||
| name: default.Team.__typeof__.name | ||
| members: list[BasePerson] | ||
|
|
||
|
|
||
| @app.post("/teams/{team_name}/add_member", response_model=BaseTeam) | ||
| async def add_member(team_name: str, member_name: str): |
There was a problem hiding this comment.
And similarly, something like:
| class BaseTeam(default.Team.__variants__.Base): | |
| name: default.Team.__typeof__.name | |
| members: list[BasePerson] | |
| @app.post("/teams/{team_name}/add_member", response_model=BaseTeam) | |
| async def add_member(team_name: str, member_name: str): | |
| class TeamMember(default.Person.__variants__.Base): | |
| name: default.Person.__typeof__.name | |
| @app.post("/teams/{team_name}/members", response_model=BaseTeam) | |
| async def add_member(team_name: str, new_member: TeamMember): |
|
|
||
| .. code-block:: python | ||
|
|
||
| @app.post("/person/{pname}/quit_team", response_model=str) |
There was a problem hiding this comment.
Avoid returning bare strings. Probably return the refetched person object.
| @app.post("/person/{pname}/quit_team", response_model=str) | |
| @app.delete("/person/{pname}/team", response_model=Person) |
| @app.get("/games/", response_model=list[GameSessionBase]) | ||
| async def get_games_with_team(team_name: str): | ||
| db = g.client | ||
| q = default.GameSession.filter( | ||
| # use an expression as a filter | ||
| lambda g: std.any(g.players.team.name == team_name), | ||
| # filter by status and is_full | ||
| status=default.GameStatus.Waiting, | ||
| is_full=False, | ||
| ).order_by(title=True) | ||
|
|
||
| return await db.query(q) |
There was a problem hiding this comment.
I would treat the "filter by team name" part here as just a special case of dynamic filtering, so this endpoint is really just get_games that allows you to filter on teams, status, fullness, or returns all games.
| @app.get("/games/", response_model=list[GameSessionBase]) | |
| async def get_games_with_team(team_name: str): | |
| db = g.client | |
| q = default.GameSession.filter( | |
| # use an expression as a filter | |
| lambda g: std.any(g.players.team.name == team_name), | |
| # filter by status and is_full | |
| status=default.GameStatus.Waiting, | |
| is_full=False, | |
| ).order_by(title=True) | |
| return await db.query(q) | |
| class GameFilter(BaseModel): | |
| team_name: str | None = None | |
| status: default.GameStatus | None = None | |
| is_full: bool | None = None | |
| @app.get("/games/", response_model=list[GameSessionBase]) | |
| async def get_games(game_filter: Annotated[GameFilter, Query()]): | |
| db = g.client | |
| q = default.GameSession.order_by(title=True) | |
| if game_filter.team_name is not None: | |
| q = q.filter( | |
| # use an expression as a filter | |
| lambda g: std.any(g.players.team.name == game_filter.team_name) | |
| ) | |
| if game_filter.status is not None: | |
| q = q.filter(status=game_filter.status) | |
| if game_filter.is_full is not None: | |
| q = q.filter(is_full=game_filter.is_full) | |
| return await db.query(q) |
| @app.post("/games/{game_id}/start", response_model=str) | ||
| async def start_game(game_id: uuid.UUID): | ||
| db = g.client | ||
| # instead of fetching the game, then updating and saving, | ||
| # we can update directly | ||
| q = default.GameSession.filter( | ||
| id=game_id, | ||
| # make sure the game is eligible to be started | ||
| status=default.GameStatus.Waiting, | ||
| is_full=True, | ||
| ).update( | ||
| status='Active', | ||
| ).select('*') # select the updated game | ||
| result = await db.query(q) | ||
|
|
||
| if len(result) == 0: | ||
| return "Game not started" | ||
| else: | ||
| return f"{result[0].num_players} player game started" |
There was a problem hiding this comment.
| @app.post("/games/{game_id}/start", response_model=str) | |
| async def start_game(game_id: uuid.UUID): | |
| db = g.client | |
| # instead of fetching the game, then updating and saving, | |
| # we can update directly | |
| q = default.GameSession.filter( | |
| id=game_id, | |
| # make sure the game is eligible to be started | |
| status=default.GameStatus.Waiting, | |
| is_full=True, | |
| ).update( | |
| status='Active', | |
| ).select('*') # select the updated game | |
| result = await db.query(q) | |
| if len(result) == 0: | |
| return "Game not started" | |
| else: | |
| return f"{result[0].num_players} player game started" | |
| @app.post("/games/{game_id}/start", response_model=GameSession) | |
| async def start_game(game_id: uuid.UUID): | |
| db = g.client | |
| # instead of fetching the game, then updating and saving, | |
| # we can update directly | |
| q = default.GameSession.filter( | |
| id=game_id, | |
| # make sure the game is eligible to be started | |
| status=default.GameStatus.Waiting, | |
| is_full=True, | |
| ).update( | |
| status='Active', | |
| ).select('*') # select the updated game | |
| result = await db.query(q) | |
| if len(result) == 0: | |
| raise HTTPException(status_code=404, f"No eligible game found with id '{game_id}'') | |
| return result |
| @app.get("/games/fit_team", response_model=list[GameSessionBase]) | ||
| async def get_games_for_team(team_name: str): | ||
| db = g.client | ||
| # make the team subquery | ||
| team = default.Team.filter(name=team_name) | ||
| # use the team subquery to find the games | ||
| q = default.GameSession.filter( | ||
| lambda g: g.max_players - g.num_players >= std.count(team.members), | ||
| ).order_by(title=True) | ||
|
|
||
| return await db.query(q) No newline at end of file |
There was a problem hiding this comment.
Oh, this is fancy! Putting on my RESTifarian glasses, I'd write this as a fit_team_name attribute on the GameFilter from earlier. We'd also want to update it to take into account games that already have some members of this team, so the math is a bit less straightforward than the current algorithm.
This primer focuses on integration between FastAPI and Gel in ORM/QB mode.
Change the examples to use Person instead of User to avoid implication that this has anything to do with authentication and user accounts. Some markup cleanup.
c100f03 to
73ed9cf
Compare
This is a draft because some of the QB code here is bugged.