Неожиданные открытия в TypeORM 0.3.11

Неожиданные открытия в TypeORM 0.3.11

25 января 2023 г.

TypeORM — это инструмент объектно-реляционного сопоставления (ORM), предназначенный для работы с TypeScript. Он позволяет разработчикам взаимодействовать с базами данных с использованием объектно-ориентированного синтаксиса и поддерживает широкий спектр популярных баз данных, включая MySQL, PostgreSQL и SQLite. Но, скорее всего, вы это уже знаете. Так что смысл этой статьи не в том, чтобы объяснить, что такое TypeORM, а в том, чтобы показать некоторые неожиданные открытия, которые я сделал, работая с ним.

Обновление TypeORM до версии 0.3.11

Недавно я работал над проектом, использующим TypeORM. Проект сильно устарел, а версия TypeORM была 0.2.*. Решил обновить до последней версии 0.3.11. Я ожидал найти некоторые критические изменения, и они действительно их внесли.

Что мне понравилось в новой версии

Скажем несколько слов о несовместимых изменениях, которые мне понравились. Прежде всего, .findOne() из findOneBy() больше не будет возвращать undefined. Вместо этого эти методы будут возвращать null. Это хорошее изменение, null — это значение, а результат null означает, что мы что-то сделали, но получили пустой результат. undefined, с другой стороны, является типом, и это означает, что мы ничего не делали. Так что это хорошее изменение.

Давайте посмотрим на пример:

import {Entity, Column, PrimaryGeneratedColumn} from "typeorm";

@Entity()
export class User {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @Column()
    email: string;
}

Старое поведение:

const user = await User.findOne({name: "the name we don't have in the database"});

console.log(user); // undefined

Текущее поведение:

const user = await User.findOne({name: "the name we don't have in the database"});

console.log(user); // null

Что меня устраивает в новой версии

Далее изменения в API связаны с методом .find(). Старая версия была более гибкой и позволяла передавать только параметры where или весь объект FindOptions. Новая версия требует передачи всего объекта FindOptions. Новая версия является строгой, и вы всегда должны передавать объект FindOptions целиком.

Старое поведение:

const users = await User.find({
    name: "Alice"
})

Новый:

const users = await User.find({
    where: {name: "Alice"}
})

К счастью для нас, в новой версии есть новый метод с именем .findMy(), который принимает только параметры where.

const users = await User.findBy({
    name: "Alice"
})

Что мне не нравится в новой версии

И последнее, но не менее важное: изменения в обработке значений null и undefined параметров where. Это стало для меня неожиданностью, так как я не смог найти никакой информации об этом изменении в документации и журнале изменений. И на самом деле я наткнулся на это изменение только на стадии тестирования. В целом, приложение, над которым я работал, имеет впечатляющее тестовое покрытие (более 90%), а REST API имеет довольно хороший уровень проверки. Поэтому отправить параметр null или undefined в API непросто. Итак, позвольте мне перейти к самой проблеме.

Давайте представим, что в некоторых местах у нас нет надлежащей проверки параметров запроса, и мы можем отправить в API null или undefined.

Давайте посмотрим на пример:

function findUserByEmail(email: string): Promise<User | null> {
    return User.findOneBy({
        email: email
    });
}

В идеальном мире Typescript эта функция выглядит просто отлично, и невозможно напрямую передать null или undefined в коде — это нарушит компиляцию. Но в конце концов он будет скомпилирован в JavaScript, и итоговый код будет выглядеть примерно так:

function findUserByEmail(email) {
    return User.findOneBy({
        email: email
    });
}

Таким образом, можно передать null или undefined в функцию findUserByEmail.

А теперь давайте посмотрим на сгенерированные SQL-запросы для следующих вызовов функций.

Для действительного параметра электронной почты обе версии TypeORM будут генерировать один и тот же запрос:

select *
from
    user
where
    email = :email limit 1

Я упрощаю запрос, но суть в том, что запрос тот же.

А вот для параметров null и undefined запрос в новой версии будет другим:

select *
from
    user limit 1

Как видите, этот запрос вернет первого пользователя из базы данных. А это не то, чего мы хотим.

Причина этого в том, что разработчики TypeORM решили пропустить предложение where, если параметр where имеет значение null или не определено. В предыдущей версии такие параметры вызывали ошибку, и запрос не выполнялся. Но в новой версии все будет пропущено, и запрос будет выполняться без предложения where. С этим связана проблема на Github, но, похоже, она не будет исправлена ​​в ближайшее время. https://github.com/typeorm/typeorm/issues/9316

Поэтому вместо того, чтобы ждать исправления, я решил написать небольшой класс-оболочку, чтобы обеспечить дополнительный уровень проверки параметров where. Класс-оболочка проверит, является ли параметр where null или undefined, и выдаст ошибку.

import {BaseEntity, FindOneOptions} from 'typeorm'
import {FindManyOptions} from 'typeorm/find-options/FindManyOptions'
import {FindOptionsWhere} from 'typeorm/find-options/FindOptionsWhere'

interface Constructable {
    new(...args: any[]): any
}

function paramsCheckingMixin<T extends Constructable>(base: T): T {
    return class extends base {
        /**
         * Counts entities that match given options.
         */
        static async count<T extends BaseEntity>(
            this: { new(): T } & typeof BaseEntity,
            options?: FindManyOptions<T>
        ): Promise<number> {
            await validateWhereOptions(options?.where)

            return this.getRepository<T>().count(options as FindManyOptions<BaseEntity>)
        }

        /**
         * Counts entities that match given WHERE conditions.
         */
        static async countBy<T extends BaseEntity>(
            this: { new(): T } & typeof BaseEntity,
            where: FindOptionsWhere<T>
        ): Promise<number> {
            await validateWhereOptions(where)

            return this.getRepository<T>().countBy(where as FindOptionsWhere<BaseEntity>)
        }

        /**
         * Finds entities that match given options.
         */
        static async find<T extends AppBaseEntity>(
            this: { new(): T } & typeof AppBaseEntity,
            options?: FindManyOptions<T>
        ): Promise<T[]> {
            await validateWhereOptions(options?.where)

            return this.getRepository<T>().find(options)
        }

        /**
         * Finds entities that match given WHERE conditions.
         */
        static async findBy<T extends AppBaseEntity>(
            this: { new(): T } & typeof AppBaseEntity,
            where: FindOptionsWhere<T>
        ): Promise<T[]> {
            await validateWhereOptions(where)

            return this.getRepository<T>().findBy(where)
        }

        /**
         * Finds entities that match given find options.
         * Also counts all entities that match given conditions,
         * but ignores pagination settings (from and take options).
         */
        static async findAndCount<T extends AppBaseEntity>(
            this: { new(): T } & typeof AppBaseEntity,
            options?: FindManyOptions<T>
        ): Promise<[T[], number]> {
            await validateWhereOptions(options?.where)

            return this.getRepository<T>().findAndCount(options)
        }

        /**
         * Finds entities that match given WHERE conditions.
         * Also counts all entities that match given conditions,
         * but ignores pagination settings (from and take options).
         */
        static async findAndCountBy<T extends AppBaseEntity>(
            this: { new(): T } & typeof AppBaseEntity,
            where: FindOptionsWhere<T>
        ): Promise<[T[], number]> {
            await validateWhereOptions(where)

            return this.getRepository<T>().findAndCountBy(where)
        }

        /**
         * Finds first entity that matches given conditions.
         */
        static async findOne<T extends AppBaseEntity>(
            this: { new(): T } & typeof AppBaseEntity,
            options: FindOneOptions<T>
        ): Promise<T | null> {
            await validateWhereOptions(options?.where)

            return this.getRepository<T>().findOne(options)
        }

        /**
         * Finds first entity that matches given conditions.
         */
        static async findOneBy<T extends AppBaseEntity>(
            this: { new(): T } & typeof AppBaseEntity,
            where: FindOptionsWhere<T>
        ): Promise<T | null> {
            await validateWhereOptions(where)

            return this.getRepository<T>().findOneBy(where)
        }

        /**
         * Finds first entity that matches given conditions.
         */
        static async findOneOrFail<T extends AppBaseEntity>(
            this: { new(): T } & typeof AppBaseEntity,
            options: FindOneOptions<T>
        ): Promise<T> {
            await validateWhereOptions(options?.where)

            return this.getRepository<T>().findOneOrFail(options)
        }

        /**
         * Finds first entity that matches given conditions.
         */
        static async findOneByOrFail<T extends AppBaseEntity>(
            this: { new(): T } & typeof AppBaseEntity,
            where: FindOptionsWhere<T>
        ): Promise<T> {
            await validateWhereOptions(where)

            return this.getRepository<T>().findOneByOrFail(where)
        }
    }
}

export abstract class AppBaseEntity extends paramsCheckingMixin(BaseEntity) {
}

function validateWhereOptions(where?: FindOptionsWhere<BaseEntity>[] | FindOptionsWhere<BaseEntity>): Promise<void> {
    if (!where) {
        return Promise.resolve()
    }

    if (!Array.isArray(where)) {
        where = [where]
    }
    const errors: string[] = []
    where.forEach((findOptionsWhere) => {
        for (const key in findOptionsWhere) {
            if (findOptionsWhere[key] === null || findOptionsWhere[key] === undefined) {
                errors.push(`Invalid value of where parameter ${key}`)
            }
        }
    })

    if (errors.length) {
        return Promise.reject(errors.join('. '))
    }

    return Promise.resolve()
}

Класс-оболочка используется в качестве базового класса для всех сущностей.

Заключение

Я считаю, что безопасность — очень важная часть любого приложения, поскольку мы все заботимся о наших пользователях и их данных. К сожалению, его часто упускают из виду, и разработчики не уделяют ему должного внимания. Я надеюсь, что эта статья поможет вам улучшить безопасность вашего приложения на уровне базы данных и поможет избежать некоторых распространенных ошибок.


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE