Сегодня я столкнулся с ситуацией, когда мне нужно синхронизировать коллекцию mongoDB с Vertica (SQL Database), где моими объектными ключами будут столбцы таблицы в 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'
}
Что именно мне не хватает, и как я могу это исправить?
Это на самом деле гораздо более "завуалированная" проблема, чем вы думаете, и все это действительно сводится к "названным ключам", которые, как правило, представляют собой настоящую проблему, и ваши данные "должны" не использовать "точки данных" в именовании таких ключей.
Другая очевидная проблема в вашей попытке называется "декартовым произведением". Здесь вы $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"
}
Чтобы на самом деле использовать "named keys", нам нужны операторы $objectToArray
и $arrayToObject
которые были доступны только с MongoDB 3.4.4. Используя эти и $replaceRoot
конвейера $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
с помощью $indexOfCP
для определения которого "внутренний ключ" является значением для значения, а "Attr". Затем ключи переименовываются здесь в значение "внешнего" ключа, доступ к которому мы можем получить, потому что это "v"
любезно предоставлено $objectToArray
.
Затем для "внутреннего значения", который является "массивом", мы хотим, чтобы $map
каждую запись в объединенный "массив", который в основном имеет форму:
[
{ "k": "firstProperty", "v": "x" },
{ "k": "firstPropertyAttr", "v": "abc" }
]
Это происходит для каждого элемента "внутренний массив", для которого $arrayToObject
меняет процесс и превращает каждый "k"
и "v"
в "ключ" и "значение" объекта соответственно.
Так как вывод по-прежнему является "массивом массивов" "внутренних ключей" в этой точке, $concatArrays
$reduce
которые выводят и применяют $concatArrays
при обработке каждого элемента, чтобы "присоединиться" к одному массиву для "data"
.
Остается только просто $unwind
массив, созданный из каждого исходного документа, а затем применить $replaceRoot
, который является частью, которая фактически позволяет "разные имена ключей" в "корне" каждого выходного документа.
"Слияние" здесь осуществляется путем подачи массива объектов с теми же "k"
и "v"
конструкциями, обозначенными для "userId"
, и "concatentating", что с преобразованием $objectToArray
"data"
. Конечно, этот "новый массив" затем преобразуется в объект через $arrayToObject
последний раз, который формирует аргумент "object" для "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"
}
Без поддержки оператора, как показано в приведенном выше списке, просто невозможно, чтобы структура агрегации выводила документы с разными именами ключей.
Поэтому, хотя это невозможно дать указание "серверу" сделать это через $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
отбрасывая ключи с именами. Или вы делаете окончательный подход и просто манипулируете результатами курсора и записываете обратно в новую коллекцию.