【问题标题】:Unwind Multiple Document Arrays Into New Documents将多个文档数组展开为新文档
【发布时间】:2017-10-31 13:28:07
【问题描述】:

今天我遇到了一种情况,我需要将 mongoDB 集合同步到 vertica(SQL 数据库),其中我的对象键将是 SQL 中表的列。 我使用 mongoDB 聚合框架,首先对想要的结果文档进行查询、操作和投影,然后将其同步到 vertica。

我要聚合的架构如下所示:

{
  userId: 123
  firstProperty: {
    firstArray: ['x','y','z'],
    anotherAttr: 'abc'
  },
  anotherProperty: {
    secondArray: ['a','b','c'],
    anotherAttr: 'def'
  }  
}

由于数组值与其他数组值无关,我需要的是嵌套数组的每个值都将在一个单独的结果文档中。 为此,我使用以下聚合管道:

db.collection('myCollection').aggregate([
            {
                $match: {
                    $or: [
                        {'firstProperty.firstArray.1': {$exists: true}},
                        {'secondProperty.secondArray.1': {$exists: true}}
                    ]
                }
            },
            {
                $project: {
                    userId: 1,
                    firstProperty: 1,
                    secondProperty: 1
                }
            }, {
                $unwind: {path:'$firstProperty.firstAray'}
            }, {
                $unwind: {path:'$secondProperty.secondArray'},
            }, {
                $project: {
                    userId: 1,
                    firstProperty: '$firstProperty.firstArray',
                    firstPropertyAttr: '$firstProperty.anotherAttr',
                    secondProperty: '$secondProperty.secondArray',
                    seondPropertyAttr: '$secondProperty.anotherAttr'
                }
            }, {
                $out: 'another_collection'
            }
        ])

我期望的是以下结果:

{
  userId: 'x1',
  firstProperty: 'x',
  firstPropertyAttr: 'a'
}
{
  userId: 'x1',
  firstProperty: 'y',
  firstPropertyAttr: 'a'
}
{
  userId: 'x1',
  firstProperty: 'z',
  firstPropertyAttr: 'a'
}
{
  userId: 'x1',
  secondProperty: 'a',
  firstPropertyAttr: 'b'
}
{
  userId: 'x1',
  secondProperty: 'b',
  firstPropertyAttr: 'b'
}
{
  userId: 'x1',
  secondProperty: 'c',
  firstPropertyAttr: 'b'
}

相反,我得到了类似的东西:

{
  userId: 'x1',
  firstProperty: 'x',
  firstPropertyAttr: 'b'
  secondProperty: 'a',
  secondPropertyAttr: 'b'
}
{
  userId: 'x1',
  firstProperty: 'y',
  firstPropertyAttr: 'b'
  secondProperty: 'b',
  secondPropertyAttr: 'b'
}
{
  userId: 'x1',
  firstProperty: 'z',
  firstPropertyAttr: 'b'
  secondProperty: 'c',
  secondPropertyAttr: 'b'
}

我到底错过了什么,我该如何解决?

【问题讨论】:

  • 您的预期结果包含具有不同字段名称的文档。其中一些具有 firstProperty,而另一些具有 secondProperty。那是你真正想要达到的目标吗?
  • 请注意,您的预期输出和当前输出实际上与给定的数据不匹配,或者与提供的聚合管道相关。但管道确实明确了数据“应该”来自哪里。特别是您的预期输出看起来像是您忘记了命名,但我相信我遵循了您打算呈现的“对象键作为列的名称”的一般要点。
  • 嗯,是的。我需要以不同的方式命名最终属性,因为它们代表不同的数据数组源。但这正是我在管道的最终预测中所做的。

标签: javascript mongodb aggregation-framework


【解决方案1】:

这实际上是一个比您想象的更“复杂”的问题,这一切都归结为“命名键”,这通常是一个真正的问题,您的数据“不应该”使用“数据点”在这些键的命名中。

您尝试中的另一个明显问题称为“笛卡尔积”。这是您 $unwind 一个数组然后 $unwind 另一个数组的位置,这会导致“第一个”$unwind 中的项目针对“第二个”中存在的每个值重复。

解决第二个问题,基本方法是“组合数组”,以便您只从单一来源$unwind。这对于所有其他方法都很常见。

至于方法,这些方法因您可用的 MongoDB 版本和应用程序的一般实用性而异。因此,让我们逐步了解它们:

删除命名键

这里最简单的方法是在输出中根本不期望命名键,而是将它们标记为"name",在最终输出中标识它们的来源。因此,我们要做的就是在初始“组合”数组的构造中指定每个“预期”键,然后简单地 $filter 指定由当前文档中不存在的命名路径产生的任何 null 值。

db.getCollection('myCollection').aggregate([
  { "$match": {
    "$or": [
      { "firstProperty.firstArray.0": { "$exists": true } },
      { "anotherProperty.secondArray.0": { "$exists": true } }
    ]  
  }},
  { "$project": {
    "_id": 0,
    "userId": 1,
    "combined": {
      "$filter": {
        "input": [
          { 
            "name": { "$literal": "first" },
            "array": "$firstProperty.firstArray",
            "attr": "$firstProperty.anotherAttr"
          },
          {
            "name": { "$literal": "another" },
            "array": "$anotherProperty.secondArray",
            "attr": "$anotherProperty.anotherAttr"
          }
        ],
        "cond": {
          "$ne": ["$$this.array", null ]
        }
      }
    }
  }},
  { "$unwind": "$combined" },
  { "$unwind": "$combined.array" },
  { "$project": {
    "userId": 1,
    "name": "$combined.name",
    "value": "$combined.array",
    "attr": "$combined.attr"
  }}
])

根据您问题中包含的数据,这将产生:

/* 1 */
{
    "userId" : 123.0,
    "name" : "first",
    "value" : "x",
    "attr" : "abc"
}

/* 2 */
{
    "userId" : 123.0,
    "name" : "first",
    "value" : "y",
    "attr" : "abc"
}

/* 3 */
{
    "userId" : 123.0,
    "name" : "first",
    "value" : "z",
    "attr" : "abc"
}

/* 4 */
{
    "userId" : 123.0,
    "name" : "another",
    "value" : "a",
    "attr" : "def"
}

/* 5 */
{
    "userId" : 123.0,
    "name" : "another",
    "value" : "b",
    "attr" : "def"
}

/* 6 */
{
    "userId" : 123.0,
    "name" : "another",
    "value" : "c",
    "attr" : "def"
}

合并对象 - 至少需要 MongoDB 3.4.4

要真正使用“命名键”,我们需要 $objectToArray$arrayToObject 运算符,它们仅在 MongoDB 3.4.4 之后才可用。使用这些和$replaceRoot 管道阶段,我们可以简单地处理到您想要的输出,而无需明确在任何阶段命名要输出的键:

db.getCollection('myCollection').aggregate([
  { "$match": {
    "$or": [
      { "firstProperty.firstArray.0": { "$exists": true } },
      { "anotherProperty.secondArray.0": { "$exists": true } }
    ]  
  }},
  { "$project": {
    "_id": 0,
    "userId": 1,
    "data": {
      "$reduce": {
        "input": {
          "$map": {
            "input": {
              "$filter": {
                "input": { "$objectToArray": "$$ROOT" },
                "cond": { "$not": { "$in": [ "$$this.k", ["_id", "userId"] ] } }
              }
            },
            "as": "d",
            "in": {
              "$let": {
                "vars": {
                  "inner": {
                    "$map": {
                      "input": { "$objectToArray": "$$d.v" },
                      "as": "i",
                      "in": {
                        "k": {
                          "$cond": {
                            "if": { "$ne": [{ "$indexOfCP": ["$$i.k", "Array"] }, -1] },
                            "then": "$$d.k",
                            "else": { "$concat": ["$$d.k", "Attr"] }
                          }  
                        },
                        "v": "$$i.v"
                      }
                    }
                  }
                },
                "in": {
                  "$map": {
                    "input": { 
                      "$arrayElemAt": [
                        "$$inner.v",
                        { "$indexOfArray": ["$$inner.k", "$$d.k"] } 
                      ]
                    },
                    "as": "v",
                    "in": {
                      "$arrayToObject": [[
                        { "k": "$$d.k", "v": "$$v" },
                        { 
                          "k": { "$concat": ["$$d.k", "Attr"] },
                          "v": {
                            "$arrayElemAt": [
                              "$$inner.v",
                              { "$indexOfArray": ["$$inner.k", { "$concat": ["$$d.k", "Attr"] }] }
                            ]
                          }
                        }
                      ]]
                    }
                  }
                }
              }
            }
          }
        },
        "initialValue": [],
        "in": { "$concatArrays": [ "$$value", "$$this" ] }
      }
    }
  }},
  { "$unwind": "$data" },
  { "$replaceRoot": {
    "newRoot": {
      "$arrayToObject": {
        "$concatArrays": [
          [{ "k": "userId", "v": "$userId" }],
          { "$objectToArray": "$data" }
        ]
      } 
    }   
  }}
])

将“键”转换为数组,然后将“子键”转换为数组,并将这些内部数组中的值映射到输出中的键对,这非常可怕。

$objectToArray 的关键部分基本上需要将您的“嵌套键”结构“转换”为 "k""v" 的数组,代表键的“名称”和“值”。这被调用了两次,一次用于文档的“外部”部分,并将诸如"_id""userId" 之类的“常量”字段排除在这样的数组结构中。然后对每个“数组”元素进行第二次调用,以使这些“内部键”成为类似的“数组”。

然后使用$indexOfCP 进行匹配,以确定哪个“内键”是值,哪个是“Attr”。然后在此处将键重命名为“外部”键值,我们可以访问它,因为这是 "v"$objectToArray 提供的。

那么对于作为“数组”的“内部值”,我们想将$map每个条目放入一个组合的“数组”中,该数组基本上具有以下形式:

[
  { "k": "firstProperty", "v": "x" },
  { "k": "firstPropertyAttr", "v": "abc" }
]

每个“内部数组”元素都会发生这种情况,$arrayToObject 会反转过程并将每个 "k""v" 分别转换为对象的“键”和“值”。

由于此时输出仍然是“内部键”的“数组数组”,$reduce 包装该输出并在处理每个元素时应用$concatArrays 以便“加入”单个数组对于"data"

剩下的就是简单地$unwind从每个源文档生成的数组,然后应用$replaceRoot,这是实际上允许在每个文档输出的“根”处“不同的键名”的部分。

这里的“合并”是通过提供具有相同"k""v" 构造的对象数组来完成的,这些对象用"userId" 表示,并通过"data"$objectToArray 变换“连接”。当然,这个“新数组”最后一次通过$arrayToObject 转换为一个对象,形成"newRoot" 的“对象”参数作为表达式。

当存在大量无法真正明确命名的“命名键”时,您会这样做。它实际上给了你想要的结果:

/* 1 */
{
    "userId" : 123.0,
    "firstProperty" : "x",
    "firstPropertyAttr" : "abc"
}

/* 2 */
{
    "userId" : 123.0,
    "firstProperty" : "y",
    "firstPropertyAttr" : "abc"
}

/* 3 */
{
    "userId" : 123.0,
    "firstProperty" : "z",
    "firstPropertyAttr" : "abc"
}

/* 4 */
{
    "userId" : 123.0,
    "anotherProperty" : "a",
    "anotherPropertyAttr" : "def"
}

/* 5 */
{
    "userId" : 123.0,
    "anotherProperty" : "b",
    "anotherPropertyAttr" : "def"
}

/* 6 */
{
    "userId" : 123.0,
    "anotherProperty" : "c",
    "anotherPropertyAttr" : "def"
}

没有 MongoDB 3.4.4 或更高版本的命名键

如果没有如上清单所示的运算符支持,聚合框架根本不可能输出具有不同键名的文档。

因此,尽管无法通过$out 指示“服务器”执行此操作,但您当然可以简单地迭代光标并编写一个新集合

var ops = [];

db.getCollection('myCollection').find().forEach( d => {
  ops = ops.concat(Object.keys(d).filter(k => ['_id','userId'].indexOf(k) === -1 )
    .map(k => 
      d[k][Object.keys(d[k]).find(ki => /Array$/.test(ki))]
        .map(v => ({
          [k]: v,
          [`${k}Attr`]: d[k][Object.keys(d[k]).find(ki => /Attr$/.test(ki))]
      }))
    )
    .reduce((acc,curr) => acc.concat(curr),[])
    .map( o => Object.assign({ userId: d.userId },o) )
  );

  if (ops.length >= 1000) {
    db.getCollection("another_collection").insertMany(ops);
    ops = [];
  }

})

if ( ops.length > 0 ) {
  db.getCollection("another_collection").insertMany(ops);
  ops = [];
}

与之前的聚合中所做的事情相同,但只是“外部”。它本质上为与“内部”数组匹配的每个文档生成文档数组,如下所示:

[ 
    {
        "userId" : 123.0,
        "firstProperty" : "x",
        "firstPropertyAttr" : "abc"
    }, 
    {
        "userId" : 123.0,
        "firstProperty" : "y",
        "firstPropertyAttr" : "abc"
    }, 
    {
        "userId" : 123.0,
        "firstProperty" : "z",
        "firstPropertyAttr" : "abc"
    }, 
    {
        "userId" : 123.0,
        "anotherProperty" : "a",
        "anotherPropertyAttr" : "def"
    }, 
    {
        "userId" : 123.0,
        "anotherProperty" : "b",
        "anotherPropertyAttr" : "def"
    }, 
    {
        "userId" : 123.0,
        "anotherProperty" : "c",
        "anotherPropertyAttr" : "def"
    }
]

这些被“缓存”到一个大数组中,当长度达到 1000 或更多时,最终通过.insertMany() 将其写入新集合。当然,这需要与服务器进行“来回”通信,但如果您没有可用于先前聚合的功能,它确实可以以最有效的方式完成工作。

结论

这里的总体要点是,除非您实际上有一个支持它的 MongoDB,否则您不会仅从聚合管道中获取输出中具有“不同键名”的文档。

因此,当您没有该支持时,您要么选择第一个选项,然后使用 $out 丢弃命名键。或者您采用最后一种方法,简单地操作光标结果并写回新集合。

【讨论】:

  • 您建议的第一个解决方案(“删除命名键”)完美地满足了我的需求。我的数据在现实中看起来有点不同,但我给出的例子简化了它。非常感谢。
猜你喜欢
  • 2018-01-22
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-04-20
  • 1970-01-01
  • 1970-01-01
  • 2015-12-22
相关资源
最近更新 更多