Как сделать вложенную сортировку в postgresql

У меня есть столбцы id (строка), parentId (строка или ноль) и order (число). Это древовидная структура, в которой строки с одинаковыми parentId являются дочерними элементами одного и того же узла. order используется для сортировки детей для каждого родителя (чтобы они могли быть одинаковыми для детей от разных родителей).

Для данного id я хочу извлечь предыдущую и следующую строку. Мне нужно как-то отсортировать данные, сначала parentId посмотрев на их order, а затем вытащив в середине таблицы их дочерние элементы, снова отсортировав их по order и еще раз (рекурсия). Вот как мои данные выглядят после выполнения запроса:

SELECT "content"."id", "content"."parentId", "content"."order" FROM "course_content_entity" "content" WHERE ( "content"."deletedAt" IS NULL ) AND ("content"."courseId" = '05dd28d5-a244-4ca1-b7fb-fc3bc7b2e422') order by "content"."parentId", "content"."order" 

введите сюда описание изображения

Конечно, сейчас это то, чего я хочу. Есть идеи, как это сделать?

РЕДАКТИРОВАТЬ

Просто чтобы объяснить, зачем мне это нужно: для данного дочернего элемента в дереве (которое представляет курс) мне нужно добавить навигацию для перехода к следующей и предыдущей статье, у которых могут быть разные родители.

Благодаря запросу @Islingre мне удалось отредактировать его в соответствии со структурой моей базы данных, и я получил следующий запрос и результаты:

// query
WITH RECURSIVE node(id, "parentId", "order", "name", position_array) AS (
    SELECT
        root.id,
        root."parentId",
        root.order,
        root.name,
        ARRAY[ROW_NUMBER() OVER (ORDER BY root.order, root.id)] AS position_array
    FROM course_content_entity root
    WHERE root."parentId" IS NULL AND root."courseId" = '18f75c26-3608-49c2-8e1c-56618b35c780'
UNION ALL
    SELECT
        child.id,
        child."parentId",
        child.order,
        child.name,
        parent.position_array || ROW_NUMBER() OVER (PARTITION BY child."parentId" ORDER BY child.order) AS position_array
    FROM course_content_entity child
    INNER JOIN node parent  ON parent.id::uuid = child."parentId"::uuid
    WHERE child."courseId" = '18f75c26-3608-49c2-8e1c-56618b35c780'

  --parent.id IS NOT DISTINCT FROM child."parentId" 
)
SELECT
    n.id,
    n."parentId",
    n.order,
    n.name,
    n.position_array,
    ROW_NUMBER() OVER (ORDER BY n.position_array) AS dfs_position
FROM node n
-- WHERE n.id = '7fcebdc8-9dd4-43e3-afd4-641695d8f769'
ORDER BY dfs_position;

Результат: Результат запроса

Я добавил к запросу дважды: root."courseId" = '18f75c26-3608-49c2-8e1c-56618b35c780' чтобы получить только контент данного курса. Итак, это работает, и теперь мне нужно получить строку до и после определенного узла. Поскольку я работаю в typeorm, было бы еще лучше написать этот запрос с использованием этой библиотеки и ее queryBuilder.

Это моя сущность:

@Entity()
export class CourseEntity {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @DeleteDateColumn()
  deletedAt: Date;

  @Column()
  name: string;

  @Column({ unique: true, default: null })
  slug: string;

  @Column({ default: null })
  description: string;

  @ManyToOne(() => UserEntity, (user) => user.courses)
  author!: UserEntity;

  @OneToMany(() => CourseContentEntity, (content) => content.course, { cascade: ['soft-remove'] })
  content: Array<CourseContentEntity>;


  constructor(partial: Partial<CourseEntity>) {
    Object.assign(this, partial);
  }
}



@Entity()
export class CourseContentEntity {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @DeleteDateColumn()
  deletedAt: Date;

  @Column()
  name: string;

  @Column({ default: '' })
  slug: string;

  @Column({ nullable: true })
  parentId: string;

  @Column({ default: 0 })
  order: number;

  @Column({ default: '' })
  content: string;

  constructor(partial: Partial<CourseContentEntity>) {
    Object.assign(this, partial);
  }
}

Вот как я выполняю поиск следующего и предыдущего узла:


  async getPagination(contentId: string) {
    const content = await this._courseContentRepository.findOne({
      where: { id: contentId },
      relations: { course: true },
    });
    const query = this._courseContentRepository
      .createQueryBuilder('content')
      .leftJoin('content.course', 'course')
      .andWhere('course.id = :courseId', { courseId: content.course.id });

    /* NEXT */
    const getNext = async (content: CourseContentEntity) => {
      const nextQuery = query.clone();
      if (content.parentId) {
        nextQuery.where('content.parentId = :parentId', { parentId: content.parentId });
      } else {
        nextQuery.where('content.parentId IS NULL');
      }
      return await nextQuery
        .andWhere('content.order > :order', { order: content.order })
        .orderBy('content.order', 'ASC')
        .getOne();
    };

    const firstChild = await this._courseContentRepository.findOne({
      where: { parentId: content.id },
    });
    let next = firstChild;

    // Try to get next sibling
    if (!next) next = await getNext(content);
    if (!next && content.parentId) {
      // It's a last child node -> take node after parent node
      const parent = await this._courseContentRepository.findOne({
        where: { id: content.parentId },
      });

      next = await getNext(parent);
    }

    /* PREV */
    const getPrev = async (parentId: string | null, order: number) => {
      const prevQuery = query.clone();
      if (parentId) {
        prevQuery.where('content.parentId = :parentId', { parentId: parentId });
      } else {
        prevQuery.where('content.parentId IS NULL');
      }
      return await prevQuery.andWhere('content.order < :order', { order }).orderBy('content.order', 'DESC').getOne();
    };

    let prev = await getPrev(content.parentId, content.order);
    if (prev) {
      const lastChild = await query
        .andWhere('content.parentId = :parentId', { parentId: prev.id })
        .orderBy('content.order', 'DESC')
        .getOne();
      if (lastChild) prev = lastChild;
    } else {
      if (content.parentId) {
        const parent = await this._courseContentRepository.findOne({
          where: { id: content.parentId },
        });
        prev = parent;
      } else {
        prev = await getPrev(null, content.order);
        if (prev) {
          const lastChild = await query
            .andWhere('content.parentId = :parentId', { parentId: prev.id })
            .orderBy('content.order', 'DESC')
            .getOne();
          if (lastChild) prev = lastChild;
        }
      }
    }

    // const nextQuery = query
    //   .select('LEAD(content.id) over (order by content.order asc) as next_id')
    //   .addSelect('LEAD(content.name) over (order by content.order asc) as next_name')
    //   .where('content.parentId = :parentId', { parentId: content.parentId })
    //   .andWhere('content.id = :contentId', { contentId: contentId });

    return {
      next: next
        ? {
            id: next.id,
            name: next.name,
          }
        : undefined,
      prev: prev
        ? {
            id: prev.id,
            name: prev.name,
          }
        : undefined,
    };
  }

Мне интересно, какой раствор в итоге окажется лучше (мой текущий или сортировка дерева).

Вы ищете ответ PostgreSQL или ответ TypeORM? Поскольку оба отмечены в этом вопросе... Судя по предоставленному SQL-запросу, я предполагаю, что вы ищете ответ PostgreSQL, это правильно? Пожалуйста, также включите изображения напрямую или, что еще лучше: вставьте данные примера в вопрос, а не скрывайте их за неописательными ссылками. Пожалуйста, обновите (то есть отредактируйте) вопрос и включите недостающую информацию.

Islingre 24.04.2024 00:38
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
1
153
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ответ принят как подходящий

Чтобы отсортировать строки в порядке DFS дерева, вы можете попробовать следующий запрос:

WITH RECURSIVE node(id, parent_id, position, position_array) AS (
    SELECT
        root.id,
        root.parent_id,
        root.position,
        ARRAY[ROW_NUMBER() OVER (ORDER BY root.position, root.id)] AS position_array
    FROM forest_node root
    WHERE root.parent_id IS NULL
UNION ALL
    SELECT
        child.id,
        child.parent_id,
        child.position,
        parent.position_array || ROW_NUMBER() OVER (PARTITION BY child.parent_id ORDER BY child.position) AS position_array
    FROM forest_node child
    INNER JOIN node parent ON parent.id = child.parent_id
)
SELECT
    n.id,
    n.parent_id,
    n.position,
    n.position_array,
    ROW_NUMBER() OVER (ORDER BY n.position_array) AS dfs_position
FROM node n
ORDER BY dfs_position;

Этот запрос начинается с корней (parent_id IS NULL) и рекурсивно (С РЕКУРСИВОЙ ) добавляет их дочерние элементы, создавая position_array с помощью оконной функции ROW_NUMBER() OVER () ( Учебное пособие по оконным функциям , Список оконных функций ). Сортировка, наконец, осуществляется путем упорядочивания по position_array на основе сравнения массивов ( Функции и операторы массивов).

Переименование forest_node в "course_content_entity" и id, parent_id и position в "id", "parentId" и "order" должно сделать это применимым к вашему случаю. Я выбрал разные имена по нескольким причинам, включая лучший общий пример и желание избежать идентификаторов в кавычках.

Предположения:

  • Корневой узел может иметь или не иметь набор position. У некорневых узлов всегда установлена ​​позиция.
  • В таблице может быть несколько корневых узлов (именно поэтому я использовал forest_node вместо tree_node в качестве имени таблицы). Мы заказываем их по position и разрываем связи по id. Все узлы одного дерева появляются рядом друг с другом в итоговом списке.

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

Последнее замечание: если вы работаете с большой таблицей, возможно, было бы гораздо эффективнее написать функцию, которая просто обходит дерево узел за узлом, чтобы найти «соседей DFS». Предоставленный запрос всегда будет работать со всеми строками таблицы, которые могут стать узким местом.

Используется в целях тестирования:

CREATE TABLE forest_node (
    id text NOT NULL,
    parent_id text,
    position integer CHECK(position IS NOT NULL OR parent_id IS NULL),
    PRIMARY KEY(id),
    UNIQUE(parent_id, position)
);

INSERT INTO forest_node (id, parent_id, position)
VALUES
    ('root1', NULL, NULL),
    ('root2', NULL, NULL),
    ('root3', NULL, NULL),
    ('root1.1', 'root1', 1),
    ('root1.2', 'root1', 2),
    ('root3.2', 'root3', 2),
    ('root3.1', 'root3', 1),
    ('root2.1', 'root2', 1),
    ('root2.2', 'root2', 2),
    ('root2.1.1', 'root2.1', 1),
    ('root2.1.2', 'root2.1', 2),
    ('root2.4', 'root2', 4),
    ('root2.3', 'root2', 3);

Потрясающий! Я скорее ищу ответ в виде типоформы, но я мог бы выполнить и этот необработанный запрос. Однако я столкнулся с проблемой с «родительским узлом INNER JOIN ON родительский.id = child.parent_id», где ошибка: No operator matches the given name and argument types. You might need to add explicit type casts. Мой id имеет тип UUID, где parentId может быть нулевым. Как это исправить?

Korer 24.04.2024 15:20

Правильно ли я понимаю, что столбец id имеет тип uuid, а столбец parentId имеет тип text (или varchar или что-то подобное)? Было бы лучше, если бы они имели одинаковый тип данных (лучше всего оба uuid) и если бы было установлено ограничение внешнего ключа (@ManyToOne в TypeORM). Чтобы настроить приведенный выше запрос, просто добавьте преобразование id: INNER JOIN node parent ON parent.id::text = child.parent_id. Для больших таблиц это может снова оказать заметное влияние, но я надеюсь, что это сработает и для вас.

Islingre 24.04.2024 22:00

Что касается ответа TypeORM... не уверен. По крайней мере, нужно будет посмотреть, как определена сущность. Если вы хотите, чтобы я проверил это и, возможно, попробовал, отредактируйте вопрос и добавьте определение объекта.

Islingre 24.04.2024 22:55

Извините за поздний ответ – я отредактировал свой вопрос и добавил гораздо больше контекста и информации. Я действительно ценю твою помощь!

Korer 25.04.2024 23:47

Для подхода TypeORM к этой проблеме я вижу следующие возможности:

  • жестко запрограммировать подготовленный оператор (параметризованный SQL-запрос)
  • пройти по дереву, выбирая узлы один за другим
  • используйте QueryBuilder.addCommonTableExpression() и заставьте его работать с WITH RECURSIVE (не уверен, что это возможно... возможно, нет, по крайней мере, не через QueryBuilder, а только с помощью жестко запрограммированного SQL, я полагаю)

Я бы предложил пройти по дереву и ниже приведу для этого некоторый обобщенный код.

Сущность:

import {
    Check,
    Column,
    Entity,
    Index,
    JoinColumn,
    ManyToOne,
    OneToMany,
    PrimaryGeneratedColumn,
} from "typeorm";

@Entity()
@Check("parent_id IS NULL OR position IS NOT NULL")
@Index(["parent", "position"], { unique: true })
@Index(["name"], { unique: true })
export class ForestNode {
    @PrimaryGeneratedColumn("uuid")
    id?: string; // ? because id will only get assigned on save, not already in constructor

    @Column()
    name: string; // added property to easily identify nodes

    @ManyToOne(() => ForestNode, (parent) => parent.children, { nullable: true })
    @JoinColumn()
    parent?: ForestNode | null; // ? because relation might not be loaded when record is fetched from DB

    @Column({ nullable: true, type: "int" })
    position: number | null;

    constructor(
        name: string,
        parent: ForestNode | null,
        position: number | null,
    ) {
        this.name = name;
        this.parent = parent;
        this.position = position;
    }

    @OneToMany(() => ForestNode, (child) => child.parent)
    children?: ForestNode[]; // ? because relation might not be loaded when record is fetched from DB - and no value assigned in constructor
}

Результаты в такой таблице БД (с использованием SnakeNamingStrategy из typeorm-naming-strategies):

                        Table "public.forest_node"
  Column   |       Type        | Collation | Nullable |      Default
-----------+-------------------+-----------+----------+--------------------
 id        | uuid              |           | not null | uuid_generate_v4()
 name      | character varying |           | not null |
 position  | integer           |           |          |
 parent_id | uuid              |           |          |
Indexes:
    "PK_3249a860f2197601caa2e20f3a9" PRIMARY KEY, btree (id)
    "IDX_b809a8da806808ab77c47a0e95" UNIQUE, btree (name)
    "IDX_c6597116d3f149e5889c912e8d" UNIQUE, btree (parent_id, "position")
Check constraints:
    "CHK_e53d2fb9afed95a164b1b04a3b" CHECK (parent_id IS NULL OR "position" IS NOT NULL)
Foreign-key constraints:
    "FK_db6b2f9beaeb26ff7a41a16f220" FOREIGN KEY (parent_id) REFERENCES forest_node(id)
Referenced by:
    TABLE "forest_node" CONSTRAINT "FK_db6b2f9beaeb26ff7a41a16f220" FOREIGN KEY (parent_id) REFERENCES forest_node(id)

Пример того, как пройти по дереву, чтобы найти предшественника DFS:

async function getDfsPredecessorNode(
    forestNodeRepo: Repository<ForestNode>,
    node: ForestNode,
) {
    const { parent, position } = node;

    if (parent === undefined) {
        throw new Error("parent relation not loaded");
    }
    if (parent === null) {
        return null; // root has no predecessor
    }

    if (position === null) {
        throw new Error("non-root cannot have null position");
    }

    const predecessorSibling = await forestNodeRepo.findOne({
        where: {
            parent,
            position: LessThan(position),
        },
        order: { position: "DESC" },
        relations: { parent: true },
    });

    if (!predecessorSibling) {
        return parent;
    }

    // we want now to find the last node in the subtree rooted in predecessorSibling
    let subtreeRoot = predecessorSibling;

    while (true) {
        const lastChild = await forestNodeRepo.findOne({
            where: {
                parent: subtreeRoot,
            },
            order: { position: "DESC" },
        });

        if (!lastChild) {
            return subtreeRoot;
        }

        subtreeRoot = lastChild;
    }
}

Чтобы найти преемника DFS, вы можете реализовать аналогичный подход (если у узла есть дочерние элементы, верните первого дочернего элемента; в противном случае начните с текущего узла и проверьте, есть ли у него брат-преемник, если да, верните его, иначе повторите этот шаг с родительский узел в качестве текущего узла — если родительского узла больше нет, верните ноль).

Другие вопросы по теме