You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用Knex与Objection.js分页时关联图片丢失数据的问题

分页关联多图后数据丢失的解决方案

问题背景

分页功能在未关联图片时正常工作,但关联images多对多关系后,会丢失Post数据行:

  • 每个Post仅关联1张图片时无异常
  • 取消分页可正常获取所有带图片的Post
  • 补充测试:新增关联7张图片的Post后,数据库共9条Post数据,但page=1、itemsPerPage=8的分页仅返回1条;添加两个单图Post后,返回3条(含7图Post和两个新Post)

问题原因

原代码中使用withGraphJoined("images")进行关联查询,这会生成笛卡尔积:一个关联了N张图片的Post会在SQL结果中返回N行数据。此时limit和offset是作用在这些行上,而非Post实体的数量,导致分页逻辑失效——比如7张图的Post占了7行,limit=8只能再容纳1行(对应另一个Post的1张图),但Objection.js合并相同Post后,实际返回的Post数量远小于预期。

解决方案

先分页查询Post主数据,再批量关联查询对应的图片(避免笛卡尔积影响分页),以下是两种实现方式:

优化方案1:批量查询减少N+1请求

export async function getAll(req, res, next) {
  const { page, itemsPerPage } = req.params;
  const pageNum = parseInt(page, 10);
  const perPage = parseInt(itemsPerPage, 10);

  try {
    // 1. 分页获取当前页的Post主数据
    const paginatedCreations = await Creation.query()
      .limit(perPage)
      .offset((pageNum - 1) * perPage)
      .orderBy("pos", "desc");

    if (paginatedCreations.length === 0) {
      return res.status(200).json([]);
    }

    // 2. 批量查询当前页所有Post对应的图片
    const creationIds = paginatedCreations.map(c => c.id);
    const allImages = await CreationImage.query()
      .join("creations_images_pivot", "creations_images.id", "creations_images_pivot.image_id")
      .whereIn("creations_images_pivot.creation_id", creationIds)
      .select("creations_images.*", "creations_images_pivot.creation_id");

    // 3. 将图片映射到对应Post并处理封面URL
    const creationsWithImages = paginatedCreations.map(creation => {
      const images = allImages.filter(img => img.creation_id === creation.id);
      images.forEach(image => {
        const path = `assets/creations/${image.name}`;
        if (image.cover) {
          creation.coverUrl = `${req.protocol}://${req.get("host")}/${path}`;
        }
      });
      return { ...creation, images };
    });

    return res.status(200).json(creationsWithImages);
  } catch (error) {
    return res.status(400).json({ error: error.message });
  }
}

优化方案2:使用Objection内置关联(代码更简洁)

如果不介意N+1查询,可直接用withGraphFetched批量关联:

export async function getAll(req, res, next) {
  const { page, itemsPerPage } = req.params;
  const pageNum = parseInt(page, 10);
  const perPage = parseInt(itemsPerPage, 10);

  try {
    // 1. 分页获取Post主数据
    const paginatedCreations = await Creation.query()
      .limit(perPage)
      .offset((pageNum - 1) * perPage)
      .orderBy("pos", "desc");

    // 2. 批量关联图片
    const creationsWithImages = await Promise.all(
      paginatedCreations.map(creation => 
        creation.$query().withGraphFetched("images")
      )
    );

    // 3. 处理封面URL
    creationsWithImages.forEach(creation => {
      creation.images.forEach(image => {
        const path = `assets/creations/${image.name}`;
        if (image.cover) {
          creation.coverUrl = `${req.protocol}://${req.get("host")}/${path}`;
        }
      });
    });

    return res.status(200).json(creationsWithImages);
  } catch (error) {
    return res.status(400).json({ error: error.message });
  }
}

相关代码参考

原问题代码(getAll函数)

export function getAll(req, res, next) {
  const [page, itemsPerPage] = [req.params.page, req.params.itemsPerPage];

  console.log(req.params);

  Creation.query()
    .withGraphJoined("images")
    .limit(itemsPerPage)
    .offset((page - 1) * itemsPerPage)
    .orderBy("pos")
    .then((creations) => {
      creations.forEach((creation) => {
        creation.images.forEach((image) => {
          let path = `assets/creations/${image.name}`;
          image.cover === 1
            ? (creation.coverUrl = `${req.protocol}://${req.get(
                "host"
              )}/${path}`)
            : "";
        });
      });
      return res.status(200).json(creations);
    })
    .catch((error) => {
      return res.status(400).json({
        error: error,
      });
    });
}

Post表迁移

return knex.schema.hasTable("creations").then(function (exists) {
  if (!exists) {
    return knex.schema.createTable("creations", function (table) {
      table.increments("id").primary();
      table.string("title", 50).notNullable();
      table.text("desc");
      table.decimal("pos", 8, 0);
      table
        .integer("user_id", 10)
        .unsigned()
        .notNullable()
        .references("id")
        .inTable("users");
      table.timestamps();
    });
  }
});

Post Images表迁移

return knex.schema.hasTable("creations_images").then(function (exists) {
  if (!exists) {
    return knex.schema.createTable("creations_images", function (table) {
      table.increments("id").primary();
      table.string("name", 100).unique().notNullable();
      table.boolean("cover").defaultTo(false);
      table.timestamps();
    });
  }
});

多对多中间表迁移

return knex.schema.hasTable("creations_images_pivot").then(function (exists) {
  if (!exists) {
    return knex.schema.createTable(
      "creations_images_pivot",
      function (table) {
        table
          .integer("image_id", 10)
          .unsigned()
          .notNullable()
          .references("id")
          .inTable("creations_images");
        table
          .integer("creation_id", 10)
          .unsigned()
          .notNullable()
          .references("id")
          .inTable("creations");
      }
    );
  }
});

Post模型

class Creation extends Model {
  static get tableName() {
    return "creations";
  }

  static get relationMappings() {
    return {
      author: {
        relation: Model.BelongsToOneRelation,
        modelClass: User,
        join: {
          from: "creations.user_id",
          to: "users.id",
        },
      },

      filters: {
        relation: Model.ManyToManyRelation,
        modelClass: CreationFilter,
        join: {
          from: "creations.id",
          through: {
            from: "creations_filters_pivot.creation_id",
            to: "creations_filters_pivot.filter_id",
          },
          to: "creations_filters.id",
        },
      },

      images: {
        relation: Model.ManyToManyRelation,
        modelClass: CreationImage,
        join: {
          from: "creations.id",
          through: {
            from: "creations_images_pivot.creation_id",
            to: "creations_images_pivot.image_id",
          },
          to: "creations_images.id",
        },
      },
    };
  }

  static get jsonSchema() {
    return {
      type: "object",
      required: ["title", "user_id"],

      properties: {
        id: { type: "integer" },
        title: { type: "string", maxLength: 50 },
        desc: { type: "string" },
        pos: { type: "number" },
        user_id: { type: "integer" },
        created_at: {
          type: "string",
          format: "date-time",
          default: new Date().toISOString(),
        },
        updated_at: {
          type: "string",
          format: "date-time",
          default: new Date().toISOString(),
        },
      },
    };
  }
}

Post Images模型

class CreationImage extends uniqueParams(Model) {
  static get tableName() {
    return "creations_images";
  }

  static get relationMappings() {
    return {
      creations: {
        relation: Model.ManyToManyRelation,
        modelClass: Creation,
        join: {
          from: "creations_images.id",
          through: {
            from: "creations_images_pivot.image_id",
            to: "creations_images_pivot.creation_id",
          },
          to: "creations.id",
        },
      },
    };
  }

  static get jsonSchema() {
    return {
      type: "object",
      required: ["name"],

      properties: {
        id: { type: "integer" },
        name: { type: "string", maxLength: 100, minLength: 2 },
        cover: { type: "boolean" },
        created_at: {
          type: "string",
          format: "date-time",
          default: new Date().toISOString(),
        },
        updated_at: {
          type: "string",
          format: "date-time",
          default: new Date().toISOString(),
        },
      },
    };
  }
}

内容的提问来源于stack exchange,提问作者Jeje Pro

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.04 11:09:56