查找每个可售商品变体

MongoDBBeginner
立即练习

介绍

RIDGE & REED 销售户外服装。一位顾客想要绿色、中码、有库存且价格不超过 5000 分的商品。一款商品可以同时有绿色变体和有库存的中码变体,却没有可售的绿色中码变体。即使选对了商品,显示其整个变体数组,或显示其中最便宜但不可售的选项,仍会误导顾客。

你将保存一个可复用的商品选择器,然后构建 MongoDB 聚合管道,返回所有符合条件的 SKU,并从这些变体中计算最低价格。你需要掌握基本终端导航、MongoDB 连接、对象与数组,以及基础 find/update 知识。JavaScript 函数模板、聚合表达式和阶段都会在使用前讲解。全程使用 Terminal;MongoDB、合成库存和只读客户端均已准备好。

只修改 selector.js 和 finder.js。保留所有原始商品、变体 ID、属性、库存、价格及提供的工具。可售状态来自预先准备的库存数量,并非预留或结账保证。每个实际变体都具有文本 SKU、颜色、尺码,以及非负整数库存和以分为单位的价格。商品的变体字段可以是数组、null 或缺失;其他格式错误的输入类型不在本练习范围内。

让所有条件作用于同一个变体

本步骤中,你将把顾客请求与真实商品进行比较,并保存一个可复用的选择器,排除具有误导性的跨变体匹配。从 Terminal 开始。cd 用于切换目录;如果进入错误目录,再用这个绝对路径返回:

cd /home/labex/project/variant-desk

./catalog 是本目录中提供的读取工具;./ 表示当前目录。给它一个商品 ID,即可读取实际存储的文档:

./catalog TRAIL-1

Trail Jacket 有三个不同的变体:

SKU 颜色 尺码 库存 价格(分)
T-GM green M 0 1800
T-BM blue M 5 2200
T-GL green L 4 2100

绿色中码变体缺货。蓝色中码和绿色大码变体有货,但都不满足完整请求。SKU 标识一个变体,商品 ID 标识其所属商品。保留这些关系。

./db 将 MongoDB shell 连接到本课程的只读目录。--eval 运行引号中的 JavaScript 后退出。shell 单引号保护空格及 MongoDB 以美元符号开头的运算符,防止 shell 展开;内部双引号属于 JavaScript 字符串。"variants.color" 这样的点路径访问嵌套字段。$gt: 0 表示大于零;$lte: 5000 表示小于或等于 5000,包含预算边界。数值使用数字,而不是带引号的货币字符串。

下面这条普通点路径查询刻意放宽了条件。find 的第二个参数只投影商品 ID:1 表示包含字段,_id: 0 表示省略 MongoDB 内部 ID。sort 中的 1 表示升序;toArray() 将返回的文档收集成一个易读数组:

./db --eval 'db.products.find({"variants.color":"green","variants.size":"M","variants.stock":{$gt:0},"variants.price_cents":{$lte:5000}},{_id:0,product_id:1}).sort({product_id:1}).toArray()'

预期得到 CAMP-3、RIDGE-2 和 TRAIL-1。TRAIL-1 是误匹配:不同数组元素分别满足了不同条件。查询执行成功,但业务答案错误。

$elemMatch 让所有条件作用于同一个数组元素。你可能已在 MongoDB 数组基础中接触过此运算符;这里它是构建完整顾客查询结果的第一块基础:

./db --eval 'db.products.find({variants:{$elemMatch:{color:"green",size:"M",stock:{$gt:0},price_cents:{$lte:5000}}}},{_id:0,product_id:1}).sort({product_id:1}).toArray()'

现在只剩 CAMP-3 和 RIDGE-2。匹配商品至少有一个符合条件的变体,但这还不表示其中每个变体都符合条件。

保存一个以请求为参数的选择器,而不是把 green/M/5000 固定写进查询。JavaScript 函数是可复用代码:function buildSelector(request) 为其命名,并接收名为 request 的对象;request.color 读取该对象的颜色属性。return 将查询对象交给已准备好的客户端。花括号包围对象或函数体,冒号连接对象键和值,逗号分隔属性。函数要求的接口为 buildSelector(request),返回一个查询对象。它不是 shell 命令,也不是模块导出。

在 shell 中,cat > 替换目标文件。<<'JS' 开始按字面保留内容的 here-document,保留美元符号和引号。最后的 JS 应独占一行;在 shell 提示符下输入整个代码块:

cat > selector.js <<'JS'
function buildSelector(request) {
  return {
    variants: { $elemMatch: {
      color: request.color,
      size: request.size,
      stock: { $gt: 0 },
      price_cents: { $lte: request.max_cents }
    } }
  };
}
JS

./select 读取保存的函数,并使用 MongoDB find 原样执行它返回的选择器。三个参数分别为颜色、尺码和最高价格分值。客户端将预算转换为数字;green 和 M 等字符串仍是精确且区分大小写的标签:

./select green M 5000

实际匹配文档为 CAMP-3 和 RIDGE-2。它们的原始数组仍完整显示,包括不匹配的变体。这是 find 的正常行为:选择决定返回哪些文档,而不决定显示哪些数组元素。保留选择器这个实用成果,下一步再处理展示问题。

如果找不到文件,用 ls 列出本目录,检查文件名和工作目录绝对路径。JavaScript 有语法错误时,用修正后的完整代码块替换保存内容,再运行客户端。如果 shell 仍在等待,输入缺失的最后一行 JS。不要通过修改源库存、删除变体或重置数据库,让查询显得正确。

返回每个符合条件的 SKU

本步骤中,你将把商品选择转化为完整的变体结果,避免只返回第一个匹配,也避免把整个原始数组带入结果。从同一个 Terminal 开始,工作目录为 /home/labex/project/variant-desk。

读取两个真正匹配的商品:

./catalog RIDGE-2
./catalog CAMP-3

对于 green/M、最高 5000 分的请求,Ridge Jacket 有两个符合条件的 SKU:4500 分的 R-GM1 和 5000 分的 R-GM2。R-GM3 超出预算;其他尺码、颜色及零库存变体也不符合条件。Camp Vest 有 C-GM1 和 C-GM2,两者都是 4000 分。价格相同不会使不同 SKU 变成重复项。

位置投影 "variants.$": 1 只返回第一个匹配的数组元素。观察为什么它无法交付本请求的完整集合:

./db --eval 'db.products.find({variants:{$elemMatch:{color:"green",size:"M",stock:{$gt:0},price_cents:{$lte:5000}}}},{_id:0,product_id:1,"variants.$":1}).sort({product_id:1}).toArray()'

此输出中,每个商品只有一个变体。第二个合法 SKU 虽然有货,却缺失了。返回整个原始数组则会走向另一个错误方向,包含不需要的变体。

聚合管道是按顺序处理的阶段数组。每个阶段接收上一阶段的文档。$project 选择输出字段,也可以计算新字段。$filter 计算一个数组,包含条件为真的每个元素;这里不设置数量限制。

在 $filter 内,input 是源数组,as: "v" 为当前变体命名。"$variants" 引用文档字段;"$$v.color" 引用当前数组元素的颜色。这些美元符号是 JavaScript 字符串中的 MongoDB 表达式。request.color 则是客户端提供的 JavaScript 请求值。

聚合比较使用操作数数组:{ $eq: [left, right] } 检查相等,$gt 检查大于,$lte 检查小于或等于。$and 要求所有比较对同一个 v 都为真。这与 $elemMatch 中查询对象的写法不同,但业务条件相同。

$ifNull: ["$variants", []] 在源字段为 null 或缺失时用空数组替代。[] 是空 JavaScript 数组。字符串等非数组值不在所提供输入契约范围内;此管道并不声称能够清理任意格式错误的目录。

筛选后,$match 移除没有符合条件变体的商品。"variants.0" 引用索引零,即第一个数组元素;$exists: true 要求它存在。这是 match 阶段中的查询谓词,不是 filter 条件内部的表达式。最后的 $sort 便于阅读实际输出。

将完整模板保存到 finder.js。要求的接口为 buildPipeline(request),返回阶段数组。客户端将原样执行返回的管道,不会在之后再次筛选答案:

cat > finder.js <<'JS'
function buildPipeline(request) {
  return [
    { $project: {
        _id: 0, product_id: 1, title: 1,
        variants: { $filter: {
          input: { $ifNull: ["$variants", []] },
          as: "v",
          cond: { $and: [
            { $eq: ["$$v.color", request.color] },
            { $eq: ["$$v.size", request.size] },
            { $gt: ["$$v.stock", 0] },
            { $lte: ["$$v.price_cents", request.max_cents] }
          ] }
        } }
    } },
    { $match: { "variants.0": { $exists: true } } },
    { $sort: { product_id: 1 } }
  ];
}
JS
./finder green M 5000

预期恰好有两个商品。CAMP-3 包含 C-GM1 和 C-GM2;RIDGE-2 包含 R-GM1 和 R-GM2。每个返回变体完整保留原始 SKU、颜色、尺码、库存和价格分值。商品完整保留其 product_id 和 title。此时还没有最低价格字段;这是下一步的内容。

管道现在返回全部四个符合条件的 SKU,包括两个价格相同的 Camp Vest 变体,以及恰好位于预算边界的 Ridge SKU。TRAIL-1、空数组及 null 或缺失数组都不会产生结果商品。这个结果并非通过删除或编辑任何源文档得到。

如果客户端报告 buildPipeline 必须返回管道数组,检查保存的文件是否包含完整函数和 return [ ... ];。如果报告语法错误,替换完整代码块后重试。将查询 JavaScript 放在保存的文件中;不要添加 shell 命令、交互式 use 命令或模块导出。

为匹配集合定价并复用查找器

本步骤中,你将添加符合条件的最低价格,并用同一个保存的查询处理不同顾客请求,包括如实返回空结果。从 Terminal 开始,工作目录为 /home/labex/project/variant-desk。

Ridge Jacket 原始变体中最便宜的价格为 900 分,但库存为零。另一个便宜变体的颜色不符。即使商品本身选对了,把其中任何一个价格作为 green/M 的起售价仍会误导顾客。

$set 在聚合阶段中添加或替换输出字段,不会更新存储的商品。这里它添加 min_price_cents。只有一个数组操作数的 $min 表达式返回其中最小的数值。"$variants.price_cents" 访问当前文档变体数组中的价格。将此阶段放在筛选之后:它必须读取 $project 生成的匹配数组,而非原始数组。使用后续阶段也能明确地访问已改变的字段。

用下面的完整管道替换 finder.js。唯一新增阶段在排除空数组后计算最低价格:

cat > finder.js <<'JS'
function buildPipeline(request) {
  return [
    { $project: {
        _id: 0, product_id: 1, title: 1,
        variants: { $filter: {
          input: { $ifNull: ["$variants", []] },
          as: "v",
          cond: { $and: [
            { $eq: ["$$v.color", request.color] },
            { $eq: ["$$v.size", request.size] },
            { $gt: ["$$v.stock", 0] },
            { $lte: ["$$v.price_cents", request.max_cents] }
          ] }
        } }
    } },
    { $match: { "variants.0": { $exists: true } } },
    { $set: { min_price_cents: { $min: "$variants.price_cents" } } },
    { $sort: { product_id: 1 } }
  ];
}
JS
./finder green M 5000

结果必须保留全部四个符合条件的 SKU,并添加 CAMP-3 的最低价格 4000 和 RIDGE-2 的最低价格 4500。min_price_cents 从每个返回的变体集合中推导。不要对相同价格去重、使用整个商品的最低价格,或手写这两个数字。

现在用同一个函数降低预算。请求值改变,保存的查询不变:

./finder green M 4499

只剩 CAMP-3,仍包含两个 4000 分 SKU,最低价格为 4000。Ridge Jacket 没有符合新上限的 green/M 变体。

改变颜色和价格上限:

./finder blue M 2500

RIDGE-2 现在只包含价格 1000 的 R-BM;TRAIL-1 只包含价格 2200 的 T-BM。对一个请求正确排除的商品,可以在另一个请求中被纳入。可售性取决于具体变体与请求的关系,而非商品的永久标记。

最后,尝试一个没有匹配项的预算:

./finder green M 500

实际结果为 []。客户端不会替换成演示结果,也不会显示不可售商品来填满屏幕。查询缺失或数据库错误会作为错误报告,而不会伪装成这个成功的空数组。

最终管道结果恰好包含 product_id、title、variants 和 min_price_cents。每个匹配商品返回一次,每个符合条件的 SKU 返回一次,并保留全部五个原始变体字段。商品和变体的顺序可以不同;标识和值才是业务契约。查询还必须能处理相同结构、但 ID、价格和库存不同的另一个目录。已准备好的客户端读取真实 MongoDB 结果,不会修正选择、数组或价格。

保留两个 JavaScript 文件,以及所有原始商品和工具。你构建的是可复用的目录查询,而不是预订系统:并发库存变化或之后的结账仍可能影响可售性。后面的挑战有独立的租赁库存,使用相同查询机制,但属性名称和请求值不同。

总结

你排除了跨变体匹配,保留每个符合条件的 SKU,而不是只保留第一个,并从筛选后的数组计算展示价格。保存的函数能够处理变化的请求、相同价格和空结果,同时保持原始目录不变。