LINUX.ORG.RU

fastapi и динамическая модель

 , ,


0

3

У меня есть структура из 3 таблиц

  • users
  • userFields (ссылается m2o на users, ссылается o2o на userFieldTypes)
  • userFieldTypes

Хочу на стороне fastapi получать и отдавать users сразу с его доп полями, и уже внутри по sql распихивать как мне удобно.

Я сделал так, чтобы pydantic модель User можно было менять, вот мои модели для view стороны

class UserFieldType(BaseModel):  # create and update
    id: Optional[int]
    name: str
    type: str

    @field_validator('type')
    @classmethod
    def validate_type(cls, value):
        if value not in ALLOWED_USER_FIELD_TYPE:
            raise ValidationError(f'type must be one of {list(ALLOWED_USER_FIELD_TYPE.keys())}')
        return value

class User(BaseModel):
    id: Optional[int]
    login: str
    password: str
    description: Optional[str]

    # dynamic extra fields
    @classmethod
    def add_field(cls, name: str, definition: Any, default: Any = ..., optional=True):
        if name in cls.model_fields:
            raise ValueError(f"Field {name} already exist")
        cls.change_field(name, Optional[definition], default, optional)

    @classmethod
    def change_field(cls, name: str, definition: Any, default: Any = None, optional=True):
        if optional:
            definition=Optional[definition]
        new_field = FieldInfo(annotation=definition, default=default)
        cls.model_fields.update({name: new_field})
        cls.model_rebuild(force=True)

    @classmethod
    def delete_field(cls, name: str):
        cls.model_fields.pop(name)
        cls.model_rebuild(force=True)

И для проверки этого всего сделал несколько маршрутов

@router.get("/users", response_model=(List[schemas.view.User]))
def get_users(
        db_session: Session = Depends(session.get_session)
):
    return db_session.query(models.User).all()


@router.post("/users", response_model=schemas.view.User)
def add_user(
        user: schemas.view.User,
        db_session: Session = Depends(session.get_session)
):
    print(user)
    print('tst' in user)
    print(user.tst if 'tst' in user else "")
    print(schemas.view.User.model_fields)
    return user


@router.get("/user/extra_fields", response_model=(List[schemas.db.UserFieldType]))
def get_user_extra_fields(
        db_session: Session = Depends(session.get_session)
):
    return db_session.query(models.UserFieldType).all()


@router.post("/user/extra_fields", response_model=schemas.db.UserFieldType)
def add_user_extra_field(
        field: schemas.view.UserFieldType,
        db_session: Session = Depends(session.get_session)
):
    new_field = models.UserFieldType(**field.model_dump())
    print(schemas.view.User.model_fields)
    schemas.view.User.add_field(field.name, ALLOWED_USER_FIELD_TYPE[field.type])
    db_session.add(new_field)
    db_session.commit()
    print(schemas.view.User.model_fields)
    from app.main import app
    app.openapi_schema = None
    app.setup()
    return new_field

Проблема 1:
Я ожидал, что с изменением модели openapi схему надо будет пнуть и для этого у меня app.setup(), но документация в http:/.../docs' не обновляется.

Проблема 2:
Даже с учётом того, что я изменил модель User (добавив поле tst) fastapi её не подхватывает,
т.е. запрос к post("/users") с данными '{"id": 1, "login": "l1", "password": "p1", "description": "d1", "tst": "t1"}' выполняется, но в полученном объекте нет поля tst, эти данные потеряны. А именно я вижу:

  • print('tst' in user) -> False
  • print(schemas.view.User.model_fields) -> вижу своё новое поле {'id': FieldInfo(annotation=Union[int, NoneType], required=True), 'login': FieldInfo(annotation=str, required=True), 'password': FieldInfo(annotation=str, required=True), 'description': FieldInfo(annotation=Union[str, NoneType], required=True), 'tst': FieldInfo(annotation=Union[str, NoneType], required=True)}

Вопрос, что надо правильно пнуть в fastapi чтобы хотябы пройти проблему №2 ?

★★

Последнее исправление: Flotsky (всего исправлений: 1)

Так, я понял, что надо смотреть не 'tst' in user, а 'tst' in user_model_fields

Ещё я понял, что если позволить модели model_config = ConfigDict(extra='allow'), то я получу таки свой новый ключик. Наверное это решение проблемы 2.

Остаётся вопрос по проблеме 1, как заставить openapi документацию быть актуальной по отношению к моделям?

После добавления extra="allow" к модели ‘/docs’ теперь предлагает к post User запросу добавить "additionalProp1": {} что не то, чтобы неправильно, но может есть возможность указать актуальное количество параметров и их имена?

Flotsky ★★
() автор топика
Ответ на: комментарий от Flotsky

По-моему, в твоем случае когда у тебя стабильная схема таблицы в бд, нужно описать в модели пидантика все поля как они есть в модели алхимии - проблема решена.

Если есть какие-то динамические поля, которые можно произвольно создавать и у тебя postgres можно хранить их в колонке user.extra_fields с типом jsonb прям в json и отдавать/сохранять его как есть.

class UserField(BaseModel):  # create and update
    id: Optional[int]
    name: str
    value: str

class User(BaseModel):
    id: Optional[int]
    login: str
    password: str
    description: Optional[str]
    fields: List[UserField]

masa
()
Последнее исправление: masa (всего исправлений: 1)
Ответ на: комментарий от masa

Модели для БД есть, они лежат в сторонке и все связи в них прописаны.

Мне хочется чтобы через API можно было взять и создать пользователя сразу со всеми доп полями. А внутри уже поместить куда надо.

Т.е. у меня есть SQL.User с полями условно id, login, passwd, description и несколько SQL.UserField для этого пользователя, скажем (user_id: 1, type_id: 1, value: 'mail@..."), (user_id: 1, type_id: 2, value: '+7...')...
И соотв типы полей созданы в SQL.UserFieldTypes, в этом примере это (id: 1, name: 'mail', type: 'str'), (id: 2, name: 'phone', type: 'str')

И я хочу чтобы я через api.get Users получил объект сразу со всеми его доп полями подтянутыми из SQL.UserField.
В обратную сторону post User соотв тоже.

Для этого у меня есть отдельная View модель, которая дополняется опциональными полями при создании UserFieldType

Flotsky ★★
() автор топика

Не знаю как у вас в пидантике (тьфу, блин, и тут пид…) а у нас в датаклассах это просто делалось так:

@dataclass
class Phone:
    record_id: str
    kind: str
    phone: str

@dataclass
class User:
    id: str
    phones: List[Phone] = field(default_factory=list)


и далее

@router.get("/users")
def list_users() -> List[User]:
   ...

@router.post("/users")
def create_users(user_info: User) -> User:
   ...
no-dashi-v2 ★★
()
Ответ на: комментарий от vvn_black

И я хочу чтобы я через api.get Users получил объект сразу со всеми его доп полями подтянутыми из SQL.UserField. В обратную сторону post User соотв тоже.

Я всё это бегло изучил и придумал примерно за неделю.

Мне кажется, что за один раз получить «полного» пользователя проще и быстрее чем несколько запросов вида «дай пользователя», «дай доп поля пользователя N», (возможно ещё «дай все возможные доп поля пользователей»)

Я ошибаюсь? (открыт для советов как это делается)

Flotsky ★★
() автор топика
Ответ на: комментарий от masa

sqlite тоже умеет в json, использую в других таблицах.

Не понимаю что меняется с переходом на json кроме кучи неудобств.

Сейчас у меня есть UserFieldTypes, который перечисляет какие поля (имя/тип можно) и UserFields, который их хранит.
Я могу через связи и относительно простые запросы к БД получать инфо («у кого номер задан или имеет конкретный вид» к примеру)

С json это будет перебор всех пользователей, думаю это сильно дольше и муторней.

Да и что я меняю, мне всёравно надо где-то хранить динамическую структуру для валидации полей, а то будет у меня некий Вася с полем «впишите сюда что-то» и ни кто ему не скажет, что же там должно быть на самом деле.

Flotsky ★★
() автор топика
Ответ на: комментарий от Flotsky

Чтобы отразить возможные поля которые можно передать fastapi можно завести Enum с перечислением этих названий кастомных полей и указать как возможный вариант Users.name, оно прорастет в openapi.json и сваггер

masa
()
Ответ на: комментарий от Flotsky

В целом написать такой запрос это вместо

select ... where num is not null
будет
select ... where JSON_QUERY(extra_field, '$.num,') is not null
Если нужны индексы, постгрес умеет их строить и по функциям и по джейсон полям, про sqlile - не знаю, но видимо у тебя там меньше милиарда строк, так что не важно, все в памяти сидит.

Если же данных миллиарды и поле нужно для быстрых джоинов по индексу в большинстве запросов, то его надо явно писать в отдельную колонку в таблице

masa
()
Последнее исправление: masa (всего исправлений: 1)
Ответ на: комментарий от Flotsky

Ну и ты спросил как делают в других местах, у нас продакшен так построен, дешево и удобно. В общем-то идея в том что динамические колонки в схеме базы данных/апи это зло и надо их избегать, а для всего экспериментального временного «говна» есть json, что послали то и отдали

masa
()
Последнее исправление: masa (всего исправлений: 1)
Ответ на: комментарий от masa

Должен признать это имеет смысл и возможно выглядит сильно проще. Json валидация по крайне мере гуглится в много примеров по сравнению с динамическими моделями.

Наверное мне надо пожить с этой идеей т.к. сейчас я предвзят над своим решением

Flotsky ★★
() автор топика
Ответ на: комментарий от vvn_black

первая ссылка вроде норм решение будет, просто схему кэшировать и при необходимости обновлять хуками из модели. Как альтернативный вариант, возможно есть решения с библиотеками под GraphQL (например, strawberry-graphql), или даже Apollo, Hasura. Хотя всё это немного пахнет, ибо обычно всё под фиксированный протокол.

ac130kz ★★
()
Ответ на: комментарий от ac130kz

Как альтернативный вариант, возможно есть решения с библиотеками под GraphQL

Так это даже не альтернативный вариант, а вполне даже хороший, рабочий, практически no code. А вот брать FastAPI и пытаться с ним сделать такое - кмк, попахивает неуёмной энергией, кучей свободного времени и при этом экономии на планировании архитектуры.

vvn_black ★★★★★
()
Ответ на: комментарий от ac130kz

А чем вся эта радость лучше реализации на стороне БД в процедурах так, чтобы внешние пользователи на уровне Питона даже и не знали бы, как оно там устроено?

  • 1. Можно менять БД, если считать БД тупой хранилкой таблиц - ок
  • 2. Что-то про автодокументацию - ок
  • 3. ???
Toxo2 ★★★★
()
Ответ на: комментарий от Toxo2

На стороне бд всегда можно сделать, да не всю логику удобно/понятно/читабельно/возможно занести. Если оно будет без сильно подвязанной под питон бизнес логики, то тоже вариант. Вот правда что-то не припомню гибких документ ориентированных баз с поддержкой процедур.

ac130kz ★★
()
Ответ на: комментарий от masa

И действительно. Начал пробовать и понял, что мне достаточно просто на уровне class User(BaseModel): вместо всех def add_field... просто добавить поле extra_fields: Json и валидатор поверх.

Тогда я смогу получить что хочется и разобрать на внутренности как изначально хотелось.

Про структуру SQL ещё подумаю, попробую оба варианта и оставлю наиболее простой для себя

Спасибо, направили в нужную сторону

Flotsky ★★
() автор топика
Ответ на: комментарий от ac130kz

Думаю не готов сейчас вкатываться в неизвестное для себя. Иначе доделаю своё ПО я когда нибудь никогда.

Оставлю себе заметки на почитать после

Flotsky ★★
() автор топика
Ответ на: комментарий от Aswed

Уже не делаю.

Для человека вот только нагуглившего по примерам как и что делается это не выглядит чем-то неправильным.
Вот у меня есть пользователь, вот у него есть доп поля, список которых кто-то может захотеть на лету изменить. Должна же быть модель данных соответствующая этой структуре. Вот я и пришёл к к попыткам на лету менять модель.

Сейчас остановился на json поле+валидатор вместо попыток править модель и всё, что с ней связано. Понравилось. Сразу не додумался т.к. не знал об альтернативах и закопался в попытки править модель, бывает.

Flotsky ★★
() автор топика