Skip to content

Commit cfa5248

Browse files
authored
Merge pull request #78 from mts-ai/fix-joins-by-relationships
Fix JOINS by rerlationships
2 parents 3e68db5 + f076c45 commit cfa5248

File tree

6 files changed

+361
-20
lines changed

6 files changed

+361
-20
lines changed

fastapi_jsonapi/data_layers/filtering/sqlalchemy.py

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from fastapi_jsonapi.data_typing import TypeModel, TypeSchema
2727
from fastapi_jsonapi.exceptions import InvalidFilters, InvalidType
2828
from fastapi_jsonapi.exceptions.json_api import HTTPException
29-
from fastapi_jsonapi.schema import get_model_field, get_relationships
29+
from fastapi_jsonapi.schema import JSONAPISchemaIntrospectionError, get_model_field, get_relationships
3030

3131
log = logging.getLogger(__name__)
3232

@@ -44,7 +44,7 @@ class RelationshipFilteringInfo(BaseModel):
4444
target_schema: Type[TypeSchema]
4545
model: Type[TypeModel]
4646
aliased_model: AliasedClass
47-
column: InstrumentedAttribute
47+
join_column: InstrumentedAttribute
4848

4949
class Config:
5050
arbitrary_types_allowed = True
@@ -288,7 +288,10 @@ def get_model_column(
288288
schema: Type[TypeSchema],
289289
field_name: str,
290290
) -> InstrumentedAttribute:
291-
model_field = get_model_field(schema, field_name)
291+
try:
292+
model_field = get_model_field(schema, field_name)
293+
except JSONAPISchemaIntrospectionError as e:
294+
raise InvalidFilters(str(e))
292295

293296
try:
294297
return getattr(model, model_field)
@@ -327,8 +330,9 @@ def gather_relationships_info(
327330
model: Type[TypeModel],
328331
schema: Type[TypeSchema],
329332
relationship_path: List[str],
330-
collected_info: dict,
333+
collected_info: dict[RelationshipPath, RelationshipFilteringInfo],
331334
target_relationship_idx: int = 0,
335+
prev_aliased_model: Optional[Any] = None,
332336
) -> dict[RelationshipPath, RelationshipFilteringInfo]:
333337
is_last_relationship = target_relationship_idx == len(relationship_path) - 1
334338
target_relationship_path = RELATIONSHIP_SPLITTER.join(
@@ -342,25 +346,36 @@ def gather_relationships_info(
342346

343347
target_schema = schema.__fields__[target_relationship_name].type_
344348
target_model = getattr(model, target_relationship_name).property.mapper.class_
345-
target_column = get_model_column(
346-
model,
347-
schema,
348-
target_relationship_name,
349-
)
349+
350+
if prev_aliased_model:
351+
join_column = get_model_column(
352+
model=prev_aliased_model,
353+
schema=schema,
354+
field_name=target_relationship_name,
355+
)
356+
else:
357+
join_column = get_model_column(
358+
model,
359+
schema,
360+
target_relationship_name,
361+
)
362+
363+
aliased_model = aliased(target_model)
350364
collected_info[target_relationship_path] = RelationshipFilteringInfo(
351365
target_schema=target_schema,
352366
model=target_model,
353-
aliased_model=aliased(target_model),
354-
column=target_column,
367+
aliased_model=aliased_model,
368+
join_column=join_column,
355369
)
356370

357371
if not is_last_relationship:
358372
return gather_relationships_info(
359-
target_model,
360-
target_schema,
361-
relationship_path,
362-
collected_info,
363-
target_relationship_idx + 1,
373+
model=target_model,
374+
schema=target_schema,
375+
relationship_path=relationship_path,
376+
collected_info=collected_info,
377+
target_relationship_idx=target_relationship_idx + 1,
378+
prev_aliased_model=aliased_model,
364379
)
365380

366381
return collected_info
@@ -553,5 +568,5 @@ def create_filters_and_joins(
553568
target_schema=schema,
554569
relationships_info=relationships_info,
555570
)
556-
joins = [(info.aliased_model, info.column) for info in relationships_info.values()]
571+
joins = [(info.aliased_model, info.join_column) for info in relationships_info.values()]
557572
return expressions, joins

fastapi_jsonapi/schema.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ class JSONAPIResultDetailSchema(BaseJSONAPIResultSchema):
122122
]
123123

124124

125+
class JSONAPISchemaIntrospectionError(Exception):
126+
pass
127+
128+
125129
def get_model_field(schema: Type["TypeSchema"], field: str) -> str:
126130
"""
127131
Get the model field of a schema field.
@@ -145,7 +149,7 @@ class ComputerSchema(pydantic_base):
145149
schema=schema.__name__,
146150
field=field,
147151
)
148-
raise Exception(msg)
152+
raise JSONAPISchemaIntrospectionError(msg)
149153
return field
150154

151155

tests/fixtures/app.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
11
from pathlib import Path
2-
from typing import Type
2+
from typing import Optional, Type
33

44
import pytest
55
from fastapi import APIRouter, FastAPI
6+
from pydantic import BaseModel
67

78
from fastapi_jsonapi import RoutersJSONAPI, init
89
from fastapi_jsonapi.atomic import AtomicOperations
10+
from fastapi_jsonapi.data_typing import TypeModel
911
from fastapi_jsonapi.views.detail_view import DetailViewBase
1012
from fastapi_jsonapi.views.list_view import ListViewBase
1113
from tests.fixtures.views import (
1214
DetailViewBaseGeneric,
1315
ListViewBaseGeneric,
1416
)
1517
from tests.models import (
18+
Alpha,
19+
Beta,
1620
Child,
1721
Computer,
1822
CustomUUIDItem,
23+
Delta,
24+
Gamma,
1925
Parent,
2026
ParentToChildAssociation,
2127
Post,
@@ -25,13 +31,17 @@
2531
UserBio,
2632
)
2733
from tests.schemas import (
34+
AlphaSchema,
35+
BetaSchema,
2836
ChildInSchema,
2937
ChildPatchSchema,
3038
ChildSchema,
3139
ComputerInSchema,
3240
ComputerPatchSchema,
3341
ComputerSchema,
3442
CustomUUIDItemSchema,
43+
DeltaSchema,
44+
GammaSchema,
3545
ParentPatchSchema,
3646
ParentSchema,
3747
ParentToChildAssociationSchema,
@@ -245,3 +255,74 @@ def build_app_custom(
245255
app.include_router(atomic.router, prefix="")
246256
init(app)
247257
return app
258+
259+
260+
def build_alphabet_app() -> FastAPI:
261+
return build_custom_app_by_schemas(
262+
[
263+
ResourceInfoDTO(
264+
path="/alpha",
265+
resource_type="alpha",
266+
model=Alpha,
267+
schema_=AlphaSchema,
268+
),
269+
ResourceInfoDTO(
270+
path="/beta",
271+
resource_type="beta",
272+
model=Beta,
273+
schema_=BetaSchema,
274+
),
275+
ResourceInfoDTO(
276+
path="/gamma",
277+
resource_type="gamma",
278+
model=Gamma,
279+
schema_=GammaSchema,
280+
),
281+
ResourceInfoDTO(
282+
path="/delta",
283+
resource_type="delta",
284+
model=Delta,
285+
schema_=DeltaSchema,
286+
),
287+
],
288+
)
289+
290+
291+
class ResourceInfoDTO(BaseModel):
292+
path: str
293+
resource_type: str
294+
model: Type[TypeModel]
295+
schema_: Type[BaseModel]
296+
schema_in_patch: Optional[BaseModel] = None
297+
schema_in_post: Optional[BaseModel] = None
298+
class_list: Type[ListViewBase] = ListViewBaseGeneric
299+
class_detail: Type[DetailViewBase] = DetailViewBaseGeneric
300+
301+
class Config:
302+
arbitrary_types_allowed = True
303+
304+
305+
def build_custom_app_by_schemas(resources_info: list[ResourceInfoDTO]):
306+
router: APIRouter = APIRouter()
307+
308+
for info in resources_info:
309+
RoutersJSONAPI(
310+
router=router,
311+
path=info.path,
312+
tags=["Misc"],
313+
class_list=info.class_list,
314+
class_detail=info.class_detail,
315+
schema=info.schema_,
316+
resource_type=info.resource_type,
317+
schema_in_patch=info.schema_in_patch,
318+
schema_in_post=info.schema_in_post,
319+
model=info.model,
320+
)
321+
322+
app = build_app_plain()
323+
app.include_router(router, prefix="")
324+
325+
atomic = AtomicOperations()
326+
app.include_router(atomic.router, prefix="")
327+
init(app)
328+
return app

tests/models.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,3 +312,81 @@ class SelfRelationship(Base):
312312
class ContainsTimestamp(Base):
313313
id = Column(Integer, primary_key=True)
314314
timestamp = Column(DateTime(True), nullable=False)
315+
316+
317+
class Alpha(Base):
318+
__tablename__ = "alpha"
319+
320+
id = Column(Integer, primary_key=True, autoincrement=True)
321+
beta_id = Column(
322+
Integer,
323+
ForeignKey("beta.id"),
324+
nullable=False,
325+
index=True,
326+
)
327+
beta = relationship("Beta", back_populates="alphas")
328+
gamma_id = Column(Integer, ForeignKey("gamma.id"), nullable=False)
329+
gamma: "Gamma" = relationship("Gamma")
330+
331+
332+
class BetaGammaBinding(Base):
333+
__tablename__ = "beta_gamma_binding"
334+
335+
id: int = Column(Integer, primary_key=True)
336+
beta_id: int = Column(ForeignKey("beta.id", ondelete="CASCADE"), nullable=False)
337+
gamma_id: int = Column(ForeignKey("gamma.id", ondelete="CASCADE"), nullable=False)
338+
339+
340+
class Beta(Base):
341+
__tablename__ = "beta"
342+
343+
id = Column(Integer, primary_key=True, autoincrement=True)
344+
gammas: List["Gamma"] = relationship(
345+
"Gamma",
346+
secondary="beta_gamma_binding",
347+
back_populates="betas",
348+
lazy="noload",
349+
)
350+
alphas = relationship("Alpha")
351+
deltas: List["Delta"] = relationship(
352+
"Delta",
353+
secondary="beta_delta_binding",
354+
lazy="noload",
355+
)
356+
357+
358+
class Gamma(Base):
359+
__tablename__ = "gamma"
360+
361+
id = Column(Integer, primary_key=True, autoincrement=True)
362+
betas: List["Beta"] = relationship(
363+
"Beta",
364+
secondary="beta_gamma_binding",
365+
back_populates="gammas",
366+
lazy="raise",
367+
)
368+
delta_id: int = Column(
369+
Integer,
370+
ForeignKey("delta.id", ondelete="CASCADE"),
371+
nullable=False,
372+
index=True,
373+
)
374+
alpha = relationship("Alpha")
375+
delta: "Delta" = relationship("Delta")
376+
377+
378+
class BetaDeltaBinding(Base):
379+
__tablename__ = "beta_delta_binding"
380+
381+
id: int = Column(Integer, primary_key=True)
382+
beta_id: int = Column(ForeignKey("beta.id", ondelete="CASCADE"), nullable=False)
383+
delta_id: int = Column(ForeignKey("delta.id", ondelete="CASCADE"), nullable=False)
384+
385+
386+
class Delta(Base):
387+
__tablename__ = "delta"
388+
389+
id = Column(Integer, primary_key=True, autoincrement=True)
390+
name = Column(String)
391+
gammas: List["Gamma"] = relationship("Gamma", back_populates="delta", lazy="noload")
392+
betas: List["Beta"] = relationship("Beta", secondary="beta_delta_binding", back_populates="deltas", lazy="noload")

tests/schemas.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,3 +412,72 @@ class SelfRelationshipSchema(BaseModel):
412412
class CustomUserAttributesSchema(UserBaseSchema):
413413
spam: str
414414
eggs: str
415+
416+
417+
class AlphaSchema(BaseModel):
418+
beta: Optional["BetaSchema"] = Field(
419+
relationship=RelationshipInfo(
420+
resource_type="beta",
421+
),
422+
)
423+
gamma: Optional["GammaSchema"] = Field(
424+
relationship=RelationshipInfo(
425+
resource_type="gamma",
426+
),
427+
)
428+
429+
430+
class BetaSchema(BaseModel):
431+
alphas: Optional["AlphaSchema"] = Field(
432+
relationship=RelationshipInfo(
433+
resource_type="alpha",
434+
),
435+
)
436+
gammas: Optional["GammaSchema"] = Field(
437+
None,
438+
relationship=RelationshipInfo(
439+
resource_type="gamma",
440+
many=True,
441+
),
442+
)
443+
deltas: Optional["DeltaSchema"] = Field(
444+
None,
445+
relationship=RelationshipInfo(
446+
resource_type="delta",
447+
many=True,
448+
),
449+
)
450+
451+
452+
class GammaSchema(BaseModel):
453+
betas: Optional["BetaSchema"] = Field(
454+
None,
455+
relationship=RelationshipInfo(
456+
resource_type="beta",
457+
many=True,
458+
),
459+
)
460+
delta: Optional["DeltaSchema"] = Field(
461+
None,
462+
relationship=RelationshipInfo(
463+
resource_type="Delta",
464+
),
465+
)
466+
467+
468+
class DeltaSchema(BaseModel):
469+
name: str
470+
gammas: Optional["GammaSchema"] = Field(
471+
None,
472+
relationship=RelationshipInfo(
473+
resource_type="gamma",
474+
many=True,
475+
),
476+
)
477+
betas: Optional["BetaSchema"] = Field(
478+
None,
479+
relationship=RelationshipInfo(
480+
resource_type="beta",
481+
many=True,
482+
),
483+
)

0 commit comments

Comments
 (0)