乐趣区

MongoDB指南8特定类型的查询

上一篇文章:MongoDB 指南 —7、find 简介与查询条件
下一篇文章:

如第 2 章所述,MongoDB 的文档可以使用多种类型的数据。其中有一些在查询时会有特别的表现。

4.3.1 null

null 类型的行为有点奇怪。它确实能匹配自身,所以要是有一个包含如下文档的集合:

> db.c.find()
{"_id" : ObjectId("4ba0f0dfd22aa494fd523621"), "y" : null }
{"_id" : ObjectId("4ba0f0dfd22aa494fd523622"), "y" : 1 }
{"_id" : ObjectId("4ba0f148d22aa494fd523623"), "y" : 2 }

就可以按照预期的方式查询 ”y” 键为 null 的文档:

> db.c.find({"y" : null})
{"_id" : ObjectId("4ba0f0dfd22aa494fd523621"), "y" : null }

但是,null 不仅会匹配某个键的值为 null 的文档,而且还会匹配不包含这个键的文档。所以,这种匹配还会返回缺少这个键的所有文档:

> db.c.find({"z" : null})
{"_id" : ObjectId("4ba0f0dfd22aa494fd523621"), "y" : null }
{"_id" : ObjectId("4ba0f0dfd22aa494fd523622"), "y" : 1 }
{"_id" : ObjectId("4ba0f148d22aa494fd523623"), "y" : 2 }

如果仅想匹配键值为 null 的文档,既要检查该键的值是否为 null,还要通过 ”$exists” 条件判定键值已存在:

> db.c.find({"z" : {"$in" : [null], "$exists" : true}})

很遗憾,没有 ”$eq” 操作符,所以这条查询语句看上去有些令人费解,但是使用只有一个元素的 ”$in” 操作符效果是一样的。

4.3.2 正则表达式

正则表达式能够灵活有效地匹配字符串。例如,想要查找所有名为 Joe 或者 joe 的用户,就可以使用正则表达式执行不区分大小写的匹配:

> db.users.find({"name" : /joe/i})

系统可以接受正则表达式标志(i),但不是一定要有。现在已经匹配了各种大小写组合形式的 joe,如果还希望匹配如 ”joey” 这样的键,可以略微修改一下刚刚的正则表达式:

> db.users.find({"name" : /joey?/i})

MongoDB 使用 Perl 兼容的正则表达式(PCRE)库来匹配正则表达式,任何 PCRE 支持的正则表达式语法都能被 MongoDB 接受。建议在查询中使用正则表达式前,先在 JavaScript shell 中检查一下语法,确保匹配与设想的一致。
MongoDB 可以为前缀型正则表达式(比如 /^joey/)查询创建索引,所以这种类型的查询会非常高效。
正则表达式也可以匹配自身。虽然几乎没有人直接将正则表达式插入到数据库中,但要是万一你这么做了,也可以匹配到自身:

> db.foo.insert({"bar" : /baz/})
> db.foo.find({"bar" : /baz/})
{"_id" : ObjectId("4b23c3ca7525f35f94b60a2d"),
    "bar" : /baz/
} 

4.3.3 查询数组

查询数组元素与查询标量值是一样的。例如,有一个水果列表,如下所示:

> db.food.insert({"fruit" : ["apple", "banana", "peach"]})

下面的查询:

> db.food.find({"fruit" : "banana"})

会成功匹配该文档。这个查询好比我们对一个这样的(不合法)文档进行查询:{“fruit” : “apple”, “fruit” : “banana”, “fruit” : “peach”}。

1. $all

如果需要通过多个元素来匹配数组,就要用 ”$all” 了。这样就会匹配一组元素。例如,假设创建了一个包含 3 个元素的集合:

> db.food.insert({"_id" : 1, "fruit" : ["apple", "banana", "peach"]})
> db.food.insert({"_id" : 2, "fruit" : ["apple", "kumquat", "orange"]})
> db.food.insert({"_id" : 3, "fruit" : ["cherry", "banana", "apple"]})

要找到既有 ”apple” 又有 ”banana” 的文档,可以使用 ”$all” 来查询:

> db.food.find({fruit : {$all : ["apple", "banana"]}})
    {"_id" : 1, "fruit" : ["apple", "banana", "peach"]}
    {"_id" : 3, "fruit" : ["cherry", "banana", "apple"]}

这里的顺序无关紧要。注意,第二个结果中 ”banana” 在 ”apple” 之前。要是对只有一个元素的数组使用 ”$all”,就和不用 ”$all” 一样了。例如,{fruit : {$all : [‘apple’]}和 {fruit : ‘apple’} 的查询结果完全一样。
也可以使用整个数组进行精确匹配。但是,精确匹配对于缺少元素或者元素冗余的情况就不大灵了。例如,下面的方法会匹配之前的第一个文档:

> db.food.find({"fruit" : ["apple", "banana", "peach"]})

但是下面这个就不会匹配:

> db.food.find({"fruit" : ["apple", "banana"]})

这个也不会匹配:

> db.food.find({"fruit" : ["banana", "apple", "peach"]})

要是想查询数组特定位置的元素,需使用 key.index 语法指定下标:

> db.food.find({"fruit.2" : "peach"})

数组下标都是从 0 开始的,所以上面的表达式会用数组的第 3 个元素和 ”peach” 进行匹配。

2. $size

“$size” 对于查询数组来说也是非常有用的,顾名思义,可以用它查询特定长度的数组。例如:

> db.food.find({"fruit" : {"$size" : 3}})

得到一个长度范围内的文档是一种常见的查询。”$size” 并不能与其他查询条件(比如 ”$gt”)组合使用,但是这种查询可以通过在文档中添加一个 ”size” 键的方式来实现。这样每一次向指定数组添加元素时,同时增加 ”size” 的值。比如,原本这样的更新:

> db.food.update(criteria, {"$push" : {"fruit" : "strawberry"}})

就要变成下面这样:

> db.food.update(criteria,
... {"$push" : {"fruit" : "strawberry"}, "$inc" : {"size" : 1}})

自增操作的速度非常快,所以对性能的影响微乎其微。这样存储文档后,就可以像下面这样查询了:

> db.food.find({"size" : {"$gt" : 3}})

很遗憾,这种技巧并不能与 ”$addToSet” 操作符同时使用。

3. $slice 操作符

本章前面已经提及,find 的第二个参数是可选的,可以指定需要返回的键。这个特别的 ”$slice” 操作符可以返回某个键匹配的数组元素的一个子集。
例如,假设现在有一个博客文章的文档,我们希望返回前 10 条评论,可以这样做:

> db.blog.posts.findOne(criteria, {"comments" : {"$slice" : 10}})

也可以返回后 10 条评论,只要在查询条件中使用 -10 就可以了:

> db.blog.posts.findOne(criteria, {"comments" : {"$slice" : -10}})

“$slice” 也可以指定偏移值以及希望返回的元素数量,来返回元素集合中间位置的某些结果:

db.blog.posts.findOne(criteria, {“comments” : {“$slice” : [23, 10]}})
这个操作会跳过前 23 个元素,返回第 24~33 个元素。如果数组不够 33 个元素,则返回第 23 个元素后面的所有元素。
除非特别声明,否则使用 ”$slice” 时将返回文档中的所有键。别的键说明符都是默认不返回未提及的键,这点与 ”$slice” 不太一样。例如,有如下博客文章文档:

{"_id" : ObjectId("4b2d75476cc613d5ee930164"),
    "title" : "A blog post",
    "content" : "...",
    "comments" : [
        {
            "name" : "joe",
            "email" : "joe@example.com",
            "content" : "nice post."
        },
        {
            "name" : "bob",
            "email" : "bob@example.com",
            "content" : "good post."
        }
    ]
}

用 ”$slice” 来获取最后一条评论,可以这样:

> db.blog.posts.findOne(criteria, {"comments" : {"$slice" : -1}})
{"_id" : ObjectId("4b2d75476cc613d5ee930164"),
    "title" : "A blog post",
    "content" : "...",
    "comments" : [
        {
            "name" : "bob",
            "email" : "bob@example.com",
            "content" : "good post."
        }
    ]
}

“title” 和 ”content” 都返回了,即便是并没有显式地出现在键说明符中。

4. 返回一个匹配的数组元素

如果知道元素的下标,那么 ”$slice” 非常有用。但有时我们希望返回与查询条件相匹配的任意一个数组元素。可以使用 $ 操作符得到一个匹配的元素。对于上面的博客文章示例,可以用如下的方式得到 Bob 的评论:

> db.blog.posts.find({"comments.name" : "bob"}, {"comments.$" : 1})
{"_id" : ObjectId("4b2d75476cc613d5ee930164"),
    "comments" : [
        {
            "name" : "bob",
            "email" : "bob@example.com",
            "content" : "good post."
        }
    ]
}

注意,这样只会返回第一个匹配的文档。如果 Bob 在这篇博客文章下写过多条评论,只有 ”comments” 数组中的第一条评论会被返回。

5. 数组和范围查询的相互作用

文档中的标量(非数组元素)必须与查询条件中的每一条语句相匹配。例如,如果使用 {“x” : {“$gt” : 10, “$lt” : 20}} 进行查询,只会匹配 ”x” 键的值大于等于 10 并且小于等于 20 的文档。但是,假如某个文档的 ”x” 字段是一个数组,如果 ”x” 键的某一个元素与查询条件的任意一条语句相匹配(查询条件中的每条语句可以匹配不同的数组元素),那么这个文档也会被返回。
下面用一个例子来详细说明这种情况。假如有如下所示的文档:

{"x" : 5}
{"x" : 15}
{"x" : 25}
{"x" : [5, 25]}

如果希望找到 ”x” 键的值位于 10 和 20 之间的所有文档,直接想到的查询方式是使用 db.test.find({“x” : {“$gt” : 10, “$lt” : 20}}),希望这个查询的返回文档是{“x” : 15}。但是,实际返回了两个文档:

> db.test.find({"x" : {"$gt" : 10, "$lt" : 20}})
{"x" : 15}
{"x" : [5, 25]}

5 和 25 都不位于 10 和 20 之间,但是这个文档也返回了,因为 25 与查询条件中的第一个语句(大于 10)相匹配,5 与查询条件中的第二个语句(小于 20)相匹配。
这使对数组使用范围查询没有用:范围会匹配任意多元素数组。有几种方式可以得到预期的行为。
首先,可以使用 ”$elemMatch” 要求 MongoDB 同时使用查询条件中的两个语句与一个数组元素进行比较。但是,这里有一个问题,”$elemMatch” 不会匹配非数组元素:

> db.test.find({"x" : {"$elemMatch" : {"$gt" : 10, "$lt" : 20}})
> // 查不到任何结果

{“x” : 15}这个文档与查询条件不再匹配了,因为它的 ”x” 字段是个数组。
如果当前查询的字段上创建过索引(第 5 章会讲述索引相关内容),可以使用 min()和 max()将查询条件遍历的索引范围限制为 ”$gt” 和 ”$lt” 的值:

> db.test.find({"x" : {"$gt" : 10, "$lt" : 20}).min({"x" : 10}).max({"x" : 20})
{"x" : 15}

现在,这个查询只会遍历值位于 10 和 20 之间的索引,不再与 5 和 25 进行比较。只有当前查询的字段上建立过索引时,才可以使用 min()和 max(),而且,必须为这个索引的所有字段指定 min()和 max()。
在可能包含数组的文档上应用范围查询时,使用 min()和 max()是非常好的:如果在整个索引范围内对数组使用 ”$gt”/”$lt” 查询,效率是非常低的。查询条件会与所有值进行比较,会查询每一个索引,而不仅仅是指定索引范围内的值。

4.3.4 查询内嵌文档

有两种方法可以查询内嵌文档:查询整个文档,或者只针对其键 / 值对进行查询。
查询整个内嵌文档与普通查询完全相同。例如,有如下文档:

{
    "name" : {
        "first" : "Joe",
        "last" : "Schmoe"
     },
     "age" : 45
} 

要查寻姓名为 Joe Schmoe 的人可以这样:

> db.people.find({"name" : {"first" : "Joe", "last" : "Schmoe"}})

但是,如果要查询一个完整的子文档,那么子文档必须精确匹配。如果 Joe 决定添加一个代表中间名的键,这个查询就不再可行了,因为查询条件不再与整个内嵌文档相匹配。而且这种查询还是与顺序相关的,{“last” : “Schmoe”,”first” : “Joe”}什么都匹配不到。
如果允许的话,通常只针对内嵌文档的特定键值进行查询,这是比较好的做法。这样,即便数据模式改变,也不会导致所有查询因为要精确匹配而一下子都挂掉。我们可以使用点表示法查询内嵌文档的键:

> db.people.find({"name.first" : "Joe", "name.last" : "Schmoe"})

现在,如果 Joe 增加了更多的键,这个查询依然会匹配他的姓和名。
这种点表示法是查询文档区别于其他文档的主要特点。查询文档可以包含点来表达“进入内嵌文档内部”的意思。点表示法也是待插入的文档不能包含“.”的原因。将 URL 作为键保存时经常会遇到此类问题。一种解决方法就是在插入前或者提取后执行一个全局替换,将“.”替换成一个 URL 中的非法字符。
当文档结构变得更加复杂以后,内嵌文档的匹配需要些许技巧。例如,假设有博客文章若干,要找到由 Joe 发表的 5 分以上的评论。博客文章的结构如下例所示:

> db.blog.find()
{
    "content" : "...",
    "comments" : [
        {
            "author" : "joe",
            "score" : 3,
            "comment" : "nice post"
        },
        {
            "author" : "mary",
            "score" : 6,
            "comment" : "terrible post"
        }
    ]
}

不能直接用 db.blog.find({“comments” : {“author” : “joe”,”score” : {“$gte” : 5}}})来查寻。内嵌文档的匹配,必须要整个文档完全匹配,而这个查询不会匹配 ”comment” 键。使用 db.blog.find({“comments.author” : “joe”,”comments.score” : {“$gte” : 5}}也不行,因为符合 author 条件的评论和符合 score 条件的评论可能不是同一条评论。也就是说,会返回刚才显示的那个文档。因为 ”author” : “joe” 在第一条评论中匹配了,”score” : 6 在第二条评论中匹配了。
要正确地指定一组条件,而不必指定每个键,就需要使用 ”$elemMatch”。这种模糊的命名条件句能用来在查询条件中部分指定匹配数组中的单个内嵌文档。所以正确的写法应该是下面这样的:

> db.blog.find({"comments" : {"$elemMatch" : {"author" : "joe",
                                              "score" : {"$gte" : 5}}}})

“$elemMatch” 将限定条件进行分组,仅当需要对一个内嵌文档的多个键操作时才会用到。

4.4 $where 查询

键 / 值对是一种表达能力非常好的查询方式,但是依然有些需求它无法表达。其他方法都败下阵时,就轮到 ”$where” 子句登场了,用它可以在查询中执行任意的 JavaScript。这样就能在查询中做(几乎)任何事情。为安全起见,应该严格限制或者消除 ”$where” 语句的使用。应该禁止终端用户使用任意的 ”$where” 语句。
“$where” 语句最常见的应用就是比较文档中的两个键的值是否相等。假如我们有如下文档:

> db.foo.insert({"apple" : 1, "banana" : 6, "peach" : 3}) 
> db.foo.insert({"apple" : 8, "spinach" : 4, "watermelon" : 4})

我们希望返回两个键具有相同值的文档。第二个文档中,”spinach” 和 ”watermelon” 的值相同,所以需要返回该文档。MongoDB 似乎从来没有提供过一个 $ 条件语句来做这种查询,所以只能用 ”$where” 子句借助 JavaScript 来完成了:

> db.foo.find({"$where" : function () {... for (var current in this) {...     for (var other in this) {...         if (current != other && this[current] == this[other]) {
...             return true;
...         }
...     }
... }
... return false;
... }});

如果函数返回 true,文档就做为结果集的一部分返回;如果为 false,就不返回。
不是非常必要时,一定要避免使用 ”$where” 查询,因为它们在速度上要比常规查询慢很多。每个文档都要从 BSON 转换成 JavaScript 对象,然后通过 ”$where” 表达式来运行。而且 ”$where” 语句不能使用索引,所以只在走投无路时才考虑 ”$where” 这种用法。先使用常规查询进行过滤,然后再使用 ”$where” 语句,这样组合使用可以降低性能损失。如果可能的话,使用 ”$where” 语句前应该先使用索引进行过滤,”$where” 只用于对结果进行进一步过滤。
进行复杂查询的另一种方法是使用聚合工具,第 7 章会详细介绍。

服务器端脚本

在服务器上执行 JavaScript 时必须注意安全性。如果使用不当,服务器端 JavaScript 很容易受到注入攻击,与关系型数据库中的注入攻击类似。不过,只要在接受输入时遵循一些规则,就可以安全地使用 JavaScript。也可以在运行 mongod 时指定 –noscripting 选项,完全关闭 JavaScript 的执行。
JavaScript 的安全问题都与用户在服务器上提供的程序相关。如果希望避免这些风险,那么就要确保不能直接将用户输入的内容传递给 mongod。例如,假如你希望打印一句“Hello, name!”,这里的 name 是由用户提供的。使用如下所示的 JavaScript 函数是非常容易想到的:

> func = "function() { print('Hello, "+name+"!'); }" 

如果这里的 name 是一个用户定义的变量,它可能会是 ”‘); db.dropDatabase();print(‘” 这样一个字符串,因此,上面的代码会被转换成如下代码:

> func = "function() { print('Hello, '); db.dropDatabase(); print('!'); }"

如果执行这段代码,你的整个数据库就会被删除!
为了避免这种情况,应该使用作用域来传递 name 的值。以 Python 为例:

func = pymongo.code.Code("function() {print('Hello, '+username+'!'); }",
            {"username": name})

现在,数据库会输出如下的内容,不会有任何风险:

Hello, '); db.dropDatabase(); print('!

由于代码实际上可能是字符串和作用域的混合体,所以大多数驱动程序都有一种特殊类型,用于向数据库传递代码。作用域是用于表示变量名和值的映射的文档。对于要被执行的 JavaScript 函数来说,这个映射就是一个局部作用域。因此,在上面的例子中,函数可以访问 username 这个变量,这个变量的值就是用户传进来的字符串。
shell 中没有包含作用域的代码类型,所以作用域只能在字符串或者 JavaScript 函数中使用。

退出移动版