SQL-实践指南第二版-全-
SQL 实践指南第二版(全)
原文:
zh.annas-archive.org/md5/afdec65c61c367bb6882c2e32099cd9f译者:飞龙
前言

在加入《今日美国》的员工队伍后不久,我收到了一个数据集,接下来将近十年间我几乎每周都会分析这个数据集。它是每周的畅销书榜单,依据保密的销售数据对全国最畅销的书籍进行排名。这个榜单不仅为我提供了源源不断的故事创意,还以独特的方式捕捉了美国的时代精神。
你知道吗,烹饪书在母亲节那周的销量会略微增加,或者说,奥普拉·温弗瑞通过邀请许多默默无闻的作家上她的节目,把他们变成了畅销书作者?每周,我和书单编辑都会仔细研究销售数据和图书类别,对数据进行排序,寻找新的头条新闻。我们很少空手而归:我们记录了从《哈利·波特》系列的飞速崛起,到苏斯博士的《哦,你会去的地方!》成为毕业生常年送礼的经典之作。
那段时间,我的技术伙伴是数据库编程语言SQL(结构化查询语言)。一开始,我说服了《今日美国》的 IT 部门,允许我访问驱动我们书单应用的基于 SQL 的数据库系统。通过使用 SQL,我能够发掘数据库中隐藏的故事,这些数据包含了与书名、作者、类别以及定义出版界的代码相关的销售信息。
SQL 自那时以来一直对我有帮助,无论我担任的是产品开发、内容策略,还是最近作为《华尔街日报》的数据编辑。在每种情况下,SQL 都帮助我在数据中找到有趣的故事——而这正是你通过本书所能学到的内容。
什么是 SQL?
SQL 是一种广泛使用的编程语言,用于管理数据和数据库系统。无论你是市场分析师、记者,还是正在研究果蝇大脑神经元的科研人员,使用 SQL 来收集、修改、探索和总结数据都会带来好处。
由于 SQL 是一种已经存在了数十年的成熟语言,它已深深植根于许多现代系统中。IBM 的两位研究人员首次在 1974 年的论文中概述了 SQL(当时称为 SEQUEL)的语法,并在英国计算机科学家埃德加·F·科德的理论工作基础上发展了这一语言。1979 年,数据库公司甲骨文(当时名为关系软件)的前身成为首个将该语言应用于商业产品的公司。如今,SQL 仍然是世界上使用最广泛的计算机语言之一,而且这一局面不太可能很快改变。
每个数据库系统,如 PostgreSQL、MySQL 或 Microsoft SQL Server,都实现了自己版本的 SQL,因此如果你从一个系统跳到另一个系统,你会注意到语法上的细微差异——有时甚至是显著的差异。这背后有几个原因。美国国家标准协会(ANSI)在 1986 年采纳了 SQL 标准,国际标准化组织(ISO)在 1987 年随后也采纳了该标准。但是,标准并没有涵盖数据库实现所需的 SQL 的所有方面——例如,它没有涉及创建索引的条目。这就使得每个数据库系统制造商可以选择如何实现标准未涵盖的功能——目前没有任何数据库制造商声称完全符合该标准。
同时,商业考虑也可能促使商业数据库供应商创建非标准 SQL 特性,既为了竞争优势,也为了将用户留在自己的生态系统中。例如,微软的 SQL Server 使用专有的 Transact-SQL(T-SQL),其中包括许多不在 SQL 标准中的功能,例如用于声明局部变量的语法。因此,将使用 T-SQL 编写的代码迁移到另一个数据库系统可能并非易事。
本书中的示例和代码使用 PostgreSQL 数据库系统。PostgreSQL,简称 Postgres,是一款强大的应用程序,可以处理大量数据。以下是 PostgreSQL 成为本书推荐使用数据库系统的原因:
-
它是免费的。
-
它适用于 Windows、macOS 和 Linux 操作系统。
-
它的 SQL 实现旨在紧密遵循 SQL 标准。
-
它被广泛使用,因此在网上很容易找到帮助。
-
它的地理空间扩展 PostGIS 让你能够分析几何数据并执行映射功能,通常与 QGIS 等地图软件一起使用。
-
它可用于像 Amazon Web Services 和 Google Cloud 这样的云计算环境。
-
它是网页应用程序中常见的数据存储选择,包括那些由流行的网页框架 Django 提供支持的应用程序。
好消息是,PostgreSQL 的基本概念和大多数核心 SQL 语法约定在不同的数据库间都能通用。所以,如果你在工作中使用 MySQL,你可以运用你在这里学到的很多内容——或者轻松找到相似的代码概念。当语法是 PostgreSQL 特有时,我会特别指出。如果你需要学习具有偏离标准特性的系统的 SQL 语法,例如 Microsoft SQL Server 的 T-SQL,你可能需要进一步探索专注于该系统的资源。
为什么选择 SQL?
SQL 当然不是处理数据的唯一选择。许多人从 Microsoft Excel 表格和其中的各种分析功能开始。在使用 Excel 后,他们可能会转向 Access,这是微软 Office 某些版本中内置的数据库系统,它具有图形化查询界面,便于完成工作。那么,为什么要学习 SQL 呢?
一个原因是 Excel 和 Access 有它们的限制。Excel 当前每个工作表的最大行数为 1,048,576 行。Access 将数据库大小限制为两 GB,并且每个表格的列数限制为 255 列。数据集超出这些限制并不罕见。在面临最后期限时,你最不希望发现的问题就是你的数据库系统没有足够的容量来完成任务。
使用强大的 SQL 数据库系统,你可以处理 TB 级别的数据、多个相关表格以及成千上万的列。它让你对数据结构拥有精细的控制,从而提高效率、速度和——最重要的——准确性。
SQL 也是数据科学中编程语言的极好补充,例如 R 和 Python。如果你使用其中任何一种语言,你可以连接到 SQL 数据库,在某些情况下,甚至可以将 SQL 语法直接嵌入到编程语言中。对于没有编程语言背景的人来说,SQL 通常是一个容易理解的引导,帮助你了解与数据结构和编程逻辑相关的概念。
最后,SQL 不仅仅用于数据分析。如果你深入开发在线应用程序,你会发现数据库为许多常见的网页框架、交互式地图和内容管理系统提供了后台支持。当你需要深入了解这些应用程序的底层结构时,掌握 SQL 来管理数据和数据库将非常有用。
这本书适合谁?
实用 SQL是为那些在日常生活中接触数据,并希望学习如何分析、管理和转换数据的人而写的。考虑到这一点,我们涵盖了真实世界中的数据和场景,如美国人口普查数据、犯罪报告和纽约市的出租车数据。我们的目标不仅是理解 SQL 的工作原理,还要了解如何使用它来发现有价值的见解。
本书是为初学编程的人写的,因此早期章节涵盖了数据库、数据和 SQL 语法的关键基础。对于一些有 SQL 经验的读者来说,后面的章节会涉及更高级的主题,如地理信息系统(GIS)。我假设你对计算机操作比较熟悉,包括如何安装程序、浏览硬盘以及从互联网上下载文件,但我不假设你有编程或数据分析的经验。
你将学到什么
实用 SQL从设置系统和获取代码及数据示例的章节开始,然后讲解数据库、查询、表格和 SQL 在多个数据库系统中常见的数据基础知识。第 14 至 19 章涵盖了更具体的 PostgreSQL 主题,例如全文搜索、函数和 GIS。尽管本书中的许多章节可以独立阅读,但你应该按顺序阅读整本书,以便逐步掌握基础知识。早期章节中呈现的数据集通常会在后面再次出现,因此按顺序阅读本书有助于你保持进度。
以下总结提供了每一章的更多细节:
第一章:设置你的编码环境介绍了如何设置 PostgreSQL、pgAdmin 用户界面和文本编辑器,并且教你如何下载示例代码和数据。
第二章:创建你的第一个数据库和表格提供了将有关教师的简单数据集加载到新数据库中的逐步说明。
第三章:使用 SELECT 进行数据探索探索了基本的 SQL 查询语法,包括如何对数据进行排序和筛选。
第四章:理解数据类型解释了设置表格列以保存特定类型数据的定义,从文本到日期,再到各种形式的数字。
第五章:导入和导出数据解释了如何使用 SQL 命令从外部文件加载数据并将其导出。你将加载一份美国人口普查数据表,并在整本书中使用它。
第六章:使用 SQL 进行基础数学和统计涵盖了算术运算,并介绍了用于查找总和、平均值和中位数的聚合函数。
第七章:在关系数据库中连接表格解释了如何通过在关键列上连接多个相关表格来进行查询。你将学习如何以及何时使用不同类型的连接。
第八章:适合你的表格设计讲解了如何设置表格,以改善数据的组织性和完整性,同时也教你如何使用索引来加速查询。
第九章:通过分组和汇总提取信息解释了如何使用聚合函数来找出基于年度调查的美国图书馆使用趋势。
第十章:检查和修改数据通过使用关于肉类、蛋类和家禽生产商的记录示例,探索如何查找并修复不完整或不准确的数据。
第十一章:SQL 中的统计函数介绍了相关性、回归、排名等函数,帮助你从数据集中提取更多有意义的信息。
第十二章:处理日期和时间解释了如何在数据库中创建、操作和查询日期和时间,包括如何处理时区以及有关纽约市出租车行程和美铁火车时刻表的数据。
第十三章:高级查询技巧讲解了如何使用更复杂的 SQL 操作,如子查询、交叉表以及CASE语句,来重新分类温度读数数据集中的值。
第十四章:挖掘文本以发现有意义的数据讲解了如何使用 PostgreSQL 的全文搜索引擎和正则表达式从非结构化文本中提取数据,示例包括警察报告和美国总统演讲集。
第十五章:使用 PostGIS 分析空间数据介绍了与空间对象相关的数据类型和查询,这将帮助你分析地理特征,如县、市、道路和河流。
第十六章:使用 JSON 数据介绍了 JavaScript 对象表示法(JSON)数据格式,并通过关于电影和地震的数据探索 PostgreSQL 的 JSON 支持。
第十七章:通过视图、函数和触发器节省时间解释了如何自动化数据库任务,从而避免重复的日常工作。
第十八章:从命令行使用 PostgreSQL讲解了如何在计算机的命令提示符下使用文本命令连接到数据库并执行查询。
第十九章:维护你的数据库提供了跟踪数据库大小、自定义设置和备份数据的技巧和程序。
第二十章:讲述你数据的故事提供了生成分析思路、审查数据、得出合理结论并清晰呈现结果的指南。
附录:PostgreSQL 的其他资源列出了帮助你提高技能的软件和文档。
每一章最后都有一个“自己动手试一试”部分,其中包含练习,帮助你巩固所学的内容。
准备好了吗?让我们从第一章“设置你的编码环境”开始。
第一章:设置你的编码环境

让我们从安装完成本书练习所需的资源开始。在本章中,你将安装一个文本编辑器,下载示例代码和数据,然后安装 PostgreSQL 数据库系统及其伴随的图形用户界面 pgAdmin。我还将告诉你如何在需要时获取帮助。当你完成这些步骤时,你的计算机将拥有一个强大的环境,帮助你学习如何使用 SQL 分析数据。
别急着跳到下一章。我的高中老师(显然是押头韵的爱好者)常告诉我们:“适当的计划可以避免糟糕的表现。” 如果你遵循本章中的所有步骤,之后就能避免麻烦。
我们的第一项任务是设置一个适合处理数据的文本编辑器。
安装文本编辑器
你将添加到 SQL 数据库中的源数据通常存储在多个文本文件中,通常是以逗号分隔值(CSV)的格式存储。你将在第五章的“处理分隔文本文件”部分中深入了解 CSV 格式,但现在让我们确保你有一个文本编辑器,可以让你打开这些文件而不会无意中损坏数据。
常见的商业应用程序——文字处理器和电子表格程序——通常会在文件中引入样式或隐藏字符,且不会征求你的同意,这使得它们在数据工作中的使用变得有问题,因为数据软件期望数据以精确的格式呈现。例如,如果你用 Microsoft Excel 打开一个 CSV 文件,程序会自动修改一些数据,以便更符合人类可读性;它会假设,例如,3-09 是一个日期,并将其格式化为 9-Mar。文本编辑器仅处理纯文本,不会添加任何如格式化之类的修饰,因此程序员使用它们来编辑包含源代码、数据和软件配置的文件——这些都是你希望文本仅被视为文本,没有其他任何处理的情况。
任何文本编辑器都可以满足本书的需求,所以如果你有喜欢的编辑器,尽管使用。以下是我使用过并推荐的一些编辑器。除非另有说明,它们都是免费的,并且适用于 macOS、Windows 和 Linux。
-
微软的 Visual Studio Code:
code.visualstudio.com/ -
GitHub 提供的 Atom:
atom.io/ -
Sublime Text 由 Sublime HQ 提供(可以免费评估,但继续使用需要购买):
www.sublimetext.com/ -
作者 Don Ho 开发的 Notepad++(仅限 Windows):
notepad-plus-plus.org/(注意,这是一个与 Windows 自带的 Notepad.exe 不同的应用程序)
更高级的用户,如果偏好在命令行中工作,可能会想使用这两款默认安装在 macOS 和 Linux 中的文本编辑器:
-
作者 Bram Moolenaar 和开源社区开发的
vim:www.vim.org/ -
由作者 Chris Allegretta 和开源社区开发的 GNU
nano:www.nano-editor.org/
如果你没有文本编辑器,下载并安装一个,并熟悉打开文件夹和操作文件的基础知识。
接下来,让我们获取书中的示例代码和数据。
从 GitHub 下载代码和数据
你在进行书中的练习时所需的所有代码和数据都可以下载。按照以下步骤获取它:
-
访问这本书在 No Starch Press 网站上的页面:
nostarch.com/practical-sql-2nd-edition/。 -
在页面上,点击从 GitHub 下载代码,访问存放材料的
github.com/上的仓库。 -
在 GitHub 上的《Practical SQL 2nd Edition》页面,你应该会看到一个代码按钮。点击它,然后选择下载 ZIP将 ZIP 文件保存到你的电脑。将其放置在你可以轻松找到的位置,比如桌面。(如果你是 GitHub 用户,你也可以克隆或分叉该仓库。)
-
解压文件后,你应该会看到一个名为practical-sql-2-master的文件夹,其中包含书中的各种文件和子文件夹。同样,将此文件夹放在你可以轻松找到的位置。
在practical-sql-2-master文件夹内,每个章节你会找到一个名为Chapter_XX(XX是章节号)的子文件夹。在每个包含代码示例的章节文件夹中,还会有一个名为Chapter_XX的文件,文件扩展名为.sql。这是一个 SQL 代码文件,你可以使用文本编辑器或稍后在本章安装的 PostgreSQL 管理工具打开。请注意,为了节省空间,书中的一些代码示例已被截断,但你需要从.sql文件中获取完整的代码来完成练习。当你看到--snip--时,说明该示例已经被截断。
章节文件夹中还包含了你在练习中使用的公共数据,这些数据存储在 CSV 和其他基于文本的文件中。如前所述,可以使用真正的文本编辑器查看 CSV 文件,但不要使用 Excel 或文字处理软件打开这些文件。
现在,完成了先决条件,让我们加载数据库软件。
安装 PostgreSQL 和 pgAdmin
在这一部分,你将安装 PostgreSQL 数据库系统和一个辅助的图形管理工具 pgAdmin。你可以把 pgAdmin 看作是一个帮助你管理 PostgreSQL 数据库的可视化工作空间。它的界面让你能够查看数据库对象、管理设置、导入和导出数据,以及编写查询,这些查询是用来从数据库中检索数据的代码。
使用 PostgreSQL 的一个好处是开源社区提供了优秀的指南,使得 PostgreSQL 易于安装和运行。以下部分概述了截至目前为止在 Windows、macOS 和 Linux 上的安装步骤,但随着新版本软件或操作系统的发布,步骤可能会有所变化。请查看每个部分中提到的文档,以及包含本书资源的 GitHub 仓库;我会在其中维护更新文件,并回答常见问题。
Windows 安装
对于 Windows,我建议使用由 EDB(原 EnterpriseDB)公司提供的安装程序,该公司为 PostgreSQL 用户提供支持和服务。当你从 EDB 下载 PostgreSQL 包时,你还会获得 pgAdmin 和 Stack Builder,这些工具将在本书中以及整个 SQL 职业生涯中使用。
获取软件,请访问 www.postgresql.org/download/windows/,并在 EDB 部分点击 下载安装程序。这将引导你到 EDB 网站的下载页面。除非你使用的是较老的 32 位 Windows 电脑,否则请选择最新的 64 位 Windows 版本 PostgreSQL。
下载完安装程序后,按照以下步骤安装 PostgreSQL、pgAdmin 和附加组件:
-
右键点击安装程序,选择 以管理员身份运行。对于允许该程序对你的计算机进行更改的提示,选择 是。程序将执行一个设置任务,然后呈现初始欢迎屏幕。点击进入。
-
选择你的安装目录,接受默认设置。
-
在选择组件的屏幕上,勾选安装 PostgreSQL 服务器、pgAdmin 工具、Stack Builder 和命令行工具的框。
-
选择存储数据的位置。你可以选择默认位置,它位于 PostgreSQL 目录中的 data 子目录下。
-
选择一个密码。PostgreSQL 在安全性和权限方面非常强大。这个密码用于默认的初始数据库超级用户账户,该账户名为
postgres。 -
选择服务器监听的默认端口号。如果没有其他数据库或应用程序正在使用此端口,使用默认端口
5432。如果你已经有应用程序使用了默认端口,可以选择5433或其他端口号。 -
选择你的地区语言。使用默认设置即可。然后点击摘要屏幕,开始安装,过程将持续几分钟。
-
安装完成后,系统会询问你是否要启动 EnterpriseDB 的 Stack Builder 来获取附加包。确保勾选该选项并点击 完成。
-
当 Stack Builder 启动时,从下拉菜单中选择 PostgreSQL 安装项,然后点击 下一步。应该会下载一系列附加应用程序。
-
展开空间扩展菜单,选择适合您安装的 PostgreSQL 版本的 PostGIS 包。您可能会看到多个版本,选择最新的一个。另外,展开附加组件、工具和实用程序菜单,选择EDB 语言包,该包安装了包括 Python 在内的编程语言支持。多次点击直到安装程序下载附加组件。
-
当安装文件下载完成后,点击下一步来安装语言和 PostGIS 组件。对于 PostGIS,您需要同意许可协议;点击直到出现选择组件界面。确保选择了 PostGIS 和创建空间数据库。点击下一步,接受默认的安装位置,然后再次点击下一步。
-
在提示时输入数据库密码,并按照提示继续安装 PostGIS。
-
当系统询问是否注册
PROJ_LIB和GDAL_DATA环境变量时,选择是。对于设置POSTGIS_ENABLED_DRIVERS和启用POSTGIS_ENABLE_OUTDB_RASTERS环境变量的问题,也选择是。最后,点击完成步骤以完成安装并退出安装程序。根据版本不同,系统可能会提示你重启电脑。
安装完成后,您的 Windows 开始菜单中应出现两个新文件夹:一个是 PostgreSQL,另一个是 PostGIS。
如果您想立即开始,可以跳过并直接进入“与 pgAdmin 一起工作”部分。否则,请继续下一部分,设置环境变量以支持可选的 Python 语言支持。我们将在第十七章中讨论如何使用 Python 与 PostgreSQL 配合使用;如果您希望现在就继续,可以等到那时再设置 Python。
配置 Python 语言支持
在第十七章中,您将学习如何将 Python 编程语言与 PostgreSQL 一起使用。在上一部分中,您已经安装了 EDB 语言包,它提供了 Python 支持。按照以下步骤将语言包文件的位置添加到 Windows 系统的环境变量中:
-
通过点击 Windows 任务栏上的搜索图标,输入控制面板,然后点击控制面板图标,打开 Windows 控制面板。
-
在控制面板应用中,在搜索框中输入环境。在显示的搜索结果列表中,点击编辑系统环境变量。系统属性对话框将会出现。
-
在系统属性对话框中,点击高级选项卡,点击环境变量。打开的对话框分为两部分:用户变量和系统变量。在系统变量部分,如果没有
PATH变量,继续执行步骤 a 创建一个新的变量。如果已经存在PATH变量,继续执行步骤 b 进行修改。-
如果在系统变量部分没有看到
PATH,点击新建以打开新建系统变量对话框,如图 1-1 所示。![f01001]()
图 1-1:在 Windows 10 中创建新的
PATH环境变量在变量名框中输入 PATH。在变量值框中输入 C:\edb\languagepack\v2\Python-3.9。(你也可以点击 浏览目录,然后在浏览文件夹对话框中导航到该目录。)当你手动输入路径或浏览到路径时,点击对话框中的 确定 以关闭对话框。
-
如果你在系统变量部分看到了现有的
PATH变量,选中它并点击 编辑。在显示的变量列表中,点击 新建 并输入 C:\edb\languagepack\v2\Python-3.9。(你也可以点击浏览目录,然后在浏览文件夹对话框中导航到该目录。)在你添加了语言包路径后,选中它并点击 向上移动,直到该路径出现在变量列表的顶部。这样,PostgreSQL 就能找到正确的 Python 版本,尤其是在你有多个 Python 安装时。
结果应类似于 图 1-2 中的高亮行。点击 确定 关闭对话框。
![f01002]()
图 1-2:编辑 Windows 10 中现有的
PATH环境变量
-
-
最后,在系统变量部分点击 新建。在新建系统变量对话框中,在变量名框中输入 PYTHONHOME。在变量值框中输入 C:\edb\languagepack\v2\Python-3.9。完成后,点击所有对话框中的 确定 以关闭它们。注意,这些 Python 路径设置将在你下次重新启动系统时生效。
如果你在 PostgreSQL 安装过程中遇到任何问题,请查看本书的资源,在那里我会记录随着软件开发而发生的变化,并可以解答问题。如果你无法通过 Stack Builder 安装 PostGIS,尝试从 PostGIS 网站下载一个单独的安装程序,网址为 postgis.net/windows_downloads/,并查阅 postgis.net/documentation/ 上的指南。
现在,你可以继续进行“使用 pgAdmin”部分。
macOS 安装
对于 macOS 用户,我推荐获取 Postgres.app,这是一款开源的 macOS 应用程序,包含 PostgreSQL 以及 PostGIS 扩展和一些其他工具。你还需要单独安装 pgAdmin 图形界面和 Python 语言,以便在函数中使用。
安装 Postgres.app 和 pgAdmin
按照以下步骤操作:
-
访问
postgresapp.com/并下载该应用的最新版本。下载的文件将是一个以 .dmg 结尾的磁盘映像文件。 -
双击 .dmg 文件以打开它,然后将应用程序图标拖放到你的 应用程序 文件夹中。
-
在你的应用程序文件夹中,双击应用图标以启动 Postgres.app。(如果看到一个对话框显示应用无法打开,因为开发者无法验证,请点击取消。然后右键点击应用图标并选择打开。)当 Postgres.app 打开时,点击初始化来创建并启动 PostgreSQL 数据库服务器。
一个小象图标将出现在你的菜单栏中,表示你现在有一个数据库在运行。为了设置包含的 PostgreSQL 命令行工具,以便你以后能够使用它们,打开终端应用程序并在提示符下运行以下单行代码(你可以从 Postgres.app 网站复制该代码,作为一行代码:postgresapp.com/documentation/install.html):
**sudo mkdir -p /etc/paths.d &&**
**echo /Applications/Postgres.app/Contents/Versions/latest/bin | sudo tee /etc/paths.d/postgresapp**
你可能会被提示输入登录 Mac 时使用的密码。请输入密码。命令应在不输出任何内容的情况下执行。
接下来,由于 Postgres.app 不包含 pgAdmin,请按照以下步骤安装 pgAdmin:
-
访问 pgAdmin 网站的 macOS 下载页面
www.pgadmin.org/download/pgadmin-4-macos/。 -
选择最新版本并下载安装程序(寻找一个以 .dmg 结尾的磁盘映像文件)。
-
双击 .dmg 文件,点击通过提示接受条款,然后将 pgAdmin 的象形应用图标拖到你的应用程序文件夹中。
在 macOS 上的安装相对简单,但如果遇到任何问题,请查看 Postgres.app 的文档 postgresapp.com/documentation/ 和 pgAdmin 的文档 www.pgadmin.org/docs/。
安装 Python
在第十七章中,你将学习如何使用 Python 编程语言与 PostgreSQL 配合使用。为了在 Postgres.app 中使用 Python,你必须安装一个特定版本的 Python,尽管 macOS 自带 Python(而且你可能已经设置了额外的 Python 环境)。为了启用 Postgres.app 的可选 Python 语言支持,请按照以下步骤操作:
-
访问官方 Python 网站
www.python.org/,然后点击下载菜单。 -
在发布列表中,找到并下载最新版本的 Python 3.9。选择适合你 Mac 处理器的安装程序——老款 Mac 使用 Intel 芯片,新款 Mac 使用 Apple Silicon。下载的文件是一个以 .pkg 结尾的 Apple 软件包文件。
-
双击安装包文件来安装 Python,按照提示点击同意许可协议。安装完成后关闭安装程序。
Postgres.app 的 Python 需求可能会随时间变化。请检查其 Python 文档 postgresapp.com/documentation/plpython.html 以及本书中的资源,获取最新的更新。
现在你已经准备好进入“使用 pgAdmin”章节。
Linux 安装
如果你是 Linux 用户,安装 PostgreSQL 既简单又复杂,这种体验正是 Linux 世界的一大特色。大多数时候,你可以通过几个命令完成安装,但找到这些命令需要一定的网络搜索技巧。幸运的是,大多数流行的 Linux 发行版——包括 Ubuntu、Debian 和 CentOS——都会将 PostgreSQL 捆绑在其标准软件包中。然而,有些发行版对更新的支持更加及时,因此你下载的 PostgreSQL 可能不是最新版本。最佳做法是查阅你发行版的文档,了解如何安装 PostgreSQL(如果它尚未包含在内)或如何升级到较新的版本。
或者,PostgreSQL 项目维护着适用于 Red Hat 系列、Debian 和 Ubuntu 的最新软件包仓库。请访问yum.postgresql.org/和wiki.postgresql.org/wiki/Apt获取详细信息。你需要安装的包包括 PostgreSQL 的客户端和服务器、pgAdmin(如果有的话)、PostGIS 和 PL/Python。这些包的具体名称会根据你的 Linux 发行版有所不同。你可能还需要手动启动 PostgreSQL 数据库服务器。
pgAdmin 应用程序通常不包含在 Linux 发行版中。要安装它,请参考 pgAdmin 网站上的www.pgadmin.org/download/以获取最新的安装说明,并查看是否支持您的平台。如果你喜欢挑战,也可以在www.pgadmin.org/download/pgadmin-4-source-code/找到从源代码构建应用程序的说明。完成后,可以继续阅读“使用 pgAdmin”部分。
Ubuntu 安装示例
为了让你了解 PostgreSQL 在 Linux 上的安装过程,以下是我在 Ubuntu 21.04(代号 Hirsute Hippo)上加载 PostgreSQL、pgAdmin、PostGIS 和 PL/Python 的步骤。这些步骤结合了wiki.postgresql.org/wiki/Apt上的说明和help.ubuntu.com/community/PostgreSQL/中的“基本服务器设置”部分。如果你使用的是 Ubuntu,可以跟着操作。
按下 ctrl-alt-T 打开终端。然后,在提示符下输入以下命令来导入 PostgreSQL APT 仓库的密钥:
**sudo apt-get install curl ca-certificates gnupg**
**curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -**
接下来,运行以下命令创建文件 /etc/apt/sources.list.d/pgdg.list:
**sudo sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'**
完成后,更新软件包列表,并使用接下来的两行命令安装 PostgreSQL 和 pgAdmin。在这里,我安装了 PostgreSQL 13;如果有较新版本,你可以选择安装它。
**sudo apt-get update**
**sudo apt-get install postgresql-13**
现在,你应该已经启动了 PostgreSQL。在终端中,输入下一行命令,这可以让你登录到服务器,并使用 psql 交互式终端以默认的 postgres 用户身份连接到 postgres 数据库,我们将在第十八章详细介绍:
**sudo -u postgres psql postgres**
当 psql 启动时,它会显示版本信息以及一个 postgres=# 提示符。在提示符下输入以下命令来设置密码:
`postgres=#` **\password postgres**
我还喜欢创建一个用户名与我的 Ubuntu 用户名匹配的用户账户。为此,在 postgres=# 提示符下,输入以下命令,并将 anthony 替换为你的 Ubuntu 用户名:
`postgres=#` **CREATE USER** **anthony SUPERUSER;**
输入 \q 退出 psql,你应该会回到终端提示符。
要安装 pgAdmin,首先导入仓库的密钥:
**curl https://www.pgadmin.org/static/packages_pgadmin_org.pub | sudo apt-key add**
接下来,运行以下命令来创建文件 /etc/apt/sources.list.d/pgadmin4.list 并更新软件包列表:
**sudo sh -c 'echo "deb https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/$(lsb_release -cs) pgadmin4 main" > /etc/apt/sources.list.d/pgadmin4.list && apt update'**
然后,你可以安装 pgAdmin 4:
**sudo apt-get install pgadmin4-desktop**
最后,为了安装 PostGIS 和 PL/Python 扩展,在终端中运行以下命令(根据你的 PostgreSQL 版本替换版本号):
**sudo apt install** **postgresql-13-postgis-3**
**sudo apt install** **postgresql-plpython3-13**
查看官方的 Ubuntu 和 PostgreSQL 文档以获取更新。如果遇到任何错误,通常在 Linux 系统中进行在线搜索会获得有用的提示。
使用 pgAdmin
设置的最后一步是熟悉 pgAdmin,这是一个用于 PostgreSQL 的管理工具。pgAdmin 软件是免费的,但不要低估它的性能;它是一个功能齐全的工具,功能与 Microsoft 的 SQL Server Management Studio 等付费工具一样强大。使用 pgAdmin,你可以获得一个图形界面,配置 PostgreSQL 服务器和数据库的多个方面,并且——最适合本书的内容——使用 SQL 查询工具来编写、运行和保存查询。
启动 pgAdmin 并设置主密码
假设你之前按照本章中的操作系统安装步骤完成了安装,接下来是如何启动 pgAdmin:
-
Windows:进入开始菜单,找到你安装的 PostgreSQL 版本文件夹,点击它,然后选择 pgAdmin4。
-
macOS:点击 应用程序 文件夹中的 pgAdmin 图标,确保你已经启动了 Postgres.app。
-
Linux:启动方式可能会根据你的 Linux 发行版有所不同。通常,在终端提示符下输入
pgadmin4并按回车。在 Ubuntu 中,pgAdmin 会作为一个应用程序出现在活动概览中。
你应该会看到 pgAdmin 欢迎界面,随后应用程序会打开,如 图 1-3 所示。如果这是你第一次启动 pgAdmin,你还会收到提示设置主密码。这个密码与安装 PostgreSQL 时设置的密码无关。设置主密码并点击 OK。

图 1-3:在 Windows 10 上运行的 pgAdmin 应用程序
pgAdmin 布局包括一个显示对象浏览器的左侧垂直窗格,你可以在其中查看可用的服务器、数据库、用户及其他对象。屏幕顶部是一组菜单项,下面是标签页,用于显示数据库对象和性能的不同方面。接下来,让我们连接到你的数据库。
连接到默认的 postgres 数据库
PostgreSQL 是一个数据库管理系统,意味着它是一个允许你定义、管理和查询数据库的软件。当你安装 PostgreSQL 时,它创建了一个数据库服务器——一个在你的计算机上运行的应用程序实例——其中包含一个名为 postgres 的默认数据库。数据库是一个对象集合,包含表格、函数等更多内容,实际数据就存储在这里。我们使用 SQL 语言(以及 pgAdmin)来管理存储在数据库中的对象和数据。
在下一章中,你将在 PostgreSQL 服务器上创建自己的数据库,以组织你的工作。现在,我们先连接到默认的 postgres 数据库来探索 pgAdmin。请按照以下步骤操作:
-
在对象浏览器中,点击位于“服务器”节点左侧的向下箭头,显示默认服务器。根据你的操作系统,默认服务器名称可能是 localhost 或 PostgreSQL x,其中 x 是 Postgres 版本号。
-
双击服务器名称。如果提示,输入你在安装过程中选择的数据库密码(你可以选择保存密码,以便以后不再需要输入)。在 pgAdmin 建立连接时,会显示一个简短的消息。当连接成功时,服务器名称下应该会显示多个新对象项。
-
展开数据库,然后展开默认数据库
postgres。 -
在
postgres下,展开 Schemas 对象,然后展开 public。
你的对象浏览器窗格应类似于图 1-4。

图 1-4:pgAdmin 对象浏览器
这一对象集合定义了你的数据库服务器的每个功能,包括表格,我们在其中存储数据。要查看表格结构或使用 pgAdmin 对其执行操作,你可以在此访问表格。在第二章中,你将使用此浏览器创建一个新数据库,并保持默认的 postgres 不变。
探索查询工具
pgAdmin 应用程序包含一个查询工具,在这里你可以编写和执行代码。要打开查询工具,在 pgAdmin 的对象浏览器中,首先单击任意数据库以将其高亮显示。例如,点击 postgres 数据库,然后选择 工具 ▶ 查询工具。你将看到三个窗格:一个查询编辑器,一个用于存放代码片段的临时编辑区,以及一个显示查询结果的数据输出窗格。你可以打开多个标签页,连接并为不同的数据库编写查询,或者仅仅以你喜欢的方式组织代码。要打开另一个标签页,只需在对象浏览器中点击一个数据库,再通过菜单打开查询工具。
让我们运行一个简单的查询并查看其输出,使用清单 1-1 中的语句,它返回你安装的 PostgreSQL 版本。此代码以及本书中的所有示例均可以通过点击nostarch.com/practical-sql-2nd-edition/网站上的Download the code from GitHub链接进行下载。
SELECT version();
清单 1-1:检查你的 PostgreSQL 版本
将代码输入查询编辑器,或者如果你从 GitHub 下载了本书的代码,点击 pgAdmin 工具栏上的打开文件图标,导航到保存代码的文件夹,打开Chapter_01文件夹中的Chapter_01.sql文件。要执行语句,选择以SELECT开头的行并点击工具栏上的执行/刷新图标(它的形状像一个播放按钮)。PostgreSQL 应当在 pgAdmin 数据输出面板中返回服务器的版本,如图 1-5 所示(你可能需要通过点击数据输出面板右边缘并向右拖动,来展开列以查看完整的结果)。
你将在本书后续章节中学到更多关于查询的内容,但现在你只需要知道的是,这个查询使用了一个 PostgreSQL 特有的函数,它叫做version(),用来检索服务器的版本信息。在我的例子中,输出显示我运行的是 PostgreSQL 13.3,并且提供了软件构建的额外信息。

图 1-5:pgAdmin 查询工具显示查询结果
自定义 pgAdmin
从 pgAdmin 菜单中选择文件▶首选项会打开一个对话框,在该对话框中你可以自定义 pgAdmin 的外观和选项。以下是你现在可能想要访问的三项设置:
-
杂项▶主题允许你在标准的浅色 pgAdmin 主题和深色主题之间进行选择。
-
查询工具▶结果网格允许你设置查询结果中的最大列宽。在该对话框中,选择列数据,并在最大列宽中输入300。
-
浏览器部分让你配置 pgAdmin 布局并设置键盘快捷键。
若要获取 pgAdmin 选项的帮助,请从菜单中选择帮助▶在线帮助。在继续之前,随时可以进一步探索首选项。
pgAdmin 的替代方案
虽然 pgAdmin 非常适合初学者,但你并不需要在这些练习中使用它。如果你更喜欢使用其他可以与 PostgreSQL 配合的管理工具,随时可以使用。如果你希望在本书的所有练习中使用系统的命令行,18 章提供了如何在命令行中使用 PostgreSQL 交互式终端psql的说明。(附录列出了你可以探索的 PostgreSQL 资源,以寻找更多的管理工具。)
总结
现在你已经通过代码、文本编辑器、PostgreSQL 和 pgAdmin 设置好了环境,你可以开始学习 SQL 并使用它来发现数据中的宝贵见解!
在第二章,你将学习如何创建一个数据库和一个表格,然后加载一些数据来探索其内容。让我们开始吧!
第二章:创建你的第一个数据库和表格

SQL 不仅仅是从数据中提取知识的手段。它还是一种定义存储数据的结构的语言,使我们能够组织数据中的关系。其中最重要的结构就是表格。
表格是一个由行和列组成的数据网格。每一行包含一组列,每一列包含一种指定类型的数据:最常见的是数字、字符和日期。我们使用 SQL 来定义表格的结构,以及每个表格如何与数据库中的其他表格相关联。我们还使用 SQL 从表格中提取或查询数据。
在本章中,你将创建你的第一个数据库,添加一个表格,然后使用 SQL 在 pgAdmin 界面中插入几行数据。接着,你将使用 pgAdmin 来查看结果。让我们先从表格开始。
理解表格
了解你的表格是理解数据库中数据的基础。每当我开始使用一个新的数据库时,我做的第一件事就是查看其中的表格。我通过表格名称和列结构寻找线索。表格是包含文本、数字还是两者兼有?每个表格有多少行?
接下来,我会看看数据库中有多少个表。最简单的数据库可能只有一个表。一个处理客户数据或追踪航空旅行的完整应用程序可能包含几十个或上百个表。表的数量不仅告诉我需要分析多少数据,还暗示着我应该探索各表之间的数据关系。
在你深入研究 SQL 之前,让我们看一个表格内容的示例。我们将使用一个假设的数据库来管理学校的班级注册;在这个数据库中,有几个表格用来追踪学生和他们的课程。第一个表格叫做 student_enrollment,显示了每个班级部分注册的学生:
student_id class_id class_section semester
---------- ---------- ------------- ---------
CHRISPA004 COMPSCI101 3 Fall 2023
DAVISHE010 COMPSCI101 3 Fall 2023
ABRILDA002 ENG101 40 Fall 2023
DAVISHE010 ENG101 40 Fall 2023
RILEYPH002 ENG101 40 Fall 2023
该表显示了有两个学生注册了 COMPSCI101 课程,三个学生注册了 ENG101 课程。但是,关于每个学生和每门课程的详细信息在哪里呢?在这个例子中,这些细节存储在名为 students 和 classes 的单独表格中,而这些表格与当前表格相关联。这就是关系型数据库开始展现其强大功能的地方。
students 表的前几行包括以下内容:
student_id first_name last_name dob
---------- ---------- --------- ----------
ABRILDA002 Abril Davis 2005-01-10
CHRISPA004 Chris Park 1999-04-10
DAVISHE010 Davis Hernandez 2006-09-14
RILEYPH002 Riley Phelps 2005-06-15
students 表格包含了每个学生的详细信息,使用 student_id 列中的值来标识每个学生。该值充当一个唯一的键,将两个表格连接起来,使你能够创建如下所示的行,包含 student_enrollment 表中的 class_id 列以及 students 表中的 first_name 和 last_name 列:
class_id first_name last_name
---------- ---------- ---------
COMPSCI101 Davis Hernandez
COMPSCI101 Chris Park
ENG101 Abril Davis
ENG101 Davis Hernandez
ENG101 Riley Phelps
classes 表的工作方式也类似,包含一个 class_id 列和几个关于课程的详细信息列。数据库开发者喜欢使用单独的表来组织数据,每个表管理数据库中的一个主要 实体,以减少冗余数据。在这个示例中,我们只存储每个学生的姓名和出生日期一次。即使学生报名参加了多门课程——就像 Davis Hernandez 一样——我们也不会在 student_enrollment 表中每一门课程旁边重复输入他的名字,而是只包含他的学生 ID。
鉴于表是每个数据库的核心构建块,本章你将通过在新数据库中创建表开始你的 SQL 编程冒险。然后,你将向表中加载数据并查看完成的表。
创建数据库
你在第一章安装的 PostgreSQL 程序是一个 数据库管理系统,一个软件包,允许你定义、管理和查询存储在数据库中的数据。数据库是一个包含表、函数等的对象集合。当你安装 PostgreSQL 时,它创建了一个 数据库服务器——运行在你计算机上的应用实例——并包括一个名为 postgres 的默认数据库。
根据 PostgreSQL 文档,默认的 postgres 数据库是“供用户、工具和第三方应用程序使用的”(参见 www.postgresql.org/docs/current/app-initdb.html)。我们将创建一个新的数据库来用于本书中的示例,而不是使用默认数据库,这样我们可以将与特定主题或应用程序相关的对象组织在一起。这是一个好习惯:它有助于避免在单一数据库中堆积没有关系的表,并确保如果你的数据将被用于支持应用程序,比如移动应用程序,那么应用程序的数据库只会包含相关信息。
要创建一个数据库,你只需要一行 SQL,如 清单 2-1 所示,我们稍后将使用 pgAdmin 运行这行代码。你可以在通过 www.nostarch.com/practical-sql-2nd-edition/ 链接下载的 GitHub 文件中找到这段代码以及本书中的所有示例。
CREATE DATABASE analysis;
清单 2-1:创建名为 analysis 的数据库
这个语句使用默认的 PostgreSQL 设置在你的服务器上创建一个名为 analysis 的数据库。请注意,代码由两个关键字组成——CREATE 和 DATABASE——后面跟着新数据库的名称。你以分号结束该语句,表示命令的结束。你必须用分号结束所有 PostgreSQL 语句,这是 ANSI SQL 标准的一部分。在某些情况下,即使你省略分号,查询也能正常工作,但并非总是如此,所以养成使用分号的好习惯是明智的。
在 pgAdmin 中执行 SQL
在第一章中,你安装了图形化管理工具 pgAdmin(如果没有,请现在安装)。在我们的大部分工作中,你将使用 pgAdmin 来执行你编写的 SQL 语句,称为执行代码。在本书的第十八章,我将向你展示如何使用 PostgreSQL 命令行程序psql在终端窗口中执行 SQL 语句,但通过图形界面开始更为简单。
我们将使用 pgAdmin 来执行清单 2-1 中创建数据库的 SQL 语句。然后,我们将连接到新数据库并创建表。请按照以下步骤操作:
-
运行 PostgreSQL。如果你使用的是 Windows,安装程序会将 PostgreSQL 设置为每次启动时自动启动。在 macOS 上,你必须双击应用程序文件夹中的Postgres.app(如果你在菜单栏中看到大象图标,那么它已经在运行了)。
-
启动 pgAdmin。系统会提示你输入首次启动应用程序时设置的 pgAdmin 主密码。
-
正如你在第一章中所做的,在左侧垂直窗格(对象浏览器)中,点击服务器节点左侧的箭头,显示默认服务器。根据你安装 PostgreSQL 的方式,默认服务器可能命名为localhost或PostgreSQL x,其中x是应用程序的版本。你可能会收到另一个密码提示。这个提示是针对 PostgreSQL 的,而不是 pgAdmin 的,所以请输入你在安装 PostgreSQL 时设置的密码。你应该会看到 pgAdmin 正在建立连接的简短信息。
-
在 pgAdmin 的对象浏览器中,展开Databases,点击一次
postgres以突出显示它,如图 2-1 所示。![f02001]()
图 2-1:默认的
postgres数据库 -
通过选择工具▶查询工具来打开查询工具。
-
在查询编辑器窗格(顶部水平窗格)中,输入清单 2-1 中的代码。
-
点击执行/刷新图标(形状像右箭头)来执行语句。PostgreSQL 将创建数据库,在查询工具的输出窗格中的消息部分,你会看到一条表示查询成功执行的通知,如图 2-2 所示。
![f02002]()
图 2-2:创建一个名为
analysis的数据库 -
要查看你的新数据库,在对象浏览器中右键点击Databases。从弹出菜单中选择刷新,然后
analysis数据库将出现在列表中,如图 2-3 所示。

图 2-3:在对象浏览器中显示的analysis数据库
干得不错!你现在拥有一个名为analysis的数据库,你可以使用它来进行本书中的大多数练习。在你自己的工作中,通常的最佳实践是为每个项目创建一个新的数据库,以便将相关数据的表格放在一起。
连接到 analysis 数据库
在创建表之前,你必须确保 pgAdmin 连接的是analysis数据库,而不是默认的postgres数据库。
要做到这一点,请按照以下步骤操作:
-
通过点击工具面板最右侧的X关闭查询工具。出现提示时,无需保存文件。
-
在对象浏览器中,点击分析一次。
-
通过选择工具▶查询工具,打开一个新的查询工具窗口,并连接到
analysis数据库。 -
现在,你应该能在查询工具窗口顶部看到
analysis/postgres@localhost的标签。(再次提醒,可能你的版本显示的不是localhost,而是PostgreSQL。)
现在,任何你执行的代码都将应用于analysis数据库。
创建表格
如我所提到的,表格是数据存储和定义关系的地方。当你创建一个表格时,你需要为每个列(有时称为字段或属性)指定一个名称,并为每列指定一个数据类型。这些数据类型定义了该列可以接受的值——例如文本、整数、小数和日期——并且数据类型的定义是 SQL 用来确保数据完整性的一种方式。例如,定义为date的列只能接受某几种标准格式的数据,如YYYY-MM-DD。如果你尝试输入不符合日期格式的字符,比如单词peach,系统会报错。
存储在表格中的数据可以通过 SQL 语句进行访问和分析,或者查询。你可以对数据进行排序、编辑和查看,并且如果需求发生变化,之后也可以轻松地修改表格。
让我们在analysis数据库中创建一个表格。
使用 CREATE TABLE 语句
在这个练习中,我们将使用一个常常讨论的数据:教师薪资。列表 2-2 展示了创建一个名为teachers的表格的 SQL 语句。在你将代码输入 pgAdmin 并执行之前,我们先来回顾一下这段代码。
1 CREATE TABLE teachers (
2 id bigserial,
3 first_name varchar(25),
last_name varchar(50),
school varchar(50),
4 hire_date date,
5 salary numeric
6 );
列表 2-2:创建一个名为teachers的表格,包含六个列
这个表格定义远不完整。例如,它缺少了几个约束,这些约束能够确保必须填写的列确实包含数据,或者确保我们不会无意中输入重复的值。关于约束的详细内容,我将在第八章中讲解,但在这些早期章节中,我将它们省略,以便专注于帮助你开始探索数据。
代码以两个 SQL 关键字CREATE和TABLE开始,1 这两个关键字与名称teachers一起,告诉 PostgreSQL 接下来的代码描述的是要添加到数据库中的表格。在开括号后,语句包括一系列列名称及其数据类型,列名称和数据类型之间用逗号分隔。为了更好的可读性,每行新代码单独占一行并缩进四个空格,虽然这不是必需的,但它使代码更容易阅读。
每个列名代表一个由数据类型定义的独立数据元素。id 列的类型为 bigserial,这是一个特殊的自增整数类型,每当你向表中添加一行时,它会自动递增。第一行在 id 列的值为 1,第二行为 2,以此类推。bigserial 数据类型以及其他序列类型是 PostgreSQL 特有的实现,但大多数数据库系统都有类似的功能。
接下来,我们为教师的名字、姓氏以及他们所教学校创建列。每列的类型为 varchar,这是一种文本列,最大长度由括号中的数字指定。我们假设数据库中的每个姓氏都不会超过 50 个字符。尽管这是一个安全的假设,但你会发现随着时间的推移,例外总是会让你吃惊。
教师表的 hire_date 列的类型设置为 date,salary 列的类型为 numeric。数据类型的详细介绍将在第四章中讲解,但此表展示了一些常见的数据类型示例。代码块以闭合括号和分号结束。
现在你已经对 SQL 有了一些了解,让我们在 pgAdmin 中运行这段代码。
创建教师表
你已经有了代码,并且连接到了数据库,所以可以使用与创建数据库时相同的步骤来创建表:
-
打开 pgAdmin 查询工具(如果没有打开,点击 pgAdmin 对象浏览器中的
analysis,然后选择 Tools▶Query Tool)。 -
将 Listing 2-2 中的
CREATE TABLE脚本复制到 SQL 编辑器中(或者,如果你选择使用查询工具打开 GitHub 上的 Chapter_02.sql 文件,直接高亮列出该内容)。 -
点击 Execute/Refresh 图标(右箭头形状)执行脚本。
如果一切顺利,你将在 pgAdmin 查询工具的底部输出窗格中看到一条消息,内容为 Query returned successfully with no result in 84 msec。当然,毫秒数会根据你的系统有所不同。
现在,找到你创建的表。返回到主 pgAdmin 窗口,在对象浏览器中右击 analysis 并选择 Refresh。选择 Schemas▶public▶Tables 查看你的新表,如图 2-4 所示。

图 2-4:对象浏览器中的 teachers 表
点击 teachers 表名称左侧的箭头展开该表节点。这将显示更多关于该表的详细信息,包括列名称,如图 2-5 所示。还会显示其他信息,如索引、触发器和约束,但这些内容将在后续章节中讲解。点击表名称后,在 pgAdmin 工作区选择 SQL 菜单,将显示用于重新创建 teachers 表的 SQL 语句(注意,这些显示包括在创建表时隐式添加的额外默认标记)。

图 2-5:teachers 表的详细信息
恭喜!到目前为止,你已经构建了一个数据库并向其中添加了一个表。下一步是向表中添加数据,以便你可以编写第一个查询。
向表中插入行
你可以通过多种方式将数据添加到 PostgreSQL 表中。通常,你会处理大量的行,因此最简单的方法是从文本文件或其他数据库直接将数据导入表中。但是,为了开始,我们将使用 INSERT INTO ... VALUES 语句添加几行数据,该语句指定了目标列和数据值。接下来,我们将查看数据在新位置的表现。
使用 INSERT 语句
要向表中插入数据,首先需要删除你刚刚运行的 CREATE TABLE 语句。然后,按照你创建数据库和表时的相同步骤,将 列表 2-3 中的代码复制到你的 pgAdmin 查询工具中(或者,如果你在查询工具中打开了 Chapter_02.sql 文件,可以选中这个列表)。
1 INSERT INTO teachers (first_name, last_name, school, hire_date, salary)
2 VALUES ('Janet', 'Smith', 'F.D. Roosevelt HS', '2011-10-30', 36200),
('Lee', 'Reynolds', 'F.D. Roosevelt HS', '1993-05-22', 65000),
('Samuel', 'Cole', 'Myers Middle School', '2005-08-01', 43500),
('Samantha', 'Bush', 'Myers Middle School', '2011-10-30', 36200),
('Betty', 'Diaz', 'Myers Middle School', '2005-08-30', 43500),
('Kathleen', 'Roush', 'F.D. Roosevelt HS', '2010-10-22', 38500);3
列表 2-3:将数据插入 teachers 表
这段代码插入了六个教师的姓名和数据。这里,PostgreSQL 语法遵循 ANSI SQL 标准:在 INSERT INTO 关键字之后是表的名称,括号内是要填充的列 1。在下一行是 VALUES 关键字以及每一行每个列的插入数据 2。你需要将每一行的数据用一对括号括起来,并在括号内使用逗号分隔每个列值。数据的顺序也必须与表名后指定的列的顺序相匹配。每行数据以逗号结尾,除了最后一行,它以分号结尾,结束整个语句 3。
注意到我们插入的某些值被单引号括起来,但有些则没有。这是 SQL 的标准要求。文本和日期需要使用引号;数字,包括整数和小数,则不需要引号。我会在示例中突出显示这一要求。另外,请注意我们使用的日期格式:四位数的年份后跟月份和日期,每个部分之间用连字符连接。这是国际标准的日期格式;使用它可以帮助你避免混淆。(为什么最好使用 YYYY-MM-DD 格式?可以查看 xkcd.com/1179/ 了解更多相关漫画。)PostgreSQL 支持许多额外的日期格式,我会在示例中使用几种。
你可能会对 id 列感到好奇,它是表中的第一列。当你创建表时,你的脚本指定了该列为 bigserial 数据类型。因此,当 PostgreSQL 插入每一行时,它会自动用自增的整数填充 id 列。这个问题我将在第四章详细讨论,当时我会讲解数据类型。
现在,运行代码。这时,查询工具的消息区域应该显示如下内容:
INSERT 0 6
Query returned successfully in 150 msec.
INSERT关键字后面的两个数字中的最后一个表示插入的行数:6。第一个数字是一个未使用的遗留 PostgreSQL 值,仅为保持网络协议而返回;你可以安全地忽略它。
查看数据
你可以通过 pgAdmin 快速查看刚刚加载到teachers表中的数据。在对象浏览器中,找到该表并右击。在弹出菜单中,选择查看/编辑数据▶所有行。正如图 2-6 所示,你会看到表中六行数据,每列都由 SQL 语句中的值填充。

图 2-6:直接在 pgAdmin 中查看表数据
注意,尽管你没有为id列插入值,但每个教师都有分配的 ID 号码。此外,每个列标题会显示你在创建表时定义的数据类型。(注意,在这个例子中,PostgreSQL 中完全展开的varchar是character varying。)在查看结果时看到数据类型会帮助你在后续编写查询时,依照数据类型来处理数据。
你可以通过 pgAdmin 界面以几种方式查看数据,但我们将重点介绍编写 SQL 来处理这些任务。
代码出错时寻求帮助
也许有一个宇宙,代码总是能正常工作,但不幸的是,我们还没有发明出能够把我们带到那个地方的机器。错误是不可避免的。无论是打错字还是搞错了运算顺序,计算机语言在语法方面非常苛刻。例如,如果你忘记了在列表 2-3 中的代码中加逗号,PostgreSQL 会返回一个错误:
ERROR: syntax error at or near "("
LINE 4: ('Samuel', 'Cole', 'Myers Middle School', '2005-08-01', 43...
^
幸运的是,错误信息给出了问题的提示和位置:我们在第 4 行的左括号附近犯了一个语法错误。但有时,错误信息可能更为晦涩。在这种情况下,你可以像最优秀的程序员一样:快速在互联网上搜索错误信息。很可能其他人也遇到过同样的问题,并且可能知道答案。我发现,通过在搜索引擎中逐字输入错误信息,并指定我的数据库管理工具名称,再将结果限制为更新的项目,能获得最佳的搜索结果,从而避免使用过时的信息。
为了可读性而格式化 SQL
SQL 执行时不需要特殊格式化,因此你可以随意使用大写、小写和随机缩进的迷幻风格。但当别人需要与你的代码合作时(迟早会有这种情况),这种风格可不会为你赢得朋友。为了可读性并成为一个好程序员,以下是几条普遍接受的约定:
-
使用大写字母编写 SQL 关键字,如
SELECT。有些 SQL 程序员也将数据类型的名称写成大写,如TEXT和INTEGER。在本书中,我使用小写字母来表示数据类型,以便与你脑海中的关键字区分开来,但如果你喜欢,也可以将它们写成大写字母。 -
避免使用驼峰命名法,而应使用
lowercase_and_underscores来命名对象,例如表和列名(关于大小写的更多细节将在第八章中介绍)。 -
为了提高可读性,使用两个或四个空格缩进子句和代码块。一些程序员偏好使用制表符而非空格;你可以根据自己的需要或你所在组织的规范来选择使用哪种方式。
在本书中,我们将探索其他 SQL 编码约定,但这些是基础知识。
总结
在本章中,你完成了相当多的内容:你创建了一个数据库和一个表,并将数据加载到其中。你已经开始将 SQL 添加到数据分析工具包中了!在下一章中,你将使用这组教师数据来学习如何使用SELECT查询表的基本知识。
第三章:使用 SELECT 开始数据探索

对我来说,深入挖掘数据的最好部分不是收集、加载或清洗数据的前提工作,而是当我真正开始 访谈 数据时。那些时刻我会发现数据是否干净、是否完整,最重要的是,它能够讲述什么样的故事。可以把访谈数据看作是类似于面试求职者的过程。你想问一些问题,揭示出他们的实际专业水平是否与简历匹配。
访谈数据令人兴奋,因为你会发现真相。例如,你可能会发现一半的受访者忘记填写问卷中的电子邮件字段,或者市长过去五年没有缴纳物业税。或者你可能会发现你的数据是脏的:名字拼写不一致,日期不正确,或者数字与预期不符。这些发现会成为数据故事的一部分。
在 SQL 中,访问数据从 SELECT 关键字开始,它从数据库中的一个或多个表中检索行和列。一个 SELECT 语句可以很简单,检索一个表中的所有内容,也可以足够复杂,连接数十个表,处理多个计算,并按精确条件进行过滤。
我们将从简单的 SELECT 语句开始,然后深入了解 SELECT 可以做的更强大操作。
基本的 SELECT 语法
这里是一个 SELECT 语句,它从一个名为 my_table 的表中提取每一行和每一列:
SELECT * FROM my_table;
这一行代码展示了 SQL 查询的最基本形式。SELECT 关键字后面的星号是一个 通配符,它像是一个占位符:它不代表特定的任何东西,而是代表那个值可能是的所有内容。在这里,它是“选择所有列”的简写。如果你给出了列名而不是通配符,这个命令会选择该列中的值。FROM 关键字表示你希望查询从某个特定的表中返回数据。表名后的分号告诉 PostgreSQL 这是查询语句的结束。
让我们使用这个带有星号通配符的 SELECT 语句,针对你在第二章创建的 teachers 表。再次打开 pgAdmin,选择 analysis 数据库,打开查询工具。然后执行 Listing 3-1 中显示的语句。记住,作为输入这些语句的替代方式,你也可以通过点击 打开文件 并导航到保存你从 GitHub 下载的代码的位置来运行代码。如果你看到代码被 --snip-- 截断了,务必使用这种方法。对于本章,你应该打开 Chapter_03.sql 并在点击 执行/刷新 图标之前高亮每个语句。
SELECT * FROM teachers;
Listing 3-1:从 teachers 表查询所有行和列
一旦执行查询,查询工具的输出面板中将显示你在第二章中插入到teachers表中的所有行和列。行的顺序可能不会总是这样显示,但这没关系。
id first_name last_name school hire_date salary
-- ---------- --------- ------------------- ---------- ------
1 Janet Smith F.D. Roosevelt HS 2011-10-30 36200
2 Lee Reynolds F.D. Roosevelt HS 1993-05-22 65000
3 Samuel Cole Myers Middle School 2005-08-01 43500
4 Samantha Bush Myers Middle School 2011-10-30 36200
5 Betty Diaz Myers Middle School 2005-08-30 43500
6 Kathleen Roush F.D. Roosevelt HS 2010-10-22 38500
请注意,id列(类型为bigserial)会自动填充顺序整数,即使你没有显式插入它们。这非常方便。这个自动递增的整数充当唯一标识符或键,不仅确保表中的每一行都是唯一的,还能让我们稍后将此表与数据库中的其他表连接起来。
在继续之前,注意你有两种其他方式可以查看表中的所有行。使用 pgAdmin,你可以右键点击对象树中的teachers表,然后选择查看/编辑数据▶所有行。或者,你可以使用一种鲜为人知的标准 SQL 方法:
TABLE teachers;
这两者提供的结果与清单 3-1 中的代码相同。现在,让我们优化这个查询,使其更具针对性。
查询列的子集
通常,限制查询返回的列是更实用的,特别是在处理大数据库时,这样你就不必浏览过多的信息。你可以通过在SELECT关键字后列出列名并用逗号分隔来实现。以下是一个示例:
SELECT some_column, another_column, amazing_column FROM table_name;
使用这种语法,查询将仅从这三列中检索所有行。
让我们将此应用于teachers表。也许在你的分析中,你想重点关注教师的姓名和薪水。在这种情况下,你只需选择相关的列,如清单 3-2 所示。请注意,查询中列的顺序与表中的顺序不同:你可以按照任何你喜欢的顺序检索列。
SELECT last_name, first_name, salary FROM teachers;
清单 3-2:查询列的子集
现在,在结果集里,你已将列限制为三列:
last_name first_name salary
--------- ---------- ------
Smith Janet 36200
Reynolds Lee 65000
Cole Samuel 43500
Bush Samantha 36200
Diaz Betty 43500
Roush Kathleen 38500
尽管这些示例很基础,但它们展示了开始数据集分析的一个好策略。通常,开始分析时最好先检查你的数据是否存在并且格式符合预期,这是SELECT非常适合完成的任务。日期是否按正确格式输入,包含了月、日和年,还是像我曾经遗憾地观察到的那样,只输入了月和年作为文本?每行是否在所有列中都有值?是否没有以M字母开头的姓氏?所有这些问题都指示着潜在的风险,从缺失数据到某个环节的记录不当。
我们只处理一个包含六行的表,但当你面对一个有成千上万甚至百万行的表时,快速了解数据质量和它包含的值范围就显得至关重要。为此,让我们更深入地挖掘,并添加几个 SQL 关键字。
使用 ORDER BY 排序数据
当数据按顺序排列时,通常更容易理解,并且可能更容易揭示出模式,而不是随意混乱地排列。
在 SQL 中,我们使用包含关键字ORDER BY的子句对查询结果进行排序,后面跟上要排序的列名。应用这个子句不会改变原始表格,只会改变查询的结果。列表 3-3 展示了一个使用teachers表的示例。
SELECT first_name, last_name, salary
FROM teachers
ORDER BY salary DESC;
列表 3-3:使用ORDER BY对一列进行排序
默认情况下,ORDER BY按升序排列值,但在这里我通过添加DESC关键字进行降序排序。(可选的ASC关键字指定升序排序。)现在,通过按从高到低的顺序排列salary列,我可以确定哪些教师收入最高:
first_name last_name salary
---------- --------- ------
Lee Reynolds 65000
Samuel Cole 43500
Betty Diaz 43500
Kathleen Roush 38500
Janet Smith 36200
Samantha Bush 36200
ORDER BY子句也接受数字而非列名,数字根据其在SELECT子句中的位置来确定排序的列。因此,你可以这样重写列表 3-3,使用3来引用SELECT子句中的第三列salary:
SELECT first_name, last_name, salary
FROM teachers
ORDER BY 3 DESC;
在查询中排序的能力为我们提供了极大的灵活性,帮助我们以不同方式查看和展示数据。例如,我们不必局限于仅对一列进行排序。输入列表 3-4 中的语句。
SELECT last_name, school, hire_date
FROM teachers
1 ORDER BY school ASC, hire_date DESC;
列表 3-4:使用ORDER BY对多个列进行排序
在这种情况下,我们检索教师的姓氏、学校和聘用日期。通过按升序排序school列和按降序排序hire_date,我们创建了一个按学校分组的教师列表,其中最新聘用的教师排在前面。这可以让我们看到每所学校的最新教师。结果集应该如下所示:
last_name school hire_date
--------- ------------------- ----------
Smith F.D. Roosevelt HS 2011-10-30
Roush F.D. Roosevelt HS 2010-10-22
Reynolds F.D. Roosevelt HS 1993-05-22
Bush Myers Middle School 2011-10-30
Diaz Myers Middle School 2005-08-30
Cole Myers Middle School 2005-08-01
你可以在两个以上的列上使用ORDER BY,但很快你会发现效果开始递减,几乎难以察觉。如果你在ORDER BY子句中加入关于教师最高学历、所教年级和出生日期的列,那么在输出中一次性理解各种排序方向将变得非常困难,更不用说将其传达给他人了。数据的消化最容易发生在结果专注于回答特定问题时;因此,更好的策略是将查询中的列数限制为最重要的列,并进行多个查询来回答每个问题。
使用 DISTINCT 查找唯一值
在表格中,某一列包含重复值的行并不罕见。例如,在teachers表中,school列多次列出了相同的学校名称,因为每所学校有很多教师。
为了了解列中的值的范围,我们可以使用DISTINCT关键字,它是查询的一部分,能够消除重复项并仅显示唯一值。如列表 3-5 所示,DISTINCT应紧跟在SELECT之后使用。
SELECT DISTINCT school
FROM teachers
ORDER BY school;
列表 3-5:查询school列中的唯一值
结果如下所示:
school
-------------------
F.D. Roosevelt HS
Myers Middle School
尽管表中有六行数据,输出只显示 school 列中的两个唯一学校名称。这是评估数据质量的一个有用的第一步。例如,如果一个学校名称有多种拼写方式,这些拼写差异将很容易被发现并修正,特别是如果你对输出进行排序的话。
当你处理日期或数字时,DISTINCT 将帮助你突出不一致或格式错误的情况。例如,你可能会继承一个数据集,其中日期被输入到一个格式为 text 数据类型的列中。这种做法(你应该避免)允许格式错误的日期存在:
date
---------
5/30/2023
6//2023
6/1/2023
6/2/2023
DISTINCT 关键字也可以同时作用于多列。如果我们增加一列,查询将返回每对唯一值。运行 示例 3-6 中的代码。
SELECT DISTINCT school, salary
FROM teachers
ORDER BY school, salary;
示例 3-6:查询 school 和 salary 列中唯一值的配对
现在,查询返回每个学校所获得的唯一(或不同)工资。因为 Myers 中学的两位教师薪水为 $43,500,这一对只列在一行中,查询返回五行,而不是表中的六行:
school salary
------------------- ------
F.D. Roosevelt HS 36200
F.D. Roosevelt HS 38500
F.D. Roosevelt HS 65000
Myers Middle School 36200
Myers Middle School 43500
这种技术让我们能够提出问题:“在表中的每个 x,所有的 y 值是什么?”例如,对于每个工厂,它生产的所有化学品是什么?对于每个选举区,所有竞选公职的候选人是谁?对于每个音乐厅,本月演出的艺术家是谁?
SQL 提供了更复杂的技术与聚合函数,允许我们计数、求和,并找到最小值和最大值。我将在第六章和第九章中详细介绍这些内容。
使用 WHERE 过滤行
有时,你希望限制查询返回的行,仅显示一列或多列满足特定条件的行。以 teachers 为例,你可能希望找到所有在某一年之前被雇佣的教师,或者所有年薪超过 $75,000 的小学教师。为此,我们使用 WHERE 子句。
WHERE 子句允许你根据通过运算符提供的条件查找匹配特定值、值范围或多个值的行——运算符是一个让我们执行数学、比较和逻辑操作的关键字。你也可以使用条件排除某些行。
示例 3-7 展示了一个基本示例。请注意,在标准 SQL 语法中,WHERE 子句位于 FROM 关键字之后,并且紧跟着被查询的表名。
SELECT last_name, school, hire_date
FROM teachers
WHERE school = 'Myers Middle School';
示例 3-7:使用 WHERE 过滤行
结果集仅显示分配给 Myers 中学的教师。
last_name school hire_date
--------- ------------------- ----------
Cole Myers Middle School 2005-08-01
Bush Myers Middle School 2011-10-30
Diaz Myers Middle School 2005-08-30
在这里,我使用等于比较运算符来查找完全匹配某个值的行,当然,你也可以在 WHERE 子句中使用其他运算符来自定义你的过滤条件。表 3-1 总结了最常用的比较运算符。根据你的数据库系统,可能还会有更多可用的运算符。
表 3-1:PostgreSQL 中的比较和匹配运算符
| 运算符 | 功能 | 示例 |
|---|---|---|
= |
等于 | WHERE school = 'Baker Middle' |
<> 或 != |
不等于^* | WHERE school <> 'Baker Middle' |
> |
大于 | WHERE salary > 20000 |
< |
小于 | WHERE salary < 60500 |
>= |
大于或等于 | WHERE salary >= 20000 |
<= |
小于或等于 | WHERE salary <= 60500 |
BETWEEN |
在范围内 | WHERE salary BETWEEN 20000 AND 40000 |
IN |
匹配一组值中的一个 | WHERE last_name IN ('Bush', 'Roush') |
LIKE |
匹配一个模式(区分大小写) | WHERE first_name LIKE 'Sam%' |
ILIKE |
匹配一个模式(不区分大小写) | WHERE first_name ILIKE 'sam%' |
NOT |
否定一个条件 | WHERE first_name NOT ILIKE 'sam%' |
以下示例展示了比较运算符的应用。首先,我们使用等于运算符查找名字为 Janet 的教师:
SELECT first_name, last_name, school
FROM teachers
WHERE first_name = 'Janet';
接下来,我们列出表格中所有学校的名称,但排除 F.D. Roosevelt HS,使用不等于运算符:
SELECT school
FROM teachers
WHERE school <> 'F.D. Roosevelt HS';
在这里,我们使用小于运算符列出 2000 年 1 月 1 日之前被聘用的教师(使用YYYY-MM-DD日期格式):
SELECT first_name, last_name, hire_date
FROM teachers
WHERE hire_date < '2000-01-01';
然后,我们使用>=运算符查找薪水为$43,500 或以上的教师:
SELECT first_name, last_name, salary
FROM teachers
WHERE salary >= 43500;
下一个查询使用BETWEEN运算符查找薪水在$40,000 到$65,000 之间的教师。注意,BETWEEN是包含的,意味着结果将包括与指定的起始和结束范围匹配的值。
SELECT first_name, last_name, school, salary
FROM teachers
WHERE salary BETWEEN 40000 AND 65000;
使用BETWEEN时要小心,因为它的包容性可能会导致值的重复计算。例如,如果你使用BETWEEN 10 AND 20进行过滤,然后再使用BETWEEN 20 AND 30执行第二次查询,值为 20 的行会出现在两个查询结果中。你可以通过使用更明确的大于和小于运算符来定义范围,从而避免这种情况。例如,这个查询返回与之前相同的结果,但更明显地指定了范围:
SELECT first_name, last_name, school, salary
FROM teachers
WHERE salary >= 40000 AND salary <= 65000;
我们将在本书中反复回到这些运算符,因为它们将在帮助我们找到所需的数据和答案时发挥重要作用。
使用LIKE和ILIKE与WHERE
比较运算符相对简单明了,但匹配运算符LIKE和ILIKE需要额外的解释。它们都允许你查找包括与指定模式匹配的字符的多种值,如果你不完全知道自己在搜索什么,或者在找出拼写错误的单词时,它们非常有用。要使用LIKE和ILIKE,你需要使用一个或两个符号来指定匹配的模式:
-
百分号(%) 匹配一个或多个字符的通配符
-
下划线(_) 匹配一个字符的通配符
例如,如果你尝试查找单词baker,以下LIKE模式将匹配它:
LIKE 'b%'
LIKE '%ak%'
LIKE '_aker'
LIKE 'ba_er'
区别是什么?LIKE运算符是 ANSI SQL 标准的一部分,区分大小写。ILIKE运算符是 PostgreSQL 特有的实现,不区分大小写。清单 3-8 展示了这两个关键字如何返回不同的结果。第一个WHERE子句使用LIKE 1 来查找以sam开头的名称,由于它区分大小写,所以不会返回任何结果。第二个使用不区分大小写的ILIKE 2 会从表中返回Samuel和Samantha。
SELECT first_name
FROM teachers
1 WHERE first_name `LIKE` 'sam%';
SELECT first_name
FROM teachers
2 WHERE first_name `ILIKE` 'sam%';
清单 3-8: 使用LIKE和ILIKE进行筛选
多年来,我倾向于使用ILIKE和通配符运算符,以确保在搜索时不会无意间排除结果,特别是在审查数据时。我不假设输入人名、地名、产品名或其他专有名词的人总是记得正确地大写它们。如果面试数据的目标之一是了解其质量,那么使用不区分大小写的搜索将帮助你发现不同的变体。
由于LIKE和ILIKE是模式搜索,在大数据库中性能可能较慢。我们可以通过使用索引来提高性能,关于这一点我将在第八章的“通过索引加速查询”中详细介绍。
结合AND和OR运算符
当我们将比较运算符结合使用时,它们变得更加有用。为此,我们使用逻辑运算符AND和OR将它们连接起来,必要时还可以使用括号。
清单 3-9 中的语句展示了三种以这种方式结合运算符的示例。
SELECT *
FROM teachers
1 WHERE school = 'Myers Middle School'
AND salary < 40000;
SELECT *
FROM teachers
2 WHERE last_name = 'Cole'
OR last_name = 'Bush';
SELECT *
FROM teachers
3 WHERE school = 'F.D. Roosevelt HS'
AND (salary < 38000 OR salary > 40000);
清单 3-9: 使用AND和OR结合运算符
第一个查询在WHERE子句 1 中使用AND来查找在迈尔斯中学工作且薪水低于 40,000 美元的教师。因为我们使用AND连接这两个条件,所以这两个条件必须同时成立,行才会满足WHERE子句中的条件并返回查询结果。
第二个示例使用OR 2 来搜索姓氏匹配 Cole 或 Bush 的任何教师。当我们使用OR连接条件时,只需要其中一个条件为真,行就会满足WHERE子句的条件。
最后的示例搜索罗斯福学校的教师,他们的薪水要么低于 38,000 美元,要么高于 40,000 美元。当我们将语句放入括号中时,这些语句会作为一组先进行评估,然后再与其他条件结合。在这种情况下,学校名称必须是F.D. Roosevelt HS,薪水必须低于或高于指定的值,才能使该行满足WHERE子句的条件。
如果我们在子句中同时使用AND和OR,但没有使用括号,数据库将首先评估AND条件,然后评估OR条件。在最后一个示例中,这意味着如果我们省略括号,结果会不同——数据库会寻找学校名称为F.D. Roosevelt HS且薪水低于 38,000 美元的行,或者寻找任何薪水高于 40,000 美元的学校行。在查询工具中试试看。
综合应用
你可以开始看到,即使是之前的简单查询,也能让我们灵活而精确地深入数据,找到我们所寻找的信息。你可以使用 AND 和 OR 关键字组合比较操作符语句,以提供多个筛选标准,并且可以包括 ORDER BY 子句对结果进行排序。
考虑到前面的信息,让我们将本章的概念结合成一个语句,展示它们如何结合在一起。SQL 对关键词的顺序非常讲究,因此请遵循这一惯例。
SELECT column_names
FROM table_name
WHERE criteria
ORDER BY column_names;
列表 3-10 展示了一个针对 teachers 表的查询,包含了所有上述内容。
SELECT first_name, last_name, school, hire_date, salary
FROM teachers
WHERE school LIKE '%Roos%'
ORDER BY hire_date DESC;
列表 3-10:一个包含 WHERE 和 ORDER BY 的 SELECT 语句
这个列表返回的是罗斯福高中教师的数据,按从最新雇佣到最早雇佣的顺序排列。我们可以看到教师的入职日期与其当前薪资水平之间的一些联系:
first_name last_name school hire_date salary
---------- --------- ----------------- ---------- ------
Janet Smith F.D. Roosevelt HS 2011-10-30 36200
Kathleen Roush F.D. Roosevelt HS 2010-10-22 38500
Lee Reynolds F.D. Roosevelt HS 1993-05-22 65000
总结
现在你已经学会了几种不同 SQL 查询的基本结构,你已经为后续章节中我将介绍的许多附加技能奠定了基础。排序、筛选和从表中选择最重要的列,能够从数据中获得令人惊讶的信息,并帮助你找到数据背后的故事。
在下一章,你将学习 SQL 的另一个基础方面:数据类型。
第四章:理解数据类型

理解数据类型非常重要,因为以合适的格式存储数据是构建可用数据库和进行准确分析的基础。每当我深入研究一个新数据库时,我都会检查每个表中每一列指定的 数据类型。如果幸运的话,我能找到一个 数据字典:这是一份列出每个列、指定它是数字、字符还是其他类型,并解释列值的文档。不幸的是,许多组织没有创建和维护良好的文档,因此常常会听到,“我们没有数据字典。”在这种情况下,我会在 pgAdmin 中检查表格结构,尽可能多地了解信息。
数据类型是一个编程概念,适用于不仅仅是 SQL。你将在本章中探讨的概念也适用于你可能想学习的其他编程语言。
在 SQL 数据库中,表格中的每一列只能存储一种数据类型,且必须在 CREATE TABLE 语句中通过在列名后声明数据类型来定义。在以下简单的示例表格中——你可以查看但无需创建——你会看到三个不同数据类型的列:一个日期、一个整数和一个文本。
CREATE TABLE eagle_watch (
observation_date date,
eagles_seen integer,
notes text
);
在名为 eagle_watch 的表格中(假设是关于秃鹰的库存),我们通过在列名后添加 date 类型声明来声明 observation_date 列存储日期值。同样,我们通过 integer 类型声明将 eagles_seen 设置为存储整数,并通过 text 类型声明将 notes 设置为存储字符。
这些数据类型属于你最常遇到的三种类别:
-
字符 任何字符或符号
-
数字包括整数和分数
-
日期和时间 时间信息
让我们深入了解每种数据类型;我会标注它们是否属于标准 ANSI SQL,或者是 PostgreSQL 特有的。关于 PostgreSQL 与 SQL 标准的差异,可以在 wiki.postgresql.org/wiki/PostgreSQL_vs_SQL_Standard 找到全面的深入分析。
理解字符
字符字符串类型 是通用类型,适用于任何文本、数字和符号的组合。字符类型包括以下几种:
char(``n``)
一个固定长度的列,其中字符长度由 n 指定。设置为 char(20) 的列每行存储 20 个字符,无论插入多少字符。如果某行插入的字符少于 20 个,PostgreSQL 会用空格填充该列的剩余部分。此类型是标准 SQL 的一部分,也可以使用更长的名称 character(``n``) 来指定。如今,char(``n``) 使用较少,主要是遗留计算机系统的产物。
varchar(``n``)
一个可变长度的列,其最大长度由 n 指定。如果插入的字符少于最大值,PostgreSQL 将不会存储额外的空格。例如,字符串 blue 将占用四个空间,而字符串 123 将占用三个空间。在大型数据库中,这种做法节省了大量空间。这种类型,标准 SQL 中也有,包括用更长的名称 character varying(``n``) 来指定。
text
一个长度不受限制的可变长度列。(根据 PostgreSQL 文档,你可以存储的最长字符串大约为 1 GB。)text 类型不是 SQL 标准的一部分,但你会在其他数据库系统中找到类似的实现,包括 Microsoft SQL Server 和 MySQL。
根据 PostgreSQL 文档,www.postgresql.org/docs/current/datatype-character.html,这三种类型在性能上没有实质性的区别。如果你使用的是其他数据库管理器,情况可能有所不同,因此最好检查相关文档。varchar 和 text 的灵活性及其潜在的空间节省似乎给它们带来了优势。但是,如果你在线查阅讨论,某些用户建议使用 char 来定义一个始终包含相同字符数的列,这样可以很好地指示它应包含的数据。例如,char(2) 可能用于美国州的邮政缩写。
要查看这三种字符类型的实际应用,可以运行 Listing 4-1 中显示的脚本。这个脚本将构建并加载一个简单的表格,然后将数据导出到你电脑上的一个文本文件。
CREATE TABLE char_data_types (
1 char_column char(10),
varchar_column varchar(10),
text_column text
);
2 INSERT INTO char_data_types
VALUES
('abc', 'abc', 'abc'),
('defghi', 'defghi', 'defghi');
3 COPY char_data_types TO '`C:\YourDirectory\`typetest.txt'
4 WITH (FORMAT CSV, HEADER, DELIMITER '|');
Listing 4-1: 字符数据类型示例
我们定义了三种不同类型的字符列,并将相同的字符串插入到每一列的两行中。与第二章中学到的 INSERT INTO 语句不同,这里我们没有指定列的名称。如果 VALUES 语句中的值与表中列的数量相匹配,数据库将假定你按照列定义的顺序插入值。
接下来,我们使用 PostgreSQL 的 COPY 关键字将数据导出到一个名为 typetest.txt 的文本文件中,文件保存在你指定的目录里。你需要将 *C:\YourDirectory* 替换为你电脑上要保存该文件的目录的完整路径。本书中的示例使用的是 Windows 格式——它在文件夹和文件名之间使用反斜杠——并且路径指向位于 C: 驱动器上的名为 YourDirectory 的目录。Windows 用户必须根据第一章中“从 GitHub 下载代码和数据”部分的说明设置目标文件夹的权限。
Linux 和 macOS 的文件路径格式不同,文件夹和文件名之间使用正斜杠。例如,在我的 Mac 上,桌面文件的路径是 /Users/anthony/Desktop/。目录必须已经存在;PostgreSQL 不会为你创建它。
在 PostgreSQL 中,COPY table_name FROM 是导入功能,COPY table_name TO 是导出功能。我将在第五章中详细讲解它们;目前,你只需要知道的是,WITH 关键字选项 4 会将文件中的数据格式化,每列由一个 管道符 (|) 分隔。这样,你可以轻松看到 char 列中空白部分填充的位置。
要查看输出,使用你在第一章中安装的文本编辑器打开 typetest.txt(而不是 Word、Excel 或其他电子表格应用程序)。其内容应如下所示:
char_column|varchar_column|text_column
abc |abc|abc
defghi |defghi|defghi
即使你为 char 和 varchar 列都指定了 10 个字符,只有 char 列会在两行中输出 10 个字符,并用空格填充未使用的字符。varchar 和 text 列仅存储你插入的字符。
再次强调,三种类型之间没有实际的性能差异,尽管这个例子显示了 char 可能会比实际需要的空间更多。每个列中的几个未使用的空格看似微不足道,但如果在数百万行的数十个表中进行相同的操作,很快你就会希望自己当初更节省空间。
我倾向于在所有字符列中使用 text。这样可以避免为多个 varchar 列配置最大长度,也意味着如果字符列的要求发生变化,我以后不需要修改表。
理解数字
数字列包含各种类型的(你猜对了)数字,但不仅仅如此:它们还允许你对这些数字进行计算。这与将数字存储为字符列中的字符串有所不同,因为字符列中的字符串无法进行加法、乘法、除法或执行任何其他数学操作。此外,作为字符存储的数字排序方式不同于作为数字存储的数字,因此,如果你要进行数学运算或数字顺序很重要,应该使用数字类型。
SQL 数字类型包括以下几种:
-
整数 既包括正数,也包括负数的整数
-
定点数和浮点数 两种表示小数的格式
我们将分别查看每种类型。
使用整数
整数数据类型是你在 SQL 数据库中最常见的数字类型。这些是 整数,包括正数、负数和零。想想生活中所有出现整数的地方:你的街道或公寓号码、冰箱上的序列号、彩票上的号码。
SQL 标准提供了三种整数类型:smallint、integer 和 bigint。这三种类型的区别在于它们能够存储的数字的最大大小。表 4-1 显示了每种类型的上下限,以及它们各自所需的存储空间(以字节为单位)。
表 4-1:整数数据类型
| 数据类型 | 存储大小 | 范围 |
|---|---|---|
smallint |
2 字节 | −32768 到 +32767 |
integer |
4 字节 | −2147483648 到 +2147483647 |
bigint |
8 字节 | −9223372036854775808 到 +9223372036854775807 |
bigint类型几乎可以满足你所有的数字列需求,尽管它占用的存储空间最大。如果你处理的是超过约 21 亿的数字,它是必须使用的类型,但你也可以轻松将其设为默认类型,永远不必担心数字无法存入该列。另一方面,如果你确定数字将保持在integer的限制范围内,那么选择该类型是一个不错的选择,因为它不像bigint那样消耗空间(尤其在处理百万级数据行时,这是一个重要的考量)。
当你知道值会保持在某个范围内时,smallint是合适的选择:例如月份的天数或年份。smallint类型的存储空间是integer的一半,因此如果列的值始终适合其范围,选择它是一个明智的数据库设计决策。
如果你尝试向这些列中插入超出其范围的数字,数据库将停止操作并返回超出范围的错误。
自增整数
有时候,创建一个每次向表中添加行时都会自动递增的整数列是很有帮助的。例如,你可以使用自增列为表中的每一行创建一个唯一的 ID 号码,也称为主键。这样每一行都会有自己的 ID,其他表可以引用该 ID,这一概念我将在第七章中讲解。
在 PostgreSQL 中,你可以通过两种方式实现整数列的自动递增。一种是serial数据类型,这是 PostgreSQL 特有的实现,符合 ANSI SQL 标准中的自动编号标识符列。另一种是 ANSI SQL 标准中的IDENTITY关键字。我们先从 serial 开始。
使用 serial 实现自增
在第二章中,当你创建teachers表时,你创建了一个id列并声明了bigserial类型:这个和它的兄弟类型smallserial、serial并不完全是独立的数据类型,而是对应的smallint、integer和bigint类型的特殊实现。当你添加一个自增列时,PostgreSQL 会在每次插入行时自动递增该列的值,从 1 开始,直到每个整数类型的最大值。
表 4-2 展示了自增类型及其覆盖的范围。
表 4-2:序列数据类型
| 数据类型 | 存储大小 | 范围 |
|---|---|---|
smallserial |
2 字节 | 1 到 32767 |
serial |
4 字节 | 1 到 2147483647 |
bigserial |
8 字节 | 1 到 9223372036854775807 |
要在列上使用序列类型,可以像声明整数类型一样,在CREATE TABLE语句中声明它。例如,你可以创建一个名为people的表,其中有一个id列,大小与integer数据类型相当:
CREATE TABLE people (
id serial,
person_name varchar(100)
);
每次向表中添加一行具有person_name的记录时,id列的值将递增 1。
使用 IDENTITY 实现自增
从版本 10 开始,PostgreSQL 支持IDENTITY,这是标准 SQL 中用于自动递增整数的实现。IDENTITY语法较为冗长,但一些数据库用户更倾向于使用它,因为它与其他数据库系统(如 Oracle)具有跨平台兼容性,并且还提供了防止用户意外插入自动递增列值的选项(而序列类型则允许这种操作)。
你可以通过两种方式指定IDENTITY:
-
GENERATED ALWAYS AS IDENTITY告诉数据库始终用自动递增的值填充该列。用户不能插入一个值到id列中,除非手动覆盖该设置。详细信息请参见 PostgreSQLINSERT文档中的OVERRIDING SYSTEM VALUE部分,链接地址:www.postgresql.org/docs/current/sql-insert.html。 -
GENERATED BY DEFAULT AS IDENTITY告诉数据库,如果用户未提供值,则默认用自动递增的值填充该列。此选项允许出现重复值,这可能会使其在创建键列时变得具有问题。我将在第七章详细讨论这一点。
目前,我们将坚持使用第一种方式,即使用ALWAYS。要创建一个名为people的表,并通过IDENTITY填充id列,你可以使用以下语法:
CREATE TABLE people (
id integer GENERATED ALWAYS AS IDENTITY,
person_name varchar(100)
);
对于id数据类型,我们使用integer,后跟关键词GENERATED ALWAYS AS IDENTITY。现在,每次我们将person_name值插入表中时,数据库会自动为id列填充递增的值。
由于它与 ANSI SQL 标准的兼容性,我将在本书的其余部分使用IDENTITY。
使用十进制数
十进制表示一个整数加上一个整数的分数部分;该分数部分由小数点后的数字表示。在 SQL 数据库中,它们通过定点和浮点数据类型进行处理。例如,从我家到最近的杂货店的距离是 6.7 英里;我可以将 6.7 插入定点或浮点列,PostgreSQL 都不会有任何投诉。唯一的区别是计算机存储数据的方式。稍后,你将看到这有重要的含义。
理解定点数
定点类型,也叫做任意精度类型,表示为numeric(``precision``,``scale``)。你需要将参数precision指定为小数点左右的最大位数,参数scale则表示小数点右侧允许的位数。或者,你也可以使用decimal(``precision``,``scale``)来指定这种类型。两者都是 ANSI SQL 标准的一部分。如果你省略了scale值的指定,默认会设置为零;实际上,这样会创建一个整数。如果你省略了precision和scale的指定,数据库将存储任何精度和范围的值,直到最大值为止。(根据 PostgreSQL 文档www.postgresql.org/docs/current/datatype-numeric.html,这最大可以是小数点前 131,072 位,后面 16,383 位。)
例如,假设你正在收集来自几个当地机场的降水数据——这并不是一个不常见的数据分析任务。美国国家气象局提供的数据通常将降水量测量到小数点后两位。(如果你像我一样,可能还记得小学数学老师讲解过小数点后两位是百分位。)
要在数据库中记录降水量,总共使用五位数字(精度)并且小数点后最多两位(范围),你可以指定为numeric(5,2)。即使你输入的数字没有包含两位小数,如 1.47、1.00 和 121.50,数据库也会始终返回小数点后两位。
理解浮动类型
两种浮动类型是real和double precision,这两者都属于 SQL 标准的一部分。它们的区别在于存储的数据量。real类型允许六位小数精度,double precision则可以达到 15 位小数精度,二者都包括小数点两侧的位数。这些浮动类型也叫做可变精度类型。数据库将数字存储为表示位数的部分和一个指数—即小数点的位置。因此,与numeric类型中我们指定固定精度和范围不同,在给定列中小数点可以根据数字的不同而“浮动”。
使用定点和浮动类型
每种类型对总位数(即精度)有不同的限制,如表 4-3 所示。
表 4-3:定点和浮动数据类型
| 数据类型 | 存储大小 | 存储类型 | 范围 |
|---|---|---|---|
numeric, decimal |
可变 | 定点类型 | 小数点前最多 131,072 位;小数点后最多 16,383 位 |
real |
4 字节 | 浮动类型 | 6 位小数精度 |
double precision |
8 字节 | 浮动类型 | 15 位小数精度 |
为了查看这三种数据类型如何处理相同的数字,创建一个小表格并插入各种测试用例,如清单 4-2 所示。
CREATE TABLE number_data_types (
1 numeric_column numeric(20,5),
real_column real,
double_column double precision
);
2 INSERT INTO number_data_types
VALUES
(.7, .7, .7),
(2.13579, 2.13579, 2.13579),
(2.1357987654, 2.1357987654, 2.1357987654);
SELECT * FROM number_data_types;
清单 4-2:数字数据类型的应用
我们创建了一个表格,每个分数数据类型都有一列,并将三行数据加载到表格中。每一行在所有三列中重复相同的数字。当脚本的最后一行执行并选择表格中的所有内容时,我们得到以下结果:
numeric_column real_column double_column
-------------- ----------- -------------
0.70000 0.7 0.7
2.13579 2.13579 2.13579
2.13580 2.1357987 2.1357987654
注意发生了什么。设置为五位刻度的numeric列,无论你插入多少位小数,总是保留五位小数。如果小于五位,它会用零填充;如果大于五位,它会进行四舍五入——例如第三行的小数点后有十位数字。
real和double precision列没有填充。在第三行,你会看到 PostgreSQL 在这两列中的默认行为,它会输出浮点数的最简精确十进制表示,而不是显示完整的值。请注意,较旧版本的 PostgreSQL 可能会显示稍有不同的结果。
遇到浮点数学问题
如果你在想,“嗯,作为浮点数存储的数字看起来和作为定点数存储的数字一样”,那就要小心了。计算机存储浮点数的方式可能导致意外的数学错误。看看当我们对这些数字进行一些计算时会发生什么。运行清单 4-3 中的脚本。
SELECT
1 numeric_column * 10000000 AS fixed,
real_column * 10000000 AS floating
FROM number_data_types
2 WHERE numeric_column = .7;
清单 4-3:浮动列的舍入问题
这里,我们将numeric_column和real_column分别乘以一千万,并使用WHERE子句筛选出第一行。我们应该得到相同的结果,对吧?下面是查询返回的结果:
fixed floating
------------- ----------------
7000000.00000 6999999.88079071
你好!难怪浮点类型被称为“不精确”。好在我没有用这些数学来发射火星任务或计算联邦预算赤字。
浮点数学会产生这些错误的原因是,计算机试图将大量信息压缩到有限的位数中。这个话题已经有很多讨论,超出了本书的范围,但如果你感兴趣,可以在www.nostarch.com/practical-sql-2nd-edition/找到一个很好的摘要链接。
numeric数据类型所需的存储空间是可变的,具体取决于指定的精度和刻度,numeric可能比浮点类型消耗更多的空间。如果你正在处理数百万行数据,值得考虑是否能接受相对不精确的浮点数学。
选择你的数字数据类型
目前,在处理数字数据类型时,请考虑以下三条指南:
-
尽可能使用整数。除非你的数据使用小数,否则请坚持使用整数类型。
-
如果你在处理十进制数据并需要精确计算(例如处理货币),请选择
numeric或其等效类型decimal。浮动类型可以节省空间,但浮点数计算的不精确性在许多应用中是无法接受的。只有在精度不那么重要时,才使用它们。 -
选择一个足够大的数字类型。除非你在设计一个包含数百万行数据的数据库,否则最好选择更大的数据类型。当使用
numeric或decimal时,确保精度足够大,以容纳小数点两边的数字。对于整数,除非你确定列值会被限制在较小的integer或smallint类型中,否则使用bigint。
理解日期和时间
每当你在搜索表单中输入日期时,你正在享受数据库对当前时间的感知(从服务器接收的)以及能够处理日期、时间格式和日历的细节(如闰年和时区)。这对于通过数据讲故事至关重要,因为关于何时发生某事的问题通常和“谁”、“什么”或“多少人”一样有价值。
PostgreSQL 的日期和时间支持包括 表 4-4 所示的四种主要数据类型。
表 4-4:日期和时间数据类型
| 数据类型 | 存储大小 | 描述 | 范围 |
|---|---|---|---|
timestamp |
8 字节 | 日期和时间 | 公元前 4713 年到公元 294276 年 |
date |
4 字节 | 仅日期(无时间) | 公元前 4713 年到公元 5874897 年 |
time |
8 字节 | 时间(无日期) | 00:00:00 到 24:00:00 |
interval |
16 字节 | 时间间隔 | +/− 1.78 亿年 |
以下是 PostgreSQL 中日期和时间数据类型的概述:
-
timestamp记录日期和时间,适用于你可能跟踪的一系列情况:乘客航班的起降时间、大联盟棒球比赛的时间表,或时间线上的事件。你几乎总是希望在事件发生的时间后面加上with time zone关键字,以确保记录的时间包括发生地点的时区。否则,不同地区记录的时间将无法进行比较。timestamp with time zone格式是 SQL 标准的一部分;在 PostgreSQL 中,你可以使用timestamptz来指定相同的数据类型。 -
date仅记录日期,是 SQL 标准的一部分。 -
time仅记录时间,是 SQL 标准的一部分。虽然你可以添加with time zone关键字,但没有日期时,时区将没有意义。 -
i``nterval表示一个时间单位的值,采用数量 单位的格式。它不记录时间段的开始或结束,只记录持续时间。示例包括12 天或8 小时。(PostgreSQL 文档在www.postgresql.org/docs/current/datatype-datetime.html中列出了从微秒到千年的单位值。)你通常会使用此类型进行计算或过滤其他日期和时间列。它也是 SQL 标准的一部分,尽管 PostgreSQL 特有的语法提供了更多选项。
让我们关注一下timestamp with time zone和interval类型。要查看这些类型的实际应用,请运行清单 4-4 中的脚本。
1 CREATE TABLE date_time_types (
timestamp_column timestamp with time zone,
interval_column interval
);
2 INSERT INTO date_time_types
VALUES
('2022-12-31 01:00 EST','2 days'),
('2022-12-31 01:00 -8','1 month'),
('2022-12-31 01:00 Australia/Melbourne','1 century'),
3 (now(),'1 week');
SELECT * FROM date_time_types;
清单 4-4:timestamp和interval类型的实际应用
在这里,我们创建一个包含这两种类型列的表,并插入四行数据。对于前三行,我们的timestamp_column插入使用相同的日期和时间(2022 年 12 月 31 日凌晨 1 点),采用国际标准化组织(ISO)日期和时间格式:YYYY``-``MM``-``DD HH``:``MM``:``SS。SQL 支持其他日期格式(如MM/DD/YYYY),但建议使用 ISO 格式,以确保全球的可移植性。
在指定时间之后,我们指定了时区,但在前三行中使用了不同的格式:在第一行中,我们使用了EST缩写,表示美国东部标准时间。
在第二行中,我们设置时区为-8。该值表示与世界协调时间(UTC)之间的小时差,或称为偏移量。UTC 的值为+/−00:00,因此-8表示比 UTC 时间晚 8 小时。在美国,夏令时生效时,-8是阿拉斯加时区的值。从 11 月到次年 3 月初,美国恢复标准时间时,这个值指的是太平洋时区。(关于 UTC 时区的地图,参见en.wikipedia.org/wiki/Coordinated_Universal_Time#/media/File:Standard_World_Time_Zones.tif。)
对于第三行,我们使用地区和位置的名称来指定时区:Australia/Melbourne。该格式使用了一个标准时区数据库中的值,通常在计算机编程中使用。你可以在en.wikipedia.org/wiki/Tz_database了解更多关于时区数据库的信息。
在第四行中,脚本没有指定具体的日期、时间和时区,而是使用 PostgreSQL 的now()函数,3 该函数从你的硬件捕获当前事务时间。
脚本运行后,输出应该类似(但不完全相同)如下:
timestamp_column interval_column
----------------------------- ---------------
2022-12-31 01:00:00-05 2 days
2022-12-31 04:00:00-05 1 mon
2022-12-30 09:00:00-05 100 years
2020-05-31 21:31:15.716063-05 7 days
尽管我们在timestamp_column的前三行中提供了相同的日期和时间,但每行的输出却不同。原因是 pgAdmin 根据我的时区报告日期和时间,在显示的结果中,每个时间戳的末尾都标出了-05的 UTC 偏移量。UTC 偏移量-05表示比 UTC 时间晚五小时,相当于美国东部时区在秋冬季节采用标准时间时的时间。如果你生活在不同的时区,你可能会看到不同的偏移量;时间和日期也可能与你看到的有所不同。我们可以更改 PostgreSQL 报告这些时间戳值的方式,我将在第十二章中介绍如何操作以及处理日期和时间的其他技巧。
最后,interval_column显示了你输入的值。PostgreSQL 将1 century转换为100 years,并将1 week转换为7 days,这是因为它在区间显示的首选默认设置。阅读 PostgreSQL 文档中的“Interval Input”部分,了解更多有关区间的选项,网址是www.postgresql.org/docs/current/datatype-datetime.html。
在计算中使用interval数据类型
interval数据类型对于日期和时间数据的简易计算非常有用。例如,假设你有一列保存了客户签署合同的日期。使用区间数据,你可以在每个合同日期上加上 90 天,以确定何时与客户跟进。
要查看interval数据类型如何工作,我们将使用刚刚创建的date_time_types表,如示例 4-5 所示。
SELECT
timestamp_column,
interval_column,
1 timestamp_column - interval_column AS new_date
FROM date_time_types;
示例 4-5:使用interval数据类型
这是一条典型的SELECT语句,只是我们会计算一个名为new_date的列,该列包含timestamp_column减去interval_column的结果。(计算列称为表达式;我们将经常使用这种技巧。)在每一行中,我们从日期中减去interval数据类型指示的时间单位。这样会产生以下结果:
timestamp_column interval_column new_date
----------------------------- --------------- -----------------------------
2022-12-31 01:00:00-05 2 days 2022-12-29 01:00:00-05
2022-12-31 04:00:00-05 1 mon 2022-11-30 04:00:00-05
2022-12-30 09:00:00-05 100 years 1922-12-30 09:00:00-05
2020-05-31 21:31:15.716063-05 7 days 2020-05-24 21:31:15.716063-05
请注意,new_date列默认格式化为timestamp with time zone类型,允许在区间值使用时显示时间值以及日期。(你可以在 pgAdmin 的结果网格中看到数据类型,显示在列名下方。)再次提醒,根据你的时区,输出可能会有所不同。
理解 JSON 和 JSONB
JSON 是JavaScript 对象表示法的缩写,是一种用于存储数据和在计算机系统之间交换数据的结构化数据格式。所有主要的编程语言都支持以 JSON 格式读取和写入数据,这种格式将信息组织为键/值对和数值列表。以下是一个简单的例子:
{
"business_name": "Old Ebbitt Grill",
"business_type": "Restaurant",
"employees": 300,
"address": {
"street": "675 15th St NW",
"city": "Washington",
"state": "DC",
"zip_code": "20005"
}
}
这段 JSON 代码展示了该格式的基本结构。例如,键(key)business_name与值(value)Old Ebbitt Grill相关联。键的值也可以是一个包含额外键值对的集合,如address所示。JSON 标准对格式有严格要求,例如用冒号分隔键和值,并将键名用双引号括起来。你可以使用在线工具如jsonlint.com/检查 JSON 对象是否具有有效的格式。
PostgreSQL 当前提供两种 JSON 数据类型,它们都强制执行有效的 JSON 格式,并支持处理该格式数据的函数:
-
json存储 JSON 文本的精确副本 -
jsonb以二进制格式存储 JSON 文本
这两者之间有显著的差异。例如,jsonb支持索引,这可以提高处理速度。
JSON 在 2016 年成为 SQL 标准的一部分,但 PostgreSQL 早在几年之前就已支持,从版本 9.2 开始。PostgreSQL 目前实现了 SQL 标准中的多个函数,并提供了自己的一些额外的 JSON 函数和操作符。我们将在第十六章中更详细地介绍这些类型和功能。
使用杂项类型
字符、数字和日期/时间类型可能是你在使用 SQL 时处理的主要类型。但 PostgreSQL 支持许多其他类型,包括但不限于以下几种:
-
布尔类型,存储
true或false的值 -
几何类型,包括点、线、圆及其他二维对象
-
PostgreSQL 全文搜索引擎的文本搜索类型
-
网络地址类型,如 IP 地址或 MAC 地址
-
通用唯一标识符(UUID)类型,有时用作表中的唯一键值
-
范围类型,允许你指定值的范围,如整数或时间戳
-
存储二进制数据的类型
-
XML数据类型,用于存储这种结构化格式的信息
我将在本书中根据需要介绍这些类型。
使用 CAST 转换值的类型
有时,你可能需要将一个值从其存储的数据类型转换为另一种类型。例如,你可能想将一个数字作为字符检索,以便与文本结合使用,或者你可能需要将存储为字符的日期转换为实际的日期类型,以便按照日期顺序排序或进行时间间隔计算。你可以使用CAST()函数来执行这些转换。
CAST()函数仅在目标数据类型能够容纳原始值时成功。例如,将整数转换为文本是可能的,因为字符类型可以包含数字。而将包含字母的文本转换为数字则不行。
列表 4-6 展示了使用我们刚创建的三张数据类型表的三个示例。前两个示例能正常工作,但第三个示例将尝试执行无效的类型转换,这样你就可以看到类型转换错误是什么样子的。
1 SELECT timestamp_column, CAST(timestamp_column AS varchar(10))
FROM date_time_types;
2 SELECT numeric_column,
CAST(numeric_column AS integer),
CAST(numeric_column AS text)
FROM number_data_types;
3 SELECT CAST(char_column AS integer) FROM char_data_types;
列表 4-6:三个CAST()示例
第一个SELECT语句 1 将timestamp_column的值作为varchar返回,你应该记得varchar是可变长度字符列。在这种情况下,我已将字符长度设置为 10,这意味着转换为字符字符串时,只保留前 10 个字符。在这种情况下,这很方便,因为它只保留了列的日期部分,排除了时间。当然,也有更好的方法来从时间戳中去除时间,我将在第十二章的“提取时间戳值的组成部分”中讲解。
第二个SELECT语句 2 返回numeric_column的值三次:一次是原始形式,接着是整数形式,最后是text形式。在转换为整数时,PostgreSQL 会将值四舍五入为整数。但在转换为text时,不会发生四舍五入。
最后的SELECT3 不起作用:它返回错误invalid input syntax for type integer,因为字母不能转换为整数!
使用 CAST 快捷符号
最好编写别人也能阅读的 SQL,这样当其他人后来接手时,他们能明白你写的代码。CAST()的写法使你在使用它时的意图相当明显。然而,PostgreSQL 也提供了一种不那么显而易见的快捷符号,节省空间:双冒号。
在列名和你想要转换成的数据类型之间插入双冒号。例如,这两个语句将timestamp_column转换为varchar:
SELECT timestamp_column, CAST(timestamp_column AS varchar(10))
FROM date_time_types;
SELECT timestamp_column::varchar(10)
FROM date_time_types;
使用你觉得合适的方式,但要注意,双冒号是 PostgreSQL 特有的实现,其他 SQL 变种中没有,因此不能移植。
总结
现在,你已经具备了更好地理解你在深入研究数据库时遇到的数据格式的能力。如果你遇到作为浮动点数存储的货币值,你一定要在进行任何数学计算之前将其转换为十进制。而且,你还知道如何使用正确的文本列类型,以防止数据库过大。
接下来,我将继续讲解 SQL 基础,并向你展示如何将外部数据导入到你的数据库中。
第五章:数据的导入与导出

到目前为止,你已经学会了如何通过 SQL INSERT 语句向表中添加几行数据。逐行插入适用于快速创建测试表或向现有表中添加少量数据。但更常见的是,你可能需要加载数百、数千甚至数百万行数据,而在这种情况下,没人愿意写出一条条的 INSERT 语句。幸运的是,你不需要这样做。
如果你的数据存在于一个分隔文本文件中,每行文本表示一个表的行,每列值由逗号或其他字符分隔,PostgreSQL 可以通过 COPY 命令批量导入数据。这个命令是 PostgreSQL 特有的实现,具有包括或排除列以及处理不同分隔文本类型的选项。
在相反的方向上,COPY 也可以从 PostgreSQL 表中或查询结果中导出数据到分隔文本文件。当你想与同事共享数据或将数据转移到其他格式(如 Excel 文件)时,这个技术非常方便。
我在第四章的“理解字符”部分简要提到了 COPY 导出,但在这一章,我将更深入地讨论导入和导出。对于导入部分,我将以我最喜欢的数据集之一为例:美国按县划分的年度人口普查估算数据。
三个步骤构成了你将执行的大多数导入操作的概述:
-
获取源数据,格式为分隔文本文件。
-
创建一个表来存储数据。
-
编写一个
COPY语句来执行导入。
导入完成后,我们将检查数据并探讨更多的导入和导出选项。
分隔文本文件是最常见的文件格式,具有良好的跨专有系统和开源系统的可移植性,因此我们将重点关注这种文件类型。如果你想将其他数据库程序的专有格式的数据直接传输到 PostgreSQL——例如,从 Microsoft Access 或 MySQL——你将需要使用第三方工具。请访问 PostgreSQL 的维基页面 wiki.postgresql.org/wiki/,搜索“从其他数据库转换到 PostgreSQL”,获取工具和选项的列表。
如果你在使用 SQL 与其他数据库管理系统,查看该数据库的文档,了解它如何处理批量导入。例如,MySQL 数据库有 LOAD DATA INFILE 语句,微软的 SQL Server 也有自己的 BULK INSERT 命令。
使用分隔文本文件
许多软件应用程序以独特的格式存储数据,将一种数据格式转换为另一种格式就像是尝试在只懂英语的情况下阅读西里尔字母一样困难。幸运的是,大多数软件都可以导入和导出分隔文本文件,这是一种常见的数据格式,起到了中介的作用。
分隔文本文件包含多行数据,每一行表示表中的一行数据。在每一行中,每个数据列由特定字符分隔或界定。我见过许多不同的字符作为分隔符,从&符号到管道符号都有,但逗号是最常用的;因此,您常常会看到的文件类型名称是逗号分隔值(CSV)。术语CSV和逗号分隔可以互换使用。
下面是您可能在逗号分隔文件中看到的典型数据行:
John,Doe,123 Main St.,Hyde Park,NY,845-555-1212
请注意,每一项数据—名字、姓氏、街道、城市、州和电话—之间都用逗号分隔,没有空格。逗号告诉软件将每一项作为单独的列处理,无论是导入还是导出。很简单。
处理标题行
你常常会在分隔文本文件中找到一个标题行。顾名思义,它是文件顶部,或者说头部的单行,用于列出每个数据列的名称。通常,在数据从数据库或电子表格导出时,会添加标题行。以下是我一直在使用的带有分隔符行的示例。标题行中的每一项都对应其相应的列:
FIRSTNAME,LASTNAME,STREET,CITY,STATE,PHONE
John,Doe,123 Main St.,Hyde Park,NY,845-555-1212
标题行有几个作用。首先,标题行中的值标识了每列的数据,这在解读文件内容时特别有用。其次,一些数据库管理系统(虽然 PostgreSQL 不使用)会用标题行将分隔文件中的列映射到导入表中的正确列。PostgreSQL 不使用标题行,因此我们不希望将该行导入到表中。我们使用HEADER选项在COPY命令中排除它。在下一节中,我将介绍所有COPY选项。
引用包含分隔符的列
使用逗号作为列分隔符会导致一个潜在的难题:如果列中的值包含逗号怎么办?例如,有时人们会将公寓号与街道地址结合在一起,如 123 Main St., Apartment 200\。除非分隔符系统能够考虑到这个额外的逗号,否则在导入时,行会被认为有额外的列,导致导入失败。
为了处理这种情况,分隔符文件使用一种称为文本限定符的任意字符,将包含分隔符字符的列括起来。这就像一个信号,告诉程序忽略该分隔符,并将文本限定符之间的内容视为单一列。在逗号分隔文件中,常用的文本限定符是双引号。以下是再次提供的示例数据,但街道名称列被双引号括起来:
FIRSTNAME,LASTNAME,STREET,CITY,STATE,PHONE
John,Doe,"123 Main St., Apartment 200",Hyde Park,NY,845-555-1212
在导入时,数据库将识别双引号无论是否包含分隔符,都会表示为一列。当导入 CSV 文件时,PostgreSQL 默认会忽略双引号列中的分隔符,但如果导入需要,您可以指定不同的文本限定符。(并且鉴于 IT 专业人员有时会做出奇怪的选择,您可能确实需要使用不同的字符。)
最后,在 CSV 模式下,如果 PostgreSQL 在双引号列中发现两个连续的文本限定符,它会去掉一个。例如,假设 PostgreSQL 发现了如下内容:
"123 Main St."" Apartment 200"
如果是这样,它将在导入时将该文本视为单一列,只保留一个限定符:
123 Main St." Apartment 200
类似的情况可能表明你的 CSV 文件格式存在错误,这也是为什么,正如你稍后会看到的,导入后检查数据总是一个好主意。
使用 COPY 导入数据
要将数据从外部文件导入到我们的数据库中,首先需要在数据库中创建一个与源文件中的列和数据类型匹配的表。完成后,导入的 COPY 语句就是 清单 5-1 中的三行代码。
1 COPY `table_name`
2 FROM '`C:\YourDirectory\your_file.csv`'
3 WITH (FORMAT CSV, HEADER);
清单 5-1:使用 COPY 进行数据导入
我们用 COPY 关键字 1 开始代码块,后跟目标表的名称,该表必须已经在数据库中存在。可以把这个语法理解为“将数据复制到名为 table_name 的表中”。
FROM 关键字 2 确定源文件的完整路径,我们用单引号将路径括起来。如何指定路径取决于你的操作系统。对于 Windows,从驱动器字母、冒号、反斜杠和目录名称开始。例如,要导入位于 Windows 桌面的文件,FROM 行会如下所示:
FROM 'C:\Users\Anthony\Desktop\`my_file.csv`'
在 macOS 或 Linux 上,从系统根目录开始并使用正斜杠,然后从那里继续。以下是当我从 macOS 桌面导入文件时,FROM 行可能的样子:
FROM '/Users/anthony/Desktop/`my_file.csv`'
在书中的示例中,我使用 Windows 风格的路径 C:\YourDirectory\ 作为占位符。请将其替换为你从 GitHub 下载的 CSV 文件存储的路径。
WITH 关键字 3 让你指定选项,这些选项被括在圆括号中,用于定制你的输入或输出文件。在这里,我们指定外部文件应使用逗号分隔,并且在导入时应排除文件的表头行。值得查看官方 PostgreSQL 文档中的所有选项,链接是 www.postgresql.org/docs/current/sql-copy.html,但这里列出了你常用的一些选项:
输入和输出文件格式
- 使用
FORMATformat_name选项来指定你正在读取或写入的文件类型。格式名称有CSV、TEXT或BINARY。除非你深入构建技术系统,否则很少会遇到需要处理BINARY格式的情况,因为数据是以字节序列存储的。更常见的是,你将使用标准的 CSV 文件。在TEXT格式中,默认的分隔符是 制表符(尽管你可以指定其他字符),而像\r这样的反斜杠字符会被识别为其 ASCII 等效值——在这种情况下是回车符。TEXT格式主要由 PostgreSQL 内置的备份程序使用。
表头行的存在
- 在导入时,使用
HEADER来指定源文件包含一个你希望排除的标题行。数据库将从文件的第二行开始导入,这样标题行中的列名就不会成为表格中的数据部分。(务必检查源 CSV 文件,确保这是你想要的;并非每个 CSV 文件都有标题行!)在导出时,使用HEADER告诉数据库将列名作为输出文件中的标题行包含进去,这有助于用户理解文件的内容。
分隔符
DELIMITER'``character``'选项允许你指定导入或导出文件使用的分隔符字符。分隔符必须是单一字符,不能是回车符。如果你使用FORMAT CSV,默认的分隔符是逗号。这里包括DELIMITER是为了说明,如果数据采用了其他分隔符,你也可以选择不同的分隔符。例如,如果你收到了管道符分隔的数据,可以按如下方式设置:DELIMITER '|'。
引号字符
- 之前,你学习过在 CSV 文件中,如果一个单独列的值中包含逗号,会导致导入混乱,除非该列的值被某个字符包围,这个字符作为文本限定符,告诉数据库将该值作为一个完整的列处理。默认情况下,PostgreSQL 使用双引号,但如果你导入的 CSV 文件使用了其他字符作为文本限定符,可以通过
QUOTE'``quote_character``'选项指定该字符。
现在,你对分隔文件有了更好的理解,可以开始导入了。
导入描述县的数据
在这个导入练习中,你将使用的数据集比第二章中创建的teachers表要大得多。它包含美国每个县的普查人口估算,共有 3142 行,16 列宽。(普查中的县包括一些其他名称的地理区域:路易斯安那州的教区、阿拉斯加州的区和普查区,以及特别是在弗吉尼亚州的城市。)
为了理解数据,了解一点美国普查局的情况会有所帮助。普查局是一个联邦机构,负责追踪全国人口统计数据。它最著名的项目是每十年进行一次的全国人口普查,最近一次是在 2020 年进行的。这些数据列出了每个国家的人口年龄、性别、种族和民族信息,用于确定每个州在 435 个席位的美国众议院中应有多少代表。近年来,像德克萨斯州和佛罗里达州这样的快速增长州获得了更多席位,而像纽约州和俄亥俄州这样增长缓慢的州则失去了代表席位。
我们将使用的数据是人口普查的年度人口估算。这些估算使用最近一次的十年人口普查数据作为基础,并且考虑了出生、死亡以及国内外迁移,以每年为国家、州、县和其他地理区域估算人口。由于缺乏年度的实地人口统计数据,它是获取美国各地人口更新数据的最佳方式。对于这个练习,我将从 2019 年美国人口普查的县级人口估算数据中选取了几列(以及来自人口普查地理数据的几列描述性列),并将它们编译成一个名为us_counties_pop_est_2019.csv的文件。如果你按照第一章中“从 GitHub 下载代码和数据”部分的说明操作,你应该在电脑上有这个文件。如果没有,现在去下载它。
使用文本编辑器打开文件。你应该会看到一行标题,包含以下列:
state_fips,county_fips,region,state_name,county_name, `--snip--`
让我们通过检查创建导入表的代码来探索这些列。
创建us_counties_pop_est_2019表
列表 5-2 中的代码展示了CREATE TABLE脚本。在 pgAdmin 中点击你在第二章创建的analysis数据库。(最好将本书中的数据存储在analysis中,因为我们将在后续章节中重用其中的一部分。)在 pgAdmin 的菜单栏中,选择工具▶查询工具。你可以将代码输入到工具中,或者从你从 GitHub 下载的文件中复制并粘贴。将脚本放入窗口后,运行它。
CREATE TABLE us_counties_pop_est_2019 (
1 state_fips text,
county_fips text,
2 region smallint,
3 state_name text,
county_name text,
4 area_land bigint,
area_water bigint,
5 internal_point_lat numeric(10,7),
internal_point_lon numeric(10,7),
6 pop_est_2018 integer,
pop_est_2019 integer,
births_2019 integer,
deaths_2019 integer,
international_migr_2019 integer,
domestic_migr_2019 integer,
residual_2019 integer,
7 CONSTRAINT counties_2019_key PRIMARY KEY (state_fips, county_fips)
);
列表 5-2:人口普查县级人口估算的CREATE TABLE语句
返回到 pgAdmin 的主窗口,在对象浏览器中,右键点击并刷新analysis数据库。选择架构▶public▶表以查看新创建的表。虽然它为空,但你可以通过在 pgAdmin 查询工具中运行基本的SELECT查询来查看其结构:
SELECT * FROM us_counties_pop_est_2019;
当你运行SELECT查询时,你会看到你创建的表格的列出现在 pgAdmin 的数据输出窗格中。目前还没有数据行,我们需要导入它们。
理解人口普查的列和数据类型
在将 CSV 文件导入到表格之前,让我们先通过清单 5-2 中的几个列和我所选择的数据类型进行说明。作为指南,我使用了两个官方的人口普查数据字典:一个是用于估算数据的,链接为www2.census.gov/programs-surveys/popest/technical-documentation/file-layouts/2010-2019/co-est2019-alldata.pdf,另一个是包含地理列的十年一次的普查计数,链接为www.census.gov/prod/cen2010/doc/pl94-171.pdf。在表格定义中,我给一些列起了更易读的名称。尽可能依赖数据字典是一个好习惯,因为它可以帮助你避免错误配置列或可能丢失数据。始终询问是否有数据字典可用,或者如果数据是公开的,进行在线搜索。
在这一组人口普查数据中,以及你刚刚创建的表格中,每一行都显示了某个县的年度变化(出生、死亡和迁移)的估算人口和组成部分。前两列是县的state_fips 1 和 county_fips,这是这些实体的标准联邦代码。我们使用text类型来存储这两个字段,因为这些代码可能包含前导零,如果我们将其存储为整数类型,前导零会丢失。例如,阿拉斯加的state_fips是02。如果我们使用整数类型,这个前导的0在导入时会被去除,留下2,这就不是阿拉斯加的正确代码了。此外,我们不会对这个值进行任何数学运算,所以不需要使用整数类型。区分代码和数字非常重要;这些州和县的值实际上是标签,而不是用于计算的数字。
region 2 中的数字从 1 到 4 代表了美国县的大致位置:东北、中西部、南部或西部。没有数字大于 4,因此我们定义这些列的类型为smallint。state_name 3 和 county_name列包含州和县的完整名称,存储为text类型。
县的土地和水域的平方米数分别记录在area_land 4 和 area_water中。两者合起来构成县的总面积。在某些地方——比如阿拉斯加,那里有大量的土地与积雪——一些数值很容易超出integer类型的最大值 2,147,483,647。因此,我们使用了bigint类型,它能够轻松处理如育空-科尤库克人口普查区的 377,038,836,685 平方米的土地面积。
一个接近县中心的点的纬度和经度,被称为内部点,分别在internal_point_lat和internal_point_lon中指定。人口普查局—以及许多映射系统—使用十进制度系统表示纬度和经度坐标。纬度表示地球上的南北位置,赤道为 0 度,北极为 90 度,南极为-90 度。
经度表示东西位置,经过伦敦格林威治的本初子午线为 0 度经度。从那里开始,东经和西经都逐渐增大(东经为正数,西经为负数),直到它们在地球的另一侧相遇于 180 度。那里的位置,被称为反子午线,是国际日期变更线的基础。
在报告内部点时,人口普查局使用最多七位小数。由于小数点左边最多可达 180,我们需要最多 10 个数字。因此,我们使用了numeric类型,精度为10,小数位为7。
接下来,我们进入了一系列包含县人口估算和变化成分的列 6。表 5-1 列出了它们的定义。
表 5-1:人口普查人口估算列
| 列名 | 描述 |
|---|---|
pop_est_2018 |
2018 年 7 月 1 日的估算人口 |
pop_est_2019 |
2019 年 7 月 1 日的估算人口 |
births_2019 |
2018 年 7 月 1 日至 2019 年 6 月 30 日的出生人数 |
deaths_2019 |
2018 年 7 月 1 日至 2019 年 6 月 30 日的死亡人数 |
international_migr_2019 |
2018 年 7 月 1 日至 2019 年 6 月 30 日的国际迁移净值 |
domestic_migr_2019 |
2018 年 7 月 1 日至 2019 年 6 月 30 日的国内迁移净值 |
residual_2019 |
用于调整估算值的一项数值,确保一致性 |
最后,CREATE TABLE语句以CONSTRAINT子句 7 结束,指定state_fips和county_fips列将作为表的主键。这意味着这两列的组合在表中的每一行都是唯一的,这是我们将在第八章详细讨论的概念。现在,运行导入操作吧。
使用 COPY 执行人口普查导入
现在你准备好将人口普查数据导入表格了。运行列表 5-3 中的代码,记得更改文件路径以匹配计算机上数据的位置。
COPY us_counties_pop_est_2019
FROM '`C:\YourDirectory\`us_counties_pop_est_2019.csv'
WITH (FORMAT CSV, HEADER);
列表 5-3:使用COPY导入人口普查数据
当代码执行时,你应该在 pgAdmin 中看到以下消息:
COPY 3142
Query returned successfully in 75 msec.
这是个好消息:导入的 CSV 文件行数相同。如果源 CSV 文件或导入语句有问题,数据库会抛出错误。例如,如果 CSV 中的某一行列数多于目标表中的列数,你将在 pgAdmin 的“数据输出”窗格中看到错误信息,并提供修复的提示:
ERROR: extra data after last expected column
Context: COPY us_counties_pop_est_2019, line 2: "01,001,3,Alabama, ..."
即使没有报告任何错误,最好还是视觉检查一下你刚刚导入的数据,确保一切看起来如预期。
检查导入数据
从一个 SELECT 查询开始,查询所有列和行:
SELECT * FROM us_counties_pop_est_2019;
在 pgAdmin 中应该显示 3,142 行数据,当你在结果集中左右滚动时,每一列应该都有预期的值。让我们回顾一下我们特别注意用适当数据类型定义的一些列。例如,运行以下查询,显示 area_land 值最大的县。我们将使用 LIMIT 子句,这将导致查询仅返回我们想要的行数;在这里,我们要求返回三行:
SELECT county_name, state_name, area_land
FROM us_counties_pop_est_2019
ORDER BY area_land DESC
LIMIT 3;
这个查询将县级地理数据从最大土地面积排序到最小,单位为平方米。我们将 area_land 定义为 bigint,因为该字段中的最大值超过了普通 integer 类型的上限。正如你所料,阿拉斯加的大型地理区域排在最前面:
county_name state_name area_land
------------------------- ---------- ------------
Yukon-Koyukuk Census Area Alaska 377038836685
North Slope Borough Alaska 230054247231
Bethel Census Area Alaska 105232821617
接下来,让我们检查一下 internal_point_lat 和 internal_point_lon 列,我们已经用 numeric(10,7) 定义了它们。此代码根据经度将县按从大到小排序。这次,我们将使用 LIMIT 来获取五行数据:
SELECT county_name, state_name, internal_point_lat, internal_point_lon
FROM us_counties_pop_est_2019
ORDER BY internal_point_lon DESC
LIMIT 5;
经度测量的是东西方向的位置,位于英格兰本初子午线以西的位置表示为负数,从 -1、-2、-3 开始,越往西数值越小。我们按降序排序,因此我们期望美国东部的县会出现在查询结果的顶部。但结果却让人惊讶!——最上面的是一条孤立的阿拉斯加地理数据:
county_name state_name internal_point_lat internal_point_lon
-------------------------- ---------- ------------------ ------------------
Aleutians West Census Area Alaska 51.9489640 179.6211882
Washington County Maine 44.9670088 -67.6093542
Hancock County Maine 44.5649063 -68.3707034
Aroostook County Maine 46.7091929 -68.6124095
Penobscot County Maine 45.4092843 -68.6666160
原因如下:阿拉斯加阿留申群岛延伸得如此遥远(比夏威夷还要远),以至于它们穿越了 180 度经线(反经线)。一旦越过反经线,经度就会变为正值,回到 0。幸运的是,这并不是数据的错误;不过,这是一个你可以记住,用于下一次竞赛的趣味知识。
恭喜你!你已经在数据库中拥有了一套有效的政府人口统计数据。我将在本章稍后演示如何使用 COPY 导出数据,然后你将使用它来学习第六章中的数学函数。在继续导出数据之前,让我们再看看几种额外的导入技巧。
使用 COPY 导入部分列
如果 CSV 文件没有包含目标数据库表中所有列的数据,你仍然可以通过指定数据中存在的列来导入已有的数据。考虑这种情况:你正在研究你所在州所有镇镇长的薪资,以便分析按地理区域划分的政府支出趋势。首先,你创建一个名为 supervisor_salaries 的表,使用 Listing 5-4 中的代码。
CREATE TABLE supervisor_salaries (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
town text,
county text,
supervisor text,
start_date date,
salary numeric(10,2),
benefits numeric(10,2)
);
Listing 5-4:创建一个表来跟踪镇长薪资
你需要包含城镇和县的列、主管的名字、他们开始的日期、以及薪资和福利(假设你只关心当前的水平)。你还将添加一个自增的id列作为主键。然而,你联系的第一个县书记员说:“抱歉,我们只有城镇、主管和薪资这几个数据。其他信息你需要从别处获取。”你告诉他们还是发送 CSV 文件过来,你会导入能获取的数据。
我已经包含了一个示例 CSV 文件,你可以通过书中的资源下载,网址是www.nostarch.com/practical-sql-2nd-edition/,文件名为supervisor_salaries.csv。如果你用文本编辑器查看该文件,你应该能看到文件顶部的这两行:
town,supervisor,salary
Anytown,Jones,67000
你可以尝试使用这个基本的COPY语法来导入数据:
COPY supervisor_salaries
FROM '`C:\YourDirectory\`supervisor_salaries.csv'
WITH (FORMAT CSV, HEADER);
但是如果你这样做,PostgreSQL 会返回一个错误:
ERROR: invalid input syntax for type integer: "Anytown"
Context: COPY supervisor_salaries, line 2, column id: "Anytown"
SQL state: 22P04
问题在于你的表的第一列是自增的id,而你的 CSV 文件从文本列town开始。即使你的 CSV 文件在第一列中包含整数,GENERATED ALWAYS AS IDENTITY关键字也会阻止你向id列添加值。解决这个问题的方法是告诉数据库表中哪些列在 CSV 中存在,如示例 5-5 所示。
COPY supervisor_salaries 1 (town, supervisor, salary)
FROM '`C:\YourDirectory\`supervisor_salaries.csv'
WITH (FORMAT CSV, HEADER);
示例 5-5:从 CSV 导入薪资数据到三个表列
通过在表名后面括号中标注 1,我们告诉 PostgreSQL 只查找那些数据来填充这些列,尤其是当它读取 CSV 时。现在,如果你从表中选择前几行,你会看到这些列已经填充了相应的值:
id town county supervisor start_date salary benefits
-- -------- ------ ---------- ---------- ---------- --------
1 Anytown Jones 67000.00
2 Bumblyburg Larry 74999.00
使用 COPY 导入部分行
从 PostgreSQL 版本 12 开始,你可以在COPY语句中添加WHERE子句,以过滤从源 CSV 中导入的行。你可以通过主管薪资数据查看这个功能是如何工作的。
从清空你已导入的所有supervisor_salaries数据开始,使用DELETE查询。
DELETE FROM supervisor_salaries;
这将从表中删除数据,但不会重置id列的IDENTITY序列。我们将在第八章讨论表设计时讲解如何做这个操作。当这个查询完成后,运行示例 5-6 中的COPY语句,加入一个WHERE子句,过滤导入的数据,只包括那些town列值为 New Brillig 的行。
COPY supervisor_salaries (town, supervisor, salary)
FROM '*C:\YourDirectory\*supervisor_salaries.csv'
WITH (FORMAT CSV, HEADER)
WHERE town = 'New Brillig';
示例 5-6:使用WHERE导入部分行
接下来,运行SELECT * FROM supervisor_salaries;来查看表中的内容。你应该只看到一行数据:
id town county supervisor start_date salary benefits
-- ----------- ------ ---------- ---------- --------- --------
10 New Brillig Carroll 102690.00
这是一个便捷的快捷方式。现在,让我们看看如何使用临时表在导入过程中进行更多的数据处理。
在导入过程中为列添加值
如果你知道“米尔斯(Mills)”是应该在导入时添加到county列中的名称,尽管该值在 CSV 文件中缺失呢?一种修改导入以包含该名称的方法是将 CSV 加载到临时表中,然后再添加到supervisors_salary。临时表只会存在到数据库会话结束为止。当你重新打开数据库(或失去连接)时,这些表会消失。它们在数据处理管道中执行中间操作时非常有用;我们将用一个临时表来在导入 CSV 时为supervisor_salaries表添加县名。
再次使用DELETE查询清除你导入到supervisor_salaries中的数据。完成后,运行 Listing 5-7 中的代码,它将创建一个临时表并导入你的 CSV。然后,我们将从该表中查询数据,并在插入supervisor_salaries表时包含县名。
1 CREATE TEMPORARY TABLE supervisor_salaries_temp
(LIKE supervisor_salaries INCLUDING ALL);
2 COPY supervisor_salaries_temp (town, supervisor, salary)
FROM '`C:\YourDirectory\`supervisor_salaries.csv'
WITH (FORMAT CSV, HEADER);
3 INSERT INTO supervisor_salaries (town, county, supervisor, salary)
SELECT town, 'Mills', supervisor, salary
FROM supervisor_salaries_temp;
4 DROP TABLE supervisor_salaries_temp;
Listing 5-7:使用临时表在导入时为列添加默认值
这个脚本执行四个任务。首先,我们通过传递LIKE关键字和源表名作为参数,基于原始的supervisor_salaries表创建一个名为supervisor_salaries_temp 1 的临时表。INCLUDING ALL关键字告诉 PostgreSQL 不仅要复制表的行和列,还要复制如索引和IDENTITY设置等组件。接着,我们使用熟悉的COPY语法将supervisor_salaries.csv文件 2 导入到临时表中。
接下来,我们使用INSERT语句 3 填充工资表。我们不直接指定值,而是使用SELECT语句查询临时表。该查询将Mills指定为第二列的值,而不是列名,而是作为单引号中的字符串。
最后,我们使用DROP TABLE删除临时表 4,因为我们已经完成了这次导入。当你断开与 PostgreSQL 会话的连接时,临时表会自动消失,但现在先删除它,以防我们想做另一次导入并使用一个新的临时表来处理另一个 CSV。
在运行查询后,执行一个SELECT语句查看前几行的效果:
id town county supervisor start_date salary benefits
-- -------- --------- ---------- ---------- --------- --------
11 Anytown Mills Jones 67000.00
12 Bumblyburg Mills Larry 74999.00
即使你的源 CSV 文件没有该值,你也已经在county字段中填入了一个值。这个导入过程看起来可能有点繁琐,但通过这一过程可以看到,数据处理有时需要多个步骤才能获得期望的结果。好消息是,这个临时表的示范恰好展示了 SQL 在数据处理中的灵活性。
使用 COPY 导出数据
在使用COPY导出数据时,与其使用FROM来标识源数据,不如使用TO来指定输出文件的路径和名称。你可以控制导出多少数据——整个表、仅部分列或查询结果。
让我们来看三个快速示例。
导出所有数据
最简单的导出将表中的所有数据发送到文件。之前,你创建了包含 16 列和 3,142 行人口普查数据的us_counties_pop_est_2019表。清单 5-8 中的 SQL 语句将所有数据导出到名为us_counties_export.txt的文本文件中。为了演示你在选择输出选项时的灵活性,WITH关键字告诉 PostgreSQL 包括一个标题行,并使用管道符号而不是逗号作为分隔符。我在这里使用了.txt文件扩展名,原因有两个。首先,它演示了你可以将文件命名为.csv以外的扩展名;其次,我们使用的是管道符作为分隔符,而不是逗号,因此除非文件确实使用逗号分隔,否则我不想将文件命名为.csv。
记得将输出目录更改为你首选的保存位置。
COPY us_counties_pop_est_2019
TO '`C:\YourDirectory\`us_counties_export.txt'
WITH (FORMAT CSV, HEADER, DELIMITER '|');
清单 5-8:使用COPY导出整个表
使用文本编辑器查看导出文件,查看数据以此格式(我已截断结果):
state_fips|county_fips|region|state_name|county_name| `--snip--`
01|001|3|Alabama|Autauga County `--snip--`
文件包含一个带有列名的标题行,所有列由管道分隔符分开。
导出特定列
你不总是需要(或想要)导出所有数据:你可能有敏感信息,例如社会保障号码或出生日期,需要保密。或者,在人口普查县数据的情况下,也许你正在使用一个映射程序,只需要县名和其地理坐标来绘制位置。我们可以通过在表名后列出这三列来只导出它们,如清单 5-9 所示。当然,必须精确输入这些列名,以便 PostgreSQL 能识别它们。
COPY us_counties_pop_est_2019
(county_name, internal_point_lat, internal_point_lon)
TO '`C:\YourDirectory\`us_counties_latlon_export.txt'
WITH (FORMAT CSV, HEADER, DELIMITER '|');
清单 5-9:使用COPY导出选择的列
导出查询结果
此外,你可以向COPY添加查询来微调输出。在清单 5-10 中,我们只导出那些名称包含字母mill的县的名称和州信息,使用不区分大小写的ILIKE和我们在第三章“在 WHERE 中使用 LIKE 和 ILIKE”中介绍的%通配符字符,无论是大写还是小写。此外,请注意,在这个例子中,我已从WITH子句中删除了DELIMITER关键字。因此,输出将默认为逗号分隔的值。
COPY (
SELECT county_name, state_name
FROM us_counties_pop_est_2019
WHERE county_name ILIKE '%mill%'
)
TO '`C:\YourDirectory\`us_counties_mill_export.csv'
WITH (FORMAT CSV, HEADER);
清单 5-10:使用COPY导出查询结果
运行代码后,你的输出文件应该包含九行县名称,包括 Miller、Roger Mills 和 Vermillion:
county_name,state_name
Miller County,Arkansas
Miller County,Georgia
Vermillion County,Indiana
`--snip--`
通过 pgAdmin 进行导入和导出
有时,SQL 的 COPY 命令无法处理某些导入和导出操作。这通常发生在你连接到运行在其他计算机上的 PostgreSQL 实例时。像亚马逊云服务(AWS)中的机器就是一个很好的例子。在这种情况下,PostgreSQL 的 COPY 命令会查找该远程机器上的文件和文件路径;它无法找到你本地计算机上的文件。要使用 COPY,你需要将数据传输到远程服务器,但你可能并不总是有权限这么做。
一个解决方法是使用 pgAdmin 内置的导入/导出向导。在 pgAdmin 的对象浏览器(左侧垂直窗格)中,选择 Databases▶analysis▶Schemas▶public▶Tables,定位到你 analysis 数据库中的表列表。
接下来,右键单击你想要导入或导出的表,然后选择 Import/Export。会弹出一个对话框,让你选择是从该表导入还是导出,如 图 5-1 所示。

图 5-1:pgAdmin 导入/导出对话框
要导入,将导入/导出滑块移动到 Import。然后点击 Filename 框右侧的三个点,定位到你的 CSV 文件。在格式下拉列表中,选择 csv。然后根据需要调整标题、分隔符、引号和其他选项。点击 OK 以导入数据。
若要导出,使用相同的对话框并遵循类似的步骤。
在第十八章中,当我们讨论如何从计算机的命令行使用 PostgreSQL 时,我们将探索另一种方法,通过名为psql的工具及其\copy命令来实现。pgAdmin 的导入/导出向导实际上在后台使用\copy,但它提供了一个更友好的界面。
总结
现在你已经学会了如何将外部数据导入到数据库中,你可以开始深入研究各种数据集,无论是探索成千上万的公开数据集,还是与自己的职业或学习相关的数据。大量数据以 CSV 格式或易于转换为 CSV 格式的形式提供。查找数据字典以帮助你理解数据,并为每个字段选择正确的数据类型。
你在本章练习中导入的人口普查数据将在下一章发挥重要作用,在那一章我们将探索使用 SQL 的数学函数。
第六章:使用 SQL 进行基本数学和统计

如果你的数据包含我们在第四章中探讨的任何数值数据类型——整数、小数或浮点数——迟早你的分析将包括一些计算。你可能想知道某一列中所有美元值的平均值,或者将两列中的值相加,计算每行的总和。SQL 可以处理这些计算以及更多的内容,从基础数学到高级统计。
本章我将从基础知识开始,逐步介绍数学函数和初步统计。我还会讨论与百分比和百分比变化相关的计算。对于几个练习,我们将使用你在第五章导入的 2019 年美国人口普查估算数据。
理解数学运算符和函数
让我们从你在小学学到的基础数学开始(如果你忘记了一些,没关系)。表 6-1 显示了你在计算中最常使用的九个数学运算符。前四个(加法、减法、乘法和除法)是 ANSI SQL 标准的一部分,并且在所有数据库系统中都有实现。其他的则是 PostgreSQL 特有的运算符,尽管大多数其他数据库管理系统也可能有函数或运算符来执行这些操作。例如,模运算符(%)不仅在 PostgreSQL 中有效,在 Microsoft SQL Server 和 MySQL 中也能使用。如果你使用的是其他数据库系统,请查阅其文档。
表 6-1:基本数学运算符
| 运算符 | 描述 |
|---|---|
+ |
加法 |
- |
减法 |
* |
乘法 |
/ |
除法(仅返回商,不返回余数) |
% |
模运算(仅返回余数) |
^ |
幂运算 |
|/ |
平方根 |
||/ |
立方根 |
! |
阶乘 |
我们将通过在简单数字上执行 SQL 查询来逐步了解这些运算符,而不是在表或其他数据库对象上进行操作。你可以将这些语句分别输入到 pgAdmin 查询工具中并逐一执行,或者如果你从www.nostarch.com/practical-sql-2nd-edition/复制了本章的代码,可以高亮每一行并执行它。
理解数学和数据类型
在执行示例时,请注意每个结果的数据类型,这些类型会列在 pgAdmin 结果网格中每个列名下方。计算返回的类型会根据操作和输入数字的数据类型有所不同。当使用运算符对两个数字进行运算(加法、减法、乘法或除法)时,返回的数据类型遵循以下模式:
-
两个整数返回一个
integer(整数)。 -
如果操作符两侧或任一侧为
numeric类型,则返回numeric类型。 -
任何包含浮点数的操作都将返回一个
double precision(双精度)浮点数。
然而,指数、平方根和阶乘函数有所不同。每个函数只接受一个数字,可能在运算符的前面或后面,并且即使输入是整数,返回的也是数字和浮动类型。
有时结果的数据类型会满足你的需求;但在其他情况下,你可能需要使用 CAST 来改变数据类型,如第四章中“使用 CAST 将值从一种类型转换为另一种类型”一节所提到的,例如如果你需要将结果传递给需要特定类型的函数。我们在书中进行讲解时会指出这些情况。
加法、减法和乘法
我们先从简单的整数加法、减法和乘法开始。列表 6-1 展示了三个例子,每个例子都以 SELECT 关键字后跟数学公式的形式。自第三章以来,我们已经使用 SELECT 执行它的主要功能:从表中检索数据。但是,在 PostgreSQL、微软 SQL Server、MySQL 以及其他一些数据库管理系统中,你可以省略表名,执行简单的数学和字符串操作,就像我们这里做的那样。为了可读性,我建议在数学运算符前后使用一个空格;虽然使用空格并不是让代码正常工作的必需条件,但这是一个良好的习惯。
1 SELECT 2 + 2;
2 SELECT 9 - 1;
3 SELECT 3 * 4;
列表 6-1:使用 SQL 进行基本的加法、减法和乘法
这些语句都不是很复杂,所以你不必感到惊讶,当你在查询工具中运行 SELECT 2 + 2; 时得到结果 4。同样,减法 2 和乘法 3 的例子也会得到你期望的结果:8 和 12。输出会像任何查询结果一样显示在一列中。但由于我们没有查询表格并指定列,结果会显示在一个 ?column? 名称下,表示一个未知的列:
?column?
--------
4
没关系。我们没有影响任何表中的数据,只是显示一个结果。如果你想显示列名,可以提供一个别名,例如 SELECT 3 * 4 AS result;。
执行除法和求余
使用 SQL 进行除法会稍微复杂一些,因为整数运算和小数运算之间的区别。再加上 取余,这是一个在除法操作中只返回 余数 的运算符,结果可能会让人感到困惑。所以,为了更清楚地展示,列表 6-2 展示了四个例子。
1 SELECT 11 / 6;
2 SELECT 11 % 6;
3 SELECT 11.0 / 6;
4 SELECT CAST(11 AS numeric(3,1)) / 6;
列表 6-2:使用 SQL 进行整数和小数除法
/ 运算符 1 将整数 11 除以另一个整数 6。如果你在脑海中做这个计算,你知道答案是 1,余数为 5。然而,运行这个查询的结果是 1,这是 SQL 处理一个整数除以另一个整数的方式——只报告整数商,不显示余数。如果你想获取余数作为整数,你必须使用模运算符 % 执行相同的计算,如 2 所示。该语句仅返回余数,在此案例中为 5。今天没有单一操作可以同时提供商和余数作为整数,尽管有心的开发者将来可能会添加此功能。
模运算不仅仅用于获取余数:你还可以将它作为测试条件。例如,要检查一个数是否为偶数,你可以使用 % 2 运算进行测试。如果结果为 0 且没有余数,则该数为偶数。
有两种方式可以将两个数字相除并返回 numeric 类型的结果。首先,如果其中一个或两个数字是 numeric,结果默认会以 numeric 表示。这就是当我将 11.0 除以 6 3 时的情况。执行该查询,结果是 1.83333。显示的小数位数可能会根据你的 PostgreSQL 和系统设置有所不同。
其次,如果你正在处理仅以整数形式存储的数据,并且需要强制进行小数除法,你可以使用 CAST 将其中一个整数转换为 numeric 类型 4。执行该操作后,同样会返回 1.83333。
使用指数、根和阶乘
除了基础操作外,PostgreSQL 风格的 SQL 还提供了运算符和函数来计算平方、立方,或将基数提升为指数,还可以计算根和阶乘。清单 6-3 展示了这些操作的实际应用。
1 SELECT 3 ^ 4;
2 SELECT |/ 10;
SELECT sqrt(10);
3 SELECT ||/ 10;
4 SELECT factorial(4);
SELECT 4 !;
清单 6-3:使用 SQL 进行指数、根和阶乘运算
指数运算符 (^) 允许你将给定的基数提升到一个指数,如 1 中所示,其中 3 ^ 4(口语中我们会称其为三的四次方)返回 81。
你可以通过两种方式求一个数的平方根:使用 |/ 运算符 2 或 sqrt(``n``) 函数。对于立方根,使用 ||/ 运算符 3。这两者都是前缀运算符,因为它们出现在单一值之前。
要找到一个数字的阶乘,你可以使用factorial(``n``)函数或!运算符。!运算符仅在 PostgreSQL 13 及以前的版本中可用,是一个后缀运算符,它位于单个值后面。在数学中,你将在许多地方使用阶乘,最常见的应用是确定一组物品有多少种排列方式。比如你有四张照片。你可以将它们在墙上排列成多少种方式呢?为了找到答案,你需要计算阶乘,从物品数量开始,并将其与所有较小的正整数相乘。因此,factorial(4)等价于 4 × 3 × 2 × 1\。这就是四张照片的 24 种排列方式。难怪有时候装饰需要这么长时间!
再次提醒,这些运算符是 PostgreSQL 特有的;它们不是 SQL 标准的一部分。如果你使用的是其他数据库应用程序,请查阅其文档,了解如何实现这些运算。
注意运算顺序
你可能还记得早期的数学课程中关于数学表达式的运算顺序,或运算符优先级。SQL 首先执行哪些计算呢?毫不奇怪,SQL 遵循已建立的数学标准。到目前为止讨论的 PostgreSQL 运算符的优先顺序如下:
-
指数与根号
-
乘法、除法、模运算
-
加法与减法
根据这些规则,如果你想按照不同的顺序计算操作,你需要将操作括在圆括号内。例如,以下两个表达式会产生不同的结果:
SELECT 7 + 8 * 9;
SELECT (7 + 8) * 9;
第一个表达式返回79,因为乘法运算优先执行,先于加法。第二个返回135,因为圆括号强制加法先执行。
这是一个使用指数的第二个例子:
SELECT 3 ^ 3 - 1;
SELECT 3 ^ (3 - 1);
指数运算优先于减法运算,因此在没有圆括号的情况下,整个表达式会从左到右进行求值,找到 3 的 3 次方的运算会最先发生。然后减去 1,得到26。在第二个例子中,圆括号强制减法先进行,因此运算结果为9,即 3 的 2 次方。
记住运算符优先级,避免稍后需要纠正分析结果!
跨人口普查表列进行数学运算
让我们尝试在真实数据上使用最常用的 SQL 数学运算符,通过挖掘你在第五章导入的 2019 年美国人口普查估算表us_counties_pop_est_2019。我们不会在查询中使用数字,而是使用包含数字的列名。当我们执行查询时,计算将发生在表中的每一行。
为了刷新你的记忆,运行列表 6-4 中的脚本。它应该返回 3142 行,显示每个美国县的名称和所在州,以及 2019 年人口变化的组成部分:出生、死亡以及国际和国内迁移。
SELECT county_name AS1 county,
state_name AS state,
pop_est_2019 AS pop,
births_2019 AS births,
deaths_2019 AS deaths,
international_migr_2019 AS int_migr,
domestic_migr_2019 AS dom_migr,
residual_2019 AS residual
FROM us_counties_pop_est_2019;
清单 6-4:选择带别名的人口普查估计列
这个查询不会返回表中的所有列,而只是与人口估计相关的数据列。此外,我使用AS关键字 1 为每个列在结果集中提供了一个较短的别名。因为所有数据都来自 2019 年,我去掉了结果列名称中的年份,以减少 pgAdmin 输出中的滚动。这是一个任意的决定,你可以根据需要进行调整。
列的加法和减法
现在,让我们尝试使用两列进行简单计算。清单 6-5 将每个县的死亡人数减去出生人数,这是普查中称为自然增加的指标。让我们看看这能展示什么。
SELECT county_name AS county,
state_name AS state,
births_2019 AS births,
deaths_2019 AS deaths,
1 births_2019 - deaths_2019 AS natural_increase
FROM us_counties_pop_est_2019
ORDER BY state_name, county_name;
清单 6-5:在us_counties_pop_est_2019中减去两列
在SELECT语句中提供births_2019 - deaths_2019 1 作为其中一列来完成计算。同样,我使用AS关键字为该列提供一个可读的别名。如果不提供别名,PostgreSQL 将使用?column?标签,这显然不太有用。
运行查询以查看结果。前几行应该类似于以下输出:
county state births deaths natural_increase
-------------- ------- ------ ------ ----------------
Autauga County Alabama 624 541 83
Baldwin County Alabama 2304 2326 -22
Barbour County Alabama 256 312 -56
Bibb County Alabama 240 252 -12
使用计算器或纸笔快速检查,确认natural_increase列等于你减去的两列之间的差值。太棒了!当你浏览输出结果时,请注意一些县的出生人数超过死亡人数,而其他一些县则相反。通常,居民年龄较轻的县出生人数会超过死亡人数;而那些居民年龄较大的地方——比如农村地区和退休热门地区——往往死亡人数会超过出生人数。
现在,让我们在此基础上进行测试,验证我们是否正确导入了列。2019 年的人口估计应等于 2018 年估计加上出生、死亡、迁移和剩余因子相关的列。清单 6-6 中的代码应该显示这一点。
SELECT county_name AS county,
state_name AS state,
1 pop_est_2019 AS pop,
2 pop_est_2018 + births_2019 - deaths_2019 +
international_migr_2019 + domestic_migr_2019 +
residual_2019 AS components_total,
3 pop_est_2019 - (pop_est_2018 + births_2019 - deaths_2019 +
international_migr_2019 + domestic_migr_2019 +
residual_2019) AS difference
FROM us_counties_pop_est_2019
4 ORDER BY difference DESC;
清单 6-6:检查人口普查数据总数
该查询包括 2019 年的人口估计 1,后面是一个计算,将组成部分加到 2018 年的人口估计中作为component_total 2。2018 年的估计加上组成部分应该等于 2019 年的估计。为了避免手动检查,我们还添加了一列,将组成部分总和从 2019 年的估计中减去 3。该列名为difference,如果所有数据都在正确位置,则每一行应包含零。为了避免查看所有 3,142 行,我们在命名列上添加了ORDER BY子句 4。显示差异的任何行应该出现在查询结果的顶部或底部。
执行查询;前几行应该显示以下结果:
county state pop components_total difference
-------------- ------- ------ ---------------- ----------
Autauga County Alabama 55869 55869 0
Baldwin County Alabama 223234 223234 0
Barbour County Alabama 24686 24686 0
通过difference列显示零,我们可以确信我们的导入数据是干净的。每当我遇到或导入新的数据集时,我喜欢进行像这样的简单测试。它们帮助我更好地理解数据,并在深入分析之前解决潜在的问题。
查找整体百分比
发现数据集中各个项目之间差异的一种方法是计算某个数据点所代表的整个数据集的百分比。然后,你可以通过对比数据集中的所有项目的百分比,获取有意义的洞察——有时甚至是惊喜。
要计算整体的百分比,需将相关数字除以总数。例如,如果你有一个 12 个苹果的篮子,且用了其中 9 个做了一个派,那么就是 9 / 12 或 0.75——通常表示为 75%。
我们将尝试使用示例 6-7 中的代码,对普查人口估计数据进行操作,使用表示每个县地理特征大小的两列数据。area_land和area_water列显示了一个县的陆地和水域面积(单位为平方米)。使用该代码,我们可以计算出每个县中由水域组成的面积百分比。
SELECT county_name AS county,
state_name AS state,
1 area_water::numeric / (area_land + area_water) * 100 AS pct_water
FROM us_counties_pop_est_2019
ORDER BY pct_water DESC;
示例 6-7:计算一个县的面积中水域的百分比
该查询的关键部分是将area_water除以area_land和area_water的总和,这两者代表了该县的总面积 1。
如果我们使用原始整数类型的数据,我们将无法得到所需的分数结果:每一行将显示 0 的结果,即商。相反,我们通过将其中一个整数强制转换为数字类型来进行十进制除法。这里,为了简洁起见,我们在第一次引用area_water时使用了 PostgreSQL 特有的双冒号符号,但你也可以使用 ANSI SQL 标准中的CAST函数,具体内容见第四章。最后,我们将结果乘以 100,以将其呈现为百分数——这种方式是大多数人理解百分比的方式。
按照从高到低的百分比排序,输出的结果如下:
county state pct_water
------------------ ------------- -----------------------
Keweenaw County Michigan 90.94723747453215452900
Leelanau County Michigan 86.28858968116583102500
Nantucket County Massachusetts 84.79692499185512352300
St. Bernard Parish Louisiana 82.48371149202893908400
Alger County Michigan 81.87221940647501072300
如果你查看维基百科上关于基维诺县(Keweenaw County)的条目,你会发现为什么该县的总面积中超过 90%是水域:其陆地面积包括了苏必利尔湖中的一个岛屿,而湖水也被包括在普查中报告的总面积内。将这一点加入你的趣闻收藏吧!
跟踪百分比变化
数据分析中的另一个关键指标是百分比变化:一个数字比另一个数字大或小多少?百分比变化计算通常用于分析随时间变化的数据,特别适用于比较相似项目之间的变化。
一些示例包括以下内容:
-
每个汽车制造商的年度汽车销售变化
-
每个营销公司所拥有的每个邮件列表的月度订阅变化
-
全国各学校的年注册人数增减
计算百分比变化的公式可以这样表达:
(新数值 – 旧数值) / 旧数值
所以,如果你经营一个柠檬水摊位,今天卖出了 73 杯柠檬水,昨天卖出了 59 杯,你可以这样计算日常百分比变化:
(73 – 59) / 59 = .237 = 23.7%
让我们尝试使用一小组与假设的地方政府部门开支相关的测试数据。清单 6-8 计算了哪些部门的百分比增减最大。
1 CREATE TABLE percent_change (
department text,
spend_2019 numeric(10,2),
spend_2022 numeric(10,2)
);
2 INSERT INTO percent_change
VALUES
('Assessor', 178556, 179500),
('Building', 250000, 289000),
('Clerk', 451980, 650000),
('Library', 87777, 90001),
('Parks', 250000, 223000),
('Water', 199000, 195000);
SELECT department,
spend_2019,
spend_2022,
3 round( (spend_2022 - spend_2019) /
spend_2019 * 100, 1) AS pct_change
FROM percent_change;
清单 6-8:计算百分比变化
我们创建了一个名为percent_change的小表,并插入了六行关于 2019 年和 2022 年部门开支的数据。百分比变化公式是:spend_2022减去spend_2019,然后除以spend_2019。我们乘以 100 以将结果表示为百分之一。
为了简化输出,这次我添加了round()函数来去掉除一位小数外的所有小数位。该函数需要两个参数:要四舍五入的列或表达式,以及显示的小数位数。由于这两个数字都是numeric类型,因此结果也将是numeric类型。
该脚本产生了以下结果:
department spend_2019 spend_2022 pct_change
---------- ---------- ---------- ----------
Assessor 178556.00 179500.00 0.5
Building 250000.00 289000.00 15.6
Clerk 451980.00 650000.00 43.8
Library 87777.00 90001.00 2.5
Parks 250000.00 223000.00 -10.8
Water 199000.00 195000.00 -2.0
现在,只需找出为什么市政府的书记部门开支超过了其他部门。
使用聚合函数计算平均值和总和
到目前为止,我们已经对表格中每行的列进行了数学运算。SQL 还允许你使用聚合函数计算同一列中值的结果。你可以在www.postgresql.org/docs/current/functions-aggregate.html查看 PostgreSQL 聚合函数的完整列表,这些函数从多个输入中计算出单一结果。在数据分析中,最常用的两个聚合函数是avg()和sum()。
返回到us_counties_pop_est_2019人口普查表,计算所有县的总人口以及所有县的平均人口是合理的。使用avg()和sum()函数对pop_est_2019列(2019 年人口估算)进行操作,可以轻松完成,如清单 6-9 所示。我们再次使用round()函数去掉平均计算中的小数点后的数字。
SELECT sum(pop_est_2019) AS county_sum,
round(avg(pop_est_2019), 0) AS county_average
FROM us_counties_pop_est_2019;
清单 6-9:使用sum()和avg()聚合函数
这个计算产生了以下结果:
county_sum county_average
---------- --------------
328239523 104468
美国所有县的 2019 年估计人口总和大约为 3.282 亿,县人口估算的平均值为 104,468。
寻找中位数
一组数字中的中位数值是与平均数一样重要的指标,甚至可能更重要。以下是中位数与平均数的区别:
-
平均值 所有值的总和除以值的个数
-
中位数 排序后的数值中的“中间”值
中位数在数据分析中很重要,因为它减少了异常值的影响。考虑这个例子:假设六个孩子,年龄分别为 10、11、10、9、13 和 12,去进行一次实地考察。将年龄相加后除以六得到平均年龄很容易:
(10 + 11 + 10 + 9 + 13 + 12) / 6 = 10.8
因为这些年龄值分布在一个较小的范围内,所以 10.8 的平均数很好地代表了这个组。但是当数据值集中在分布的一端,或者组中有异常值时,平均数的帮助就会减少。
例如,假设一位年长的陪同人员加入了实地考察。年龄为 10、11、10、9、13、12 和 46,平均年龄大幅增加:
(10 + 11 + 10 + 9 + 13 + 12 + 46) / 7 = 15.9
现在,平均数并不能很好地代表这个组,因为异常值会让它产生偏差,使得它成为一个不可靠的指标。
在这种情况下,最好找出中位数,它是一个有序数值列表中的中间点——也就是数据中一半的值大于它,另一半的值小于它。以这次实地考察为例,我们将参加者的年龄按从低到高的顺序排列:
9, 10, 10, 11, 12, 13, 46
中位数(median)是 11。对于这个组来说,11 的中位数比 15.9 的平均数更能代表典型年龄。
如果数据集的数值为偶数,则需要取中间两个数值的平均数来找出中位数。我们再加入一位学生(年龄 12)到实地考察中:
9, 10, 10, 11, 12, 12, 13, 46
现在,两个中间值是 11 和 12。为了找出中位数,我们取它们的平均值:11.5。
中位数在财经新闻中经常被报道。关于房价的报告经常使用中位数,因为一个区域中少数几栋奢华别墅的销售可能会使得平均数变得无用。体育运动员的薪水也是一样:一两个超级明星可能会扭曲一个队伍的平均薪资。
一个好的测试是计算一组数值的平均数和中位数。如果它们接近,那么这组数据可能呈正态分布(即熟悉的钟形曲线),此时平均数是有用的。如果它们相差较远,那么这些数值不是正态分布的,中位数则是更好的代表。
使用百分位数函数查找中位数
PostgreSQL(与大多数关系型数据库一样)没有内建的median()函数,如同你在 Excel 或其他电子表格程序中会找到的那样。它也不包含在 ANSI SQL 标准中。我们可以使用 SQL 中的百分位数函数来找出中位数,并使用分位数或切分点将一组数字划分为相等的大小。百分位数函数是 ANSI SQL 标准的一部分。
在统计学中,百分位数表示在有序数据集中,某个百分比的数据位于该值以下。例如,医生可能会告诉你,你的身高位于你所在年龄组的第 60 百分位。这意味着 60%的人比你矮。
中位数相当于第 50 百分位——同样,一半的数值低于它,另一半高于它。百分位数函数有两个版本——percentile_cont(``n``)和percentile_disc(``n``)。这两个函数都是 ANSI SQL 标准的一部分,并且在 PostgreSQL、Microsoft SQL Server 和其他数据库中都有实现。
percentile_cont(``n``)函数计算百分位数作为连续值。也就是说,结果不必是数据集中的一个数字,而可以是介于两个数字之间的一个小数值。这遵循了在偶数个值中计算中位数的方法,即中位数是两个中间值的平均值。percentile_disc(``n``)函数则只返回离散值,这意味着结果将四舍五入为数据集中的一个数字。
在列表 6-10 中,我们制作了一个包含六个数字的测试表,并计算了百分位数。
CREATE TABLE percentile_test (
numbers integer
);
INSERT INTO percentile_test (numbers) VALUES
(1), (2), (3), (4), (5), (6);
SELECT
1 percentile_cont(.5)
WITHIN GROUP (ORDER BY numbers),
2 percentile_disc(.5)
WITHIN GROUP (ORDER BY numbers)
FROM percentile_test;
列表 6-10: 测试 SQL 百分位数函数
在连续型 1 和离散型 2 百分位数函数中,我们输入.5表示第 50 个百分位数,相当于中位数。运行代码后返回如下结果:
percentile_cont percentile_disc
--------------- ---------------
3.5 3
percentile_cont()函数返回了我们预期的中位数:3.5。但由于percentile_disc()计算的是离散值,它报告了3,即前 50%数据中的最后一个值。因为计算中位数的常用方法是在偶数个数据集中取两个中间值的平均值,因此使用percentile_cont(.5)来找到中位数。
使用人口普查数据查找中位数和百分位数
我们的人口普查数据可以展示中位数如何与平均数讲述不同的故事。列表 6-11 在sum()和avg()聚合函数旁边,增加了percentile_cont(),用以找出所有县区的总和、平均值和中位数人口。
SELECT sum(pop_est_2019) AS county_sum,
round(avg(pop_est_2019), 0) AS county_average,
percentile_cont(.5)
WITHIN GROUP (ORDER BY pop_est_2019) AS county_median
FROM us_counties_pop_est_2019;
列表 6-11: 使用sum()、avg()和percentile_cont()聚合函数
你的结果应该等于以下内容:
county_sum county_avg county_median
---------- ---------- -------------
328239523 104468 25726
中位数和平均值相差很大,这表明平均值可能会误导人们。根据 2019 年的估计,美国一半的县区人口少于 25,726 人,另一半则多于此数。如果你在关于美国人口统计的演讲中告诉观众“美国的平均县区人口是 104,468 人”,他们将会得到一个扭曲的现实图像。2019 年,估计有超过 40 个县区人口超过一百万,洛杉矶县的县区人口超过 1000 万,这将平均值拉高了。
使用百分位数函数查找其他分位数
你还可以将数据划分成更小的相等组进行分析。最常见的划分是四分位数(四个相等的组)、五分位数(五个组)和十分位数(10 个组)。要找到任何单独的值,只需将其代入百分位数函数即可。要找到标记第一个四分位数或最低 25%数据的值,你可以使用.25:
percentile_cont(.25)
然而,如果你想生成多个切点,逐个输入值会非常繁琐。你可以通过数组,即一个项目列表,将值传递给percentile_cont()。
列表 6-12 展示了如何一次性计算四个四分位数。
SELECT percentile_cont(1ARRAY[.25,.5,.75])
WITHIN GROUP (ORDER BY pop_est_2019) AS quartiles
FROM us_counties_pop_est_2019;
列表 6-12: 将值数组传递给percentile_cont()
在这个例子中,我们通过将值括在数组构造器 1 ARRAY[] 中来创建分割点。数组构造器是一个表达式,用于从括号内包含的元素构建一个数组。在括号内,我们提供以逗号分隔的值,表示切分的三个点,从而创建四个四分位数。运行查询后,你应该能看到以下输出:
quartiles
------------------------
{10902.5,25726,68072.75}
因为我们传入了一个数组,PostgreSQL 返回一个数组,结果中用大括号表示。每个四分位数之间用逗号分隔。第一个四分位数是 10,902.5,这意味着 25% 的县份人口等于或低于此值。第二个四分位数与中位数相同:25,726。第三个四分位数是 68,072.75,这意味着最大的 25% 的县份人口至少为此值。(在报告这些数据时,我们当然会四舍五入,因为讨论人口时我们不会用小数。)
数组在 ANSI SQL 标准中有定义,我们在这里的使用只是 PostgreSQL 中处理数组的几种方法之一。例如,你可以将表的某一列定义为特定数据类型的数组。如果你想将多个值存储在单一的数据库列中(例如博客文章的标签集合),而不是将它们存储在一个单独的表中,这样做就很有用。有关声明、查询和修改数组的示例,请参阅 PostgreSQL 文档:www.postgresql.org/docs/current/arrays.html。
数组还带有一系列函数(PostgreSQL 的相关函数见:www.postgresql.org/docs/current/functions-array.html),这些函数允许你执行诸如添加或移除值、计数元素等任务。一个用于处理 列表 6-12 返回结果的便捷函数是 unnest(),它通过将数组转换为行来使数组更易于阅读。列表 6-13 显示了相关代码。
SELECT unnest(
percentile_cont(ARRAY[.25,.5,.75])
WITHIN GROUP (ORDER BY pop_est_2019)
) AS quartiles
FROM us_counties_pop_est_2019;
列表 6-13:使用 unnest() 将数组转换为行
现在输出应该以行的形式展示:
quartiles
---------
10902.5
25726
68072.75
如果我们在计算十分位数,从结果数组中提取并将它们以行的形式展示将特别有帮助。
查找众数
我们可以使用 PostgreSQL 的 mode() 函数来查找众数,即出现次数最多的值。该函数不是标准 SQL 的一部分,其语法类似于百分位数函数。列表 6-14 展示了对 births_2019(显示出生人数的列)进行 mode() 计算的示例。
SELECT mode() WITHIN GROUP (ORDER BY births_2019)
FROM us_counties_pop_est_2019;
列表 6-14:使用 mode() 查找最频繁的值
结果是 86,这是 16 个县份共同拥有的出生人数。
总结
处理数字是从数据中获取意义的关键步骤,通过本章中涉及的数学技能,你已经准备好使用 SQL 处理数值分析的基础知识。书中的后续章节将介绍更深层次的统计概念,包括回归分析和相关性,但此时你已经掌握了求和、平均数和分位数的基础知识。你还学会了中位数如何比平均数更公平地评估一组值。仅此一点就能帮助你避免得出不准确的结论。
在下一章中,我将向你介绍将两个或更多表格中的数据结合起来的强大功能,以增加你进行数据分析的选择。我们将使用你已经加载到analysis数据库中的 2019 年美国人口普查数据,并探索其他数据集。
第七章:在关系数据库中连接表

在第二章中,我介绍了关系数据库的概念,这是一种支持数据跨多个相关表存储的应用程序。在关系模型中,每张表通常保存一个单独实体的数据——如学生、汽车、购买、房屋——表中的每一行描述该实体之一。一个被称为表连接的过程,允许我们将一张表中的行与其他表中的行进行连接。
关系数据库的概念来源于英国计算机科学家埃德加·F·科德(Edgar F. Codd)。1970 年,在为 IBM 工作时,他发布了一篇名为《大型共享数据银行的数据关系模型》(A Relational Model of Data for Large Shared Data Banks)的论文。他的观点彻底改变了数据库设计,并推动了 SQL 的发展。通过关系模型,你可以构建消除重复数据、更易维护并且在编写查询时提供更高灵活性的表,从而精确地获取你需要的数据。
使用 JOIN 链接表
要在查询中连接表,我们使用JOIN ... ON结构(或者本章我将介绍的其他JOIN变体)。JOIN是 ANSI SQL 标准的一部分,它通过ON子句中的布尔值表达式将一张表与另一张表连接。常用的语法测试相等性,通常采用如下形式:
SELECT *
FROM `table_a` JOIN `table_b`
ON `table_a.key_column` = `table_b.foreign_key_column`
这与你已经学习的基本SELECT类似,但我们不再在FROM子句中指定一个表,而是命名一个表,给出JOIN关键字,然后再命名第二个表。接着是ON子句,我们在这里使用等式比较操作符放置一个表达式。当查询执行时,它会返回两个表中ON子句表达式为true的行,意味着指定列中的值相等。
你可以使用任何评估为布尔结果true或false的表达式。例如,你可以匹配一个列中的值是否大于或等于另一个列中的值:
ON `table_a.key_column` >= `table_b.foreign_key_column`
这种情况很少见,但如果你的分析需要,还是可以选择这种方式。
使用关键列关联表
这是一个使用关键列关联表的例子:假设你是一个数据分析师,任务是检查一个公共机构按部门划分的薪资支出。你向该机构提交了一份信息自由法案(Freedom of Information Act)请求,期望收到一份简单的电子表格,列出每个员工及其薪水,格式如下:
dept location first_name last_name salary
---- -------- ---------- --------- ------
IT Boston Julia Reyes 115300
IT Boston Janet King 98000
Tax Atlanta Arthur Pappas 72700
Tax Atlanta Michael Taylor 89500
但实际上并不是这样。相反,机构向你发送了来自其薪资系统的数据转储:十几个 CSV 文件,每个文件代表数据库中的一张表。你阅读了文档,了解了数据布局(一定要记得请求这份文档!),并开始理解每张表中的列。两张表特别突出:一张名为employees,另一张名为departments。
使用 列表 7-1 中的代码,让我们创建这些表的版本,插入行,并检查如何连接两个表中的数据。使用你为这些练习创建的 analysis 数据库,运行所有代码,然后通过使用基本的 SELECT 语句查看数据,或通过点击 pgAdmin 中的表名并选择 查看/编辑数据▶所有行。
CREATE TABLE departments (
dept_id integer,
dept text,
city text,
1 CONSTRAINT dept_key PRIMARY KEY (dept_id),
2 CONSTRAINT dept_city_unique UNIQUE (dept, city)
);
CREATE TABLE employees (
emp_id integer,
first_name text,
last_name text,
salary numeric(10,2),
3 dept_id integer REFERENCES departments (dept_id),
4 CONSTRAINT emp_key PRIMARY KEY (emp_id)
);
INSERT INTO departments
VALUES
(1, 'Tax', 'Atlanta'),
(2, 'IT', 'Boston');
INSERT INTO employees
VALUES
(1, 'Julia', 'Reyes', 115300, 1),
(2, 'Janet', 'King', 98000, 1),
(3, 'Arthur', 'Pappas', 72700, 2),
(4, 'Michael', 'Taylor', 89500, 2);
列表 7-1:创建 departments 和 employees 表
这两个表遵循 Codd 的关系模型,因为每个表描述了一个单一实体的属性:机构的部门和员工。在 departments 表中,你应该看到以下内容:
dept_id dept city
------- ---- -------
1 Tax Atlanta
2 IT Boston
dept_id 列是表的主键。主键是一个列或列的集合,其值唯一标识表中的每一行。有效的主键列会强制执行某些约束:
-
列或列的集合必须为每一行提供唯一值。
-
列或列的集合不能有缺失值。
你使用 CONSTRAINT 关键字为 departments 1 和 employees 4 定义了主键,我将在第八章中详细介绍其他约束类型。dept_id 中的值唯一标识 departments 中的每一行,尽管这个例子只包含了部门名称和城市,但这个表可能还会包括其他信息,比如地址或联系信息。
employees 表应该包含以下内容:
emp_id first_name last_name salary dept_id
------ ---------- --------- --------- -------
1 Julia Reyes 115300.00 1
2 Janet King 98000.00 1
3 Arthur Pappas 72700.00 2
4 Michael Taylor 89500.00 2
emp_id 中的值唯一标识 employees 表中的每一行。为了确定每个员工所属的部门,表中包含了一个 dept_id 列。该列中的值引用 departments 表的主键中的值。我们称之为外键,你在创建表时作为约束 3 添加。外键约束要求其值在它所引用的列中已经存在。通常,这些值是另一个表的主键,但它也可以引用任何具有唯一值的列。因此,employees 表中的 dept_id 值必须在 departments 表中的 dept_id 存在;否则,你不能添加它们。这有助于维护数据的完整性。与主键不同,外键列可以为空,并且可以包含重复值。
在这个例子中,与员工 Julia Reyes 关联的 dept_id 为 1;这指的是 departments 表中主键 dept_id 的值 1。这告诉我们 Julia Reyes 是位于 Atlanta 的 Tax 部门的一员。
departments表还包括一个UNIQUE约束,我将在下一章的“UNIQUE 约束”中更详细地讨论。简而言之,它保证某一列中的值,或多列值的组合,是唯一的。在这里,它要求每一行在dept和city列中都有一对唯一的值,这有助于避免重复数据——例如,表中不会有两个名为Tax的亚特兰大部门。通常,你可以使用这种唯一的组合来创建一个自然键作为主键,我们将在下一章进一步讨论。
你可能会问:将数据拆分成这样组成的好处是什么?那么,考虑一下如果你按照最初的想法,将所有数据都放在一个表中,数据会是什么样子:
dept location first_name last_name salary
---- -------- ---------- --------- ------
IT Boston Julia Reyes 115300
IT Boston Janet King 98000
Tax Atlanta Arthur Pappas 72700
Tax Atlanta Michael Taylor 89500
首先,当你将来自不同实体的数据组合到一个表中时,不可避免地需要重复信息。在这里,部门名称和位置会为每个员工重复显示。当表仅包含四行数据时,或者即使是 4,000 行时,这种重复是可以接受的。但当表中有数百万行时,重复的冗长字符串既冗余又浪费宝贵的空间。
其次,将所有数据压缩到一个表中会使得数据管理变得困难。如果营销部门的名字更改为品牌营销(Brand Marketing)怎么办?表中的每一行都需要更新,如果有人不小心只更新了一部分行而不是所有行,就可能会引入错误。在这种模型中,更新部门名称要简单得多——只需更改表中的一行。
最后,信息被组织或规范化在多个表中,并不会妨碍我们将其作为一个整体来查看。我们总是可以通过JOIN查询数据,将多个表的列合并在一起。
现在你已经了解了表如何关联的基本知识,接下来我们来看看如何在查询中连接它们。
使用JOIN查询多个表
当你在查询中连接表时,数据库会在两个表中连接你指定用于连接的列,只有当这些列的值使得ON子句的表达式返回true时,才会连接。查询结果会包含来自两个表的列,前提是你在查询中要求它们。你还可以使用连接表中的列,通过WHERE子句过滤结果。
连接表的查询在语法上与基本的SELECT语句类似。不同之处在于查询还指定了以下内容:
-
要连接的表和列,使用 SQL
JOIN ... ON结构 -
使用
JOIN关键字的变体来指定要执行的连接类型
让我们先看一下JOIN ... ON结构的语法,然后再探索不同类型的连接。为了连接示例中的employees和departments表,并查看两个表的所有相关数据,可以从编写类似 Listing 7-2 的查询开始。
1 SELECT *
2 FROM employees JOIN departments
3 ON employees.dept_id = departments.dept_id
ORDER BY employees.dept_id;
Listing 7-2: 连接employees和departments表
在这个示例中,你在SELECT语句中使用了一个星号通配符,以便从查询中使用的所有表中包含所有列。接下来,在FROM子句中,你将JOIN关键字放置在你想要连接的两个表之间。最后,你使用ON子句指定要评估的表达式。对于每个表,你提供表名、一个句点和包含关键值的列名。两个表和列名之间用等号连接。
当你运行查询时,结果将包括两个表中所有在dept_id列中匹配的值。事实上,甚至dept_id列也会出现两次,因为你选择了两个表的所有列:
emp_id first_name last_name salary dept_id dept_id dept city
------ ---------- --------- --------- ------- ------- ---- -------
1 Julia Reyes 115300.00 1 1 Tax Atlanta
2 Janet King 98000.00 1 1 Tax Atlanta
3 Arthur Pappas 72700.00 2 2 IT Boston
4 Michael Taylor 89500.00 2 2 IT Boston
因此,即使数据存在于两个表中,每个表都有一组专注的列,你仍然可以查询这些表,将相关数据汇总到一起。在本章稍后的“在连接中选择特定列”一节中,我将向你展示如何从两个表中只检索你需要的列。
理解 JOIN 类型
在 SQL 中有多种连接表的方法,使用哪种连接取决于你希望如何检索数据。以下列表描述了不同类型的连接。在回顾每种连接时,思考将两个表并排放置,一个在JOIN关键字的左边,另一个在右边会很有帮助。每种连接的具体数据驱动示例如下所示:
-
JOIN返回两个表中在连接列中找到匹配值的行。另一种语法是INNER JOIN。 -
LEFT JOIN返回左侧表中的每一行。当 SQL 在右侧表中找到匹配的值时,这一行的值会包含在结果中。否则,右侧表的值不会显示。 -
RIGHT JOIN返回右侧表中的每一行。当 SQL 在左侧表中找到匹配的值时,这一行的值会包含在结果中。否则,左侧表的值不会显示。 -
FULL OUTER JOIN返回两个表中的每一行,并在连接列中的值匹配时合并这些行。如果左侧或右侧表中的某个值没有匹配项,则查询结果中不会显示该表的任何值。 -
CROSS JOIN返回两个表中所有可能的行组合。
让我们通过数据来看看这些连接是如何运作的。假设你有两个简单的表,分别存储一个学区的学校名称,学区计划未来的入学情况:district_2020 和 district_2035。district_2020 中有四行数据:
id school_2020
-- ------------------------
1 Oak Street School
2 Roosevelt High School
5 Dover Middle School
6 Webutuck High School
district_2035 中有五行数据:
id school_2035
-- ---------------------
1 Oak Street School
2 Roosevelt High School
3 Morrison Elementary
4 Chase Magnet Academy
6 Webutuck High School
请注意,学区预计会随着时间发生变化。只有id为1、2和6的学校同时出现在两个表中,而其他学校仅出现在其中一个表中。这种情况很常见,也是数据分析师的一个常见初始任务——特别是当你处理的表比这两个表有更多行时——你可以使用 SQL 来识别哪些学校同时出现在两个表中。使用不同的连接可以帮助你找到这些学校以及其他相关信息。
再次使用你的analysis数据库,运行 Listing 7-3 中的代码,来构建和填充这两个表。
CREATE TABLE district_2020 (
1 id integer CONSTRAINT id_key_2020 PRIMARY KEY,
school_2020 text
);
CREATE TABLE district_2035 (
2 id integer CONSTRAINT id_key_2035 PRIMARY KEY,
school_2035 text
);
3 INSERT INTO district_2020 VALUES
(1, 'Oak Street School'),
(2, 'Roosevelt High School'),
(5, 'Dover Middle School'),
(6, 'Webutuck High School');
INSERT INTO district_2035 VALUES
(1, 'Oak Street School'),
(2, 'Roosevelt High School'),
(3, 'Morrison Elementary'),
(4, 'Chase Magnet Academy'),
(6, 'Webutuck High School');
Listing 7-3: 创建两个表以探索JOIN类型
我们创建并填充了两个表:这些声明现在应该看起来很熟悉,但有一个新的元素:我们为每个表添加了主键。在district_2020的id列 1 和district_2035的id列 2 之后,关键字CONSTRAINT key_name PRIMARY KEY表明这些列将作为它们各自表的主键。这意味着每个表中的每一行,id列都必须填充,并且包含该表中每一行唯一的值。最后,我们使用熟悉的INSERT语句 3 将数据添加到表中。
JOIN
当我们希望返回仅包含在两个表中匹配列值的行时,我们使用JOIN或INNER JOIN。要查看这个示例,请运行 Listing 7-4 中的代码,它连接了你刚刚创建的两个表。
SELECT *
FROM district_2020 JOIN district_2035
ON district_2020.id = district_2035.id
ORDER BY district_2020.id;
Listing 7-4: 使用JOIN
类似于我们在 Listing 7-2 中使用的方法,我们在JOIN关键字两侧指定要连接的两个表。然后,在ON子句中,我们指定用于连接的表达式,在这个例子中是两个表的id列的相等性。两个表中都有三个学校 ID,因此查询只返回这三个 ID 匹配的行。仅存在于其中一个表中的学校不会出现在结果中。还要注意,JOIN关键字左侧表中的列会显示在结果表的左侧:
id school_2020 id school_2035
-- --------------------- -- ---------------------
1 Oak Street School 1 Oak Street School
2 Roosevelt High School 2 Roosevelt High School
6 Webutuck High School 6 Webutuck High School
什么时候应该使用JOIN?通常,当你处理结构良好、维护良好的数据集时,并且需要找到在所有连接的表中都存在的行。因为JOIN不会返回仅在一个表中存在的行,所以如果你想查看一个或多个表中的所有数据,请使用其他类型的连接。
使用 USING 的 JOIN
如果你在JOIN的ON子句中使用相同名称的列,你可以通过用USING子句替代ON子句,减少冗余输出并简化查询语法,如在 Listing 7-5 中所示。
SELECT *
FROM district_2020 JOIN district_2035
1 USING (id)
ORDER BY district_2020.id;
Listing 7-5: 使用USING的JOIN
在指定要连接的表后,我们添加USING 1,后面跟着括号中的列名,这个列名是两个表用于连接的列——在这个例子中是id。如果我们要在多个列上进行连接,我们将在括号内用逗号分隔它们。运行查询后,你应该看到以下结果:
id school_2020 school_2035
-- --------------------- ---------------------
1 Oak Street School Oak Street School
2 Roosevelt High School Roosevelt High School
6 Webutuck High School Webutuck High School
注意,id在这个JOIN中出现在两个表中,并且具有相同的值,因此它只显示一次。这是一个简单且方便的简写方式。
LEFT JOIN 和 RIGHT JOIN
与JOIN相比,LEFT JOIN和RIGHT JOIN关键字分别返回一个表的所有行,并且当另一个表中存在匹配值的行时,会将该行的值包括在结果中。否则,另一个表中的值不会显示。
让我们先看看 LEFT JOIN 的实际操作。执行 列表 7-6 中的代码。
SELECT *
FROM district_2020 LEFT JOIN district_2035
ON district_2020.id = district_2035.id
ORDER BY district_2020.id;
列表 7-6:使用 LEFT JOIN
查询的结果显示了来自 district_2020 的所有四行,这些数据位于连接的左侧,以及 district_2035 中与 id 列值匹配的三行。因为 district_2035 的 id 列中没有值为 5 的数据,所以没有匹配项,因此 LEFT JOIN 会在右侧返回一行空数据,而不是像 JOIN 那样完全省略左表中的行。最后,district_2035 中没有与 district_2020 匹配的任何值的行会从结果中省略:
id school_2020 id school_2035
-- --------------------- -- ---------------------
1 Oak Street School 1 Oak Street School
2 Roosevelt High School 2 Roosevelt High School
5 Dover Middle School
6 Webutuck High School 6 Webutuck High School
通过运行 RIGHT JOIN,我们可以看到类似但相反的行为,正如 列表 7-7 中所示。
SELECT *
FROM district_2020 RIGHT JOIN district_2035
ON district_2020.id = district_2035.id
ORDER BY district_2035.id;
列表 7-7:使用 RIGHT JOIN
这次,查询返回了来自 district_2035 的所有行,这些数据位于连接的右侧,以及 district_2020 中 id 列值匹配的行。查询结果会省略没有与 district_2035 在 id 上匹配的 district_2020 行:
id school_2020 id school_2035
-- --------------------- -- ---------------------
1 Oak Street School 1 Oak Street School
2 Roosevelt High School 2 Roosevelt High School
3 Morrison Elementary
4 Chase Magnet Academy
6 Webutuck High School 6 Webutuck High School
在以下几种情况下,你可以使用这些连接类型之一:
-
你希望查询结果包含其中一个表的所有行。
-
你希望查找一个表中的缺失值。例如,在比较表示两个不同时间段的实体数据时。
-
当你知道某些连接表中的行没有匹配的值时。
和 JOIN 一样,如果表满足条件,你可以用 USING 子句替换 ON 子句。
FULL OUTER JOIN
当你希望在连接中查看两个表的所有行时,无论是否匹配任何数据,都可以使用 FULL OUTER JOIN 选项。要查看其效果,请运行 列表 7-8。
SELECT *
FROM district_2020 FULL OUTER JOIN district_2035
ON district_2020.id = district_2035.id
ORDER BY district_2020.id;
列表 7-8:使用 FULL OUTER JOIN
结果返回了左表中的每一行,包括匹配的行以及右表中缺失行的空白数据,后面跟着右表中剩余的缺失行:
id school_2020 id school_2035
-- --------------------- -- ---------------------
1 Oak Street School 1 Oak Street School
2 Roosevelt High School 2 Roosevelt High School
5 Dover Middle School
6 Webutuck High School 6 Webutuck High School
3 Morrison Elementary
4 Chase Magnet Academy
虽然 FULL OUTER JOIN 明确来说不如内连接以及左连接或右连接那样常用且有用,但你仍然可以在一些任务中使用它:比如连接两个部分重叠的数据源,或者可视化表格之间共享匹配值的程度。
CROSS JOIN
在 CROSS JOIN 查询中,结果(也称为 笛卡尔积)将左表中的每一行与右表中的每一行配对,展示所有可能的行组合。列表 7-9 显示了 CROSS JOIN 的语法;因为该连接不需要在关键列之间查找匹配项,因此无需提供 ON 子句。
SELECT *
FROM district_2020 CROSS JOIN district_2035
ORDER BY district_2020.id, district_2035.id;
列表 7-9:使用 CROSS JOIN
结果有 20 行——即左表中的四行与右表中的五行的乘积:
id school_2020 id school_2035
-- --------------------- -- ---------------------
1 Oak Street School 1 Oak Street School
1 Oak Street School 2 Roosevelt High School
1 Oak Street School 3 Morrison Elementary
1 Oak Street School 4 Chase Magnet Academy
1 Oak Street School 6 Webutuck High School
2 Roosevelt High School 1 Oak Street School
2 Roosevelt High School 2 Roosevelt High School
2 Roosevelt High School 3 Morrison Elementary
2 Roosevelt High School 4 Chase Magnet Academy
2 Roosevelt High School 6 Webutuck High School
5 Dover Middle School 1 Oak Street School
5 Dover Middle School 2 Roosevelt High School
5 Dover Middle School 3 Morrison Elementary
5 Dover Middle School 4 Chase Magnet Academy
5 Dover Middle School 6 Webutuck High School
6 Webutuck High School 1 Oak Street School
6 Webutuck High School 2 Roosevelt High School
6 Webutuck High School 3 Morrison Elementary
6 Webutuck High School 4 Chase Magnet Academy
6 Webutuck High School 6 Webutuck High School
除非你想要一个超长的咖啡休息时间,否则建议避免在大型表上使用CROSS JOIN查询。两张表,每张有 25 万条记录,将生成 625 亿行的结果集,这会给即便是最强大的服务器也带来巨大压力。一个更实际的用途是生成数据来创建一个清单,比如为商店中的几种衬衫款式提供的所有颜色。
使用NULL查找缺失值的行
每当你连接表时,明智的做法是检查一个表中的关键值是否出现在另一个表中,以及是否有任何缺失的值。差异发生的原因有很多种。有些数据可能随着时间变化。例如,一个新产品的表格可能包含在旧产品表格中不存在的代码。或者可能存在问题,如文书错误或数据库输出不完整。这些都是进行正确数据推断时的重要背景信息。
当你只有少量行时,通过眼睛观察数据是一种简单的方式来查找缺失数据的行,正如我们在前面的连接示例中所做的那样。对于大型表格,你需要更好的策略:筛选出所有没有匹配的行。为此,我们使用关键字NULL。
在 SQL 中,NULL是一个特殊值,表示没有数据或数据因为没有包含而未知。例如,如果一个人填写地址表单时跳过了“中间名首字母”字段,我们就不会在数据库中存储空字符串,而是使用NULL来表示未知值。需要记住的是,NULL与0或空字符串(你可以使用两个引号('')表示)是不同的。这两者可能有一些意图不明确的含义,容易被误解,因此我们使用NULL来表示值是未知的。与0或空字符串不同,你可以在不同的数据类型中使用NULL。
当 SQL 连接返回其中一个表的空行时,这些列不会返回为空,而是返回值NULL。在列表 7-10 中,我们将通过添加WHERE子句,使用IS NULL来筛选district_2035表的id列,以找到那些行。如果我们想查看有数据的列,我们会使用IS NOT NULL。
SELECT *
FROM district_2020 LEFT JOIN district_2035
ON district_2020.id = district_2035.id
WHERE district_2035.id IS NULL;
列表 7-10:使用IS NULL筛选出缺失值
现在,连接的结果仅显示左侧表中没有在右侧表中匹配的那一行。这通常被称为反连接(anti-join)。
id school_2020 id school_2035
-- ------------------- ------ ---------------------
5 Dover Middle School
很容易反转输出,以查看右侧表中与左侧表没有匹配的行。你只需将查询改为使用RIGHT JOIN,并修改WHERE子句,筛选出district_2020.id IS NULL。
理解三种表关系类型
连接表的一部分科学(或者说艺术,一些人可能会这么说)涉及到理解数据库设计者如何设计表之间的关系,这也被称为数据库的关系模型。表关系有三种类型:一对一、一对多和多对多。
一对一关系
在我们例子 7-4 中的JOIN操作里,两个表中的id值没有重复:district_2020表中只有一行id为1,而district_2035表中只有一行id为1。这意味着在任意表中的id在另一个表中最多只能找到一个匹配项。在数据库术语中,这称为一对一关系。考虑另一个例子:连接两个包含州级人口普查数据的表。一个表可能包含家庭收入数据,另一个表则是关于教育水平的数据。两个表都会有 51 行(每个州加上华盛顿特区),如果我们根据州名、州缩写或标准地理代码来连接它们,那么每个表中的每个键值只会有一个匹配项。
一对多关系
在一对多关系中,一个表中的关键值将在另一个表的连接列中有多个匹配值。考虑一个追踪汽车的数据库。一个表会包含制造商数据,每个制造商(例如福特、本田、特斯拉等)占一行。另一个表则包含模型名称,例如野马、思域、Model 3 和雅阁,每个制造商表中的行会与这些行匹配。
多对多关系
多对多关系出现在一个表中的多个项可以与另一个表中的多个项相关联,反之亦然。例如,在一个棒球联赛中,每个球员可以被分配到多个位置,而每个位置也可以由多个球员担任。由于这种复杂性,多对多关系通常会涉及一个第三个中间表。在棒球联赛的例子中,数据库可能会有一个players表、一个positions表,以及一个第三个表players_positions,它有两个列来支持多对多关系:来自players表的id和来自positions表的id。
理解这些关系至关重要,因为它帮助我们判断查询结果是否准确反映了数据库的结构。
在连接中选择特定列
到目前为止,我们使用了星号通配符来选择两个表中的所有列。这对于快速检查数据是可以的,但更常见的情况是你需要指定一个列的子集。你可以专注于你想要的数据,并避免在有人向表中添加新列时不小心改变查询结果。
正如你在单表查询中所学到的,选择特定列时,需要使用SELECT关键字,后面跟上所需的列名。在连接多个表时,最佳实践是将表名与列名一起写明。原因是,多个表可能包含相同名称的列,这在我们当前的连接表中是完全正确的。
考虑以下查询,它尝试在没有指定表名的情况下获取id列:
SELECT id
FROM district_2020 LEFT JOIN district_2035
ON district_2020.id = district_2035.id;
由于id在district_2020和district_2035中都存在,服务器会抛出一个错误,在 pgAdmin 的结果面板中显示:column reference "id" is ambiguous(列引用id不明确)。目前无法判断id属于哪个表。
为了修正错误,我们需要在每个查询的列前面加上表名,正如我们在ON子句中所做的那样。示例 7-11 展示了语法,指定我们想要从district_2020中获取id列。我们还从两个表中获取学校名称。
SELECT district_2020.id,
district_2020.school_2020,
district_2035.school_2035
FROM district_2020 LEFT JOIN district_2035
ON district_2020.id = district_2035.id
ORDER BY district_2020.id;
示例 7-11:在连接查询中特定列的查询
我们只需要在每个列名前加上它所在的表名,其余查询语法保持不变。结果会返回来自每个表的请求列:
id school_2020 school_2035
-- --------------------- ----------------------
1 Oak Street School Oak Street School
2 Roosevelt High School Roosevelt High School
5 Dover Middle School
6 Webutuck High School Webutuck High School
我们还可以添加之前在普查数据中使用过的AS关键字,使结果中明确标识id列来自district_2020。语法如下所示:
SELECT district_2020.id AS d20_id, ...
这样,district_2020 id列的名称将以d20_id的形式显示在结果中。
使用表别名简化 JOIN 语法
为列指定表名其实不难,但对于多个列重复书写冗长的表名会让你的代码显得杂乱。为同事写可读性强的代码是最好的方式,而这通常不应该让他们在超过 25 列的代码中重复查找表名!编写更简洁代码的一种方法是使用一种叫做表别名的简写方式。
创建表别名时,我们在FROM子句中声明表时,表名后加一个或两个字符作为别名。(你可以为别名使用多个字符,但如果目标是简化代码,就不要过多使用。)这些字符将作为别名,我们可以在代码中任何引用表的位置使用它。示例 7-12 展示了这种方式的使用。
SELECT d20.id,
d20.school_2020,
d35.school_2035
1 FROM district_2020 AS d20 LEFT JOIN district_2035 AS d35
ON d20.id = d35.id
ORDER BY d20.id;
示例 7-12:使用表别名简化代码
在FROM子句中,我们使用AS关键字声明了district_2020的别名d20,以及district_2035的别名d35。这两个别名比表名更短,但依然有意义。一旦这样做,我们就可以在代码的其他地方使用别名代替完整的表名。我们的 SQL 立刻变得更简洁,这是理想的做法。请注意,AS关键字在这里是可选的;你可以在声明表名和列名的别名时省略它。
连接多个表
当然,SQL 连接不仅限于两个表。只要我们有匹配的列值可以进行连接,我们就可以继续将表添加到查询中。假设我们获得了另外两个与学校相关的表,并且想要在三表连接中将它们与 district_2020 连接。district_2020_enrollment 表包含每个学校的学生人数:
id enrollment
-- ----------
1 360
2 1001
5 450
6 927
district_2020_grades 表包含每个楼宇中所在的年级:
id grades
-- ------
1 K-3
2 9-12
5 6-8
6 9-12
为了编写查询,我们将使用列表 7-13 来创建表,加载数据,并运行查询将它们连接到 district_2020。
CREATE TABLE district_2020_enrollment (
id integer,
enrollment integer
);
CREATE TABLE district_2020_grades (
id integer,
grades varchar(10)
);
INSERT INTO district_2020_enrollment
VALUES
(1, 360),
(2, 1001),
(5, 450),
(6, 927);
INSERT INTO district_2020_grades
VALUES
(1, 'K-3'),
(2, '9-12'),
(5, '6-8'),
(6, '9-12');
SELECT d20.id,
d20.school_2020,
en.enrollment,
gr.grades
1 FROM district_2020 AS d20 JOIN district_2020_enrollment AS en
ON d20.id = en.id
2 JOIN district_2020_grades AS gr
ON d20.id = gr.id
ORDER BY d20.id;
列表 7-13:连接多个表
在执行 CREATE TABLE 和 INSERT 部分的脚本后,我们得到了新的 district_2020_enrollment 和 district_2020_grades 表,每个表都包含与本章前面提到的 district_2020 相关的记录。然后,我们将所有三个表连接起来。
在 SELECT 查询中,我们通过表的 id 列将 district_2020 与 district_2020_enrollment 连接 1。我们还声明了表别名,以保持代码简洁。接下来,查询将 district_2020 与 district_2020_grades 连接,再次使用 id 列 2。
我们的结果现在包括来自所有三个表的列:
id school_2020 enrollment grades
-- --------------------- ---------- ------
1 Oak Street School 360 K-3
2 Roosevelt High School 1001 9-12
5 Dover Middle School 450 6-8
6 Webutuck High School 927 9-12
如果需要,你还可以通过额外的连接将更多表添加到查询中。你也可以根据表之间的关系,在不同的列上进行连接。虽然 SQL 中并没有硬性限制一个查询中可以连接的表数量,但某些数据库系统可能会设置限制。请查阅相关文档。
使用集合运算符合并查询结果
某些情况下,我们需要重新排序数据,以使得来自不同表的列不是像连接那样并排返回,而是聚集在一个结果中。示例包括基于 JavaScript 的数据可视化或使用 R 和 Python 编程语言库进行分析的输入格式要求。实现这种数据操作的一种方法是使用 ANSI 标准 SQL 集合运算符 UNION、INTERSECT 和 EXCEPT。集合运算符将多个 SELECT 查询的结果合并。下面是每个运算符的简要介绍:
-
UNION给定两个查询,它将第二个查询的结果行附加到第一个查询返回的行,并删除重复项,生成一个包含唯一行的组合集合。将语法修改为UNION ALL会返回所有行,包括重复项。 -
INTERSECT只返回同时存在于两个查询结果中的行,并删除重复项。 -
EXCEPT返回仅存在于第一个查询结果中,但不在第二个查询结果中的行。重复项会被移除。
对于这些操作,两个查询必须生成相同数量的列,并且来自两个查询的结果列必须具有兼容的数据类型。让我们继续使用学校区表,简要展示它们的工作原理。
UNION 和 UNION ALL
在列表 7-14 中,我们使用 UNION 来组合检索 district_2020 和 district_2035 中所有行的查询。
SELECT * FROM district_2020
1 UNION
SELECT * FROM district_2035
2 ORDER BY id;
示例 7-14:使用 UNION 合并查询结果
该查询由两个完整的SELECT语句组成,中间用UNION关键字 1 连接。ORDER BY 2 位于id列上,发生在集合操作之后,因此不能作为每个SELECT的一部分列出。从我们已经处理的数据来看,你知道这些查询将返回两个表中完全相同的几行。但是通过将查询合并为UNION,我们的结果消除了重复项:
id school_2020
-- ---------------------
1 Oak Street School
2 Roosevelt High School
3 Morrison Elementary
4 Chase Magnet Academy
5 Dover Middle School
6 Webutuck High School
注意,学校的名称在school_2020列中,这是第一个查询结果的一部分。第二个查询中来自district_2035表的school_2035列的学校名称被简单地附加到第一个查询的结果中。因此,第二个查询中的列必须与第一个查询中的列匹配,并且具有兼容的数据类型。
如果我们希望结果包含重复行,我们可以在查询中将UNION替换为UNION ALL,就像在示例 7-15 中一样。
SELECT * FROM district_2020
UNION ALL
SELECT * FROM district_2035
ORDER BY id;
示例 7-15:使用 UNION ALL 合并查询结果
这将产生所有行,包含重复项:
id school_2020
-- ---------------------
1 Oak Street School
1 Oak Street School
2 Roosevelt High School
2 Roosevelt High School
3 Morrison Elementary
4 Chase Magnet Academy
5 Dover Middle School
6 Webutuck High School
6 Webutuck High School
最后,定制合并结果通常是有帮助的。例如,你可能想知道每一行的表值来自哪里,或者你可能想包含或排除某些列。示例 7-16 展示了使用UNION ALL的一个例子。
1 SELECT '2020' AS year,
2 school_2020 AS school
FROM district_2020
UNION ALL
SELECT '2035' AS year,
school_2035
FROM district_2035
ORDER BY school, year;
示例 7-16:自定义 UNION 查询
在第一个查询的SELECT语句 1 中,我们将字符串2020指定为填充名为year的列的值。我们在第二个查询中也使用2035作为字符串。这个方法与第五章“导入时向列添加值”部分中使用的技术类似。然后,我们将school_2020列 2 重命名为school,因为它将显示来自两个年份的学校。
执行查询以查看结果:
year school
---- --------------------
2035 Chase Magnet Academy
2020 Dover Middle School
2035 Morrison Elementary
2020 Oak Street School
2035 Oak Street School
2020 Roosevelt High School
2035 Roosevelt High School
2020 Webutuck High School
2035 Webutuck High School
现在我们的查询为每所学校生成一个年份标识,例如,我们可以看到 Dover 中学的那一行来自查询district_2020表的结果。
INTERSECT 和 EXCEPT
现在你已经知道如何使用UNION,你可以将相同的概念应用到INTERSECT和EXCEPT。示例 7-17 展示了两者,你可以分别运行它们以查看结果的差异。
SELECT * FROM district_2020
1 INTERSECT
SELECT * FROM district_2035
ORDER BY id;
SELECT * FROM district_2020
2 EXCEPT
SELECT * FROM district_2035
ORDER BY id;
示例 7-17:使用 INTERSECT 和 EXCEPT 合并查询结果
使用INTERSECT 1 的查询仅返回在两个查询结果中都存在的行,并消除重复项:
id school_2020
-- --------------
1 Oak Street School
2 Roosevelt High School
6 Webutuck High School
使用EXCEPT 2 的查询返回在第一个查询中存在但在第二个查询中不存在的行,并消除可能存在的重复项:
id school_2020
-- -------------------
5 Dover Middle School
与UNION一起,使用INTERSECT和EXCEPT的查询为你提供了充足的能力来安排和检查数据。
最后,让我们简要回到连接,看看如何对不同表中的数字进行计算。
对连接表列执行数学运算
我们在第六章探讨的数学函数在处理连接后的表格时同样适用。当在操作中引用某个列时,我们需要包括表格名称,就像在选择表格列时一样。如果你处理的是任何定期发布的新数据,你会发现这个概念对于将新发布的表格与旧表格连接并探索数值变化非常有用。
这正是我和许多记者每次发布新一轮人口普查数据时所做的事情。我们会加载新数据,并试图找出人口、收入、教育和其他指标的增长或下降模式。让我们通过重新访问我们在第五章创建的us_counties_pop_est_2019表,并加载显示 2010 年县级人口估算数据的新表来看看如何操作。为了创建表格,导入数据,并将其与 2019 年估算数据进行连接,请运行 Listing 7-18 中的代码。
1 CREATE TABLE us_counties_pop_est_2010 (
state_fips text,
county_fips text,
region smallint,
state_name text,
county_name text,
estimates_base_2010 integer,
CONSTRAINT counties_2010_key PRIMARY KEY (state_fips, county_fips)
);
2 COPY us_counties_pop_est_2010
FROM '*C:\YourDirectory\*us_counties_pop_est_2010.csv'
WITH (FORMAT CSV, HEADER);
3 SELECT c2019.county_name,
c2019.state_name,
c2019.pop_est_2019 AS pop_2019,
c2010.estimates_base_2010 AS pop_2010,
c2019.pop_est_2019 - c2010.estimates_base_2010 AS raw_change,
4 round( (c2019.pop_est_2019::numeric - c2010.estimates_base_2010)
/ c2010.estimates_base_2010 * 100, 1 ) AS pct_change
FROM us_counties_pop_est_2019 AS c2019
JOIN us_counties_pop_est_2010 AS c2010
5 ON c2019.state_fips = c2010.state_fips
AND c2019.county_fips = c2010.county_fips
6 ORDER BY pct_change DESC;
Listing 7-18: 在连接的人口普查表上进行数学运算
在这段代码中,我们在前面的基础上进行构建。我们有熟悉的CREATE TABLE语句 1,针对本次练习,它包含州、县和区域代码,并且有列显示州和县的名称。它还包括一个estimates_base_2010列,其中包含美国人口普查局为每个县估算的 2010 年人口(美国人口普查局将其每 10 年一次的完整人口普查数据进行修改,以创建一个基准数字,用于与后续年度的估算数据进行比较)。COPY语句 2 导入一个包含人口普查数据的 CSV 文件;你可以在nostarch.com/practical-sql-2nd-edition/找到us_counties_pop_est_2010.csv以及本书的所有资源。下载文件后,你需要更改文件路径,以指向你保存该文件的位置。
完成导入后,你应该会得到一个名为us_counties_pop_est_2010的表格,包含 3,142 行数据。现在我们有了 2010 年和 2019 年的人口估算表格,计算每个县在这两年之间的人口百分比变化就显得很有意义了。哪些县在增长方面领先全国?哪些县则出现了人口下降?
我们将使用第六章中使用的百分比变化公式来得出答案。SELECT语句 3 包括来自 2019 年表格的县名和州名,这些数据被别名为c2019。接下来是 2019 年和 2010 年表格中的人口估算列,两个列都使用AS重命名,以简化结果中的列名。为了得到人口的原始变化,我们从 2019 年的估算数据中减去 2010 年的基准估算值,而要计算百分比变化,我们使用公式 4,并将结果四舍五入到小数点后一位。
我们通过匹配两个表中两个列的值来连接:state_fips 和 county_fips 5。之所以使用两个列而不是一个列进行连接,是因为在这两个表中,州代码和县代码的组合代表了一个唯一的县。我们使用 AND 逻辑运算符将这两个条件组合起来。使用该语法时,只有当两个条件都满足时,行才会被连接。最后,我们按百分比变化 6 降序排列结果,这样我们就可以看到增长最快的地区排在前面。
这需要很多工作,但这是值得的。以下是结果的前五行所显示的内容:
county_name state_name pop_2019 pop_2010 raw_change pct_change
--------------- ---------- -------- -------- ---------- ----------
McKenzie County North Dakota 15024 6359 8665 136.3
Loving County Texas 169 82 87 106.1
Williams County North Dakota 37589 22399 15190 67.8
Hays County Texas 230191 157103 73088 46.5
Wasatch County Utah 34091 23525 10566 44.9
两个县,北达科他州的麦肯齐县和德克萨斯州的洛文县,从 2010 年到 2019 年人口增长了两倍以上,其他北达科他州和德克萨斯州的县也显示出显著的增长。这些地方每个都有自己的故事。对于麦肯齐县和北达科他州的其他县,巴肯地质层中的石油和天然气勘探热潮是人口激增的背后原因。这只是我们从这次分析中提取的一个有价值的洞见,也是理解国家人口趋势的起点。
总结
由于表关系是数据库架构的基础,学习如何在查询中连接表使你能够处理你将遇到的许多更复杂的数据集。在表之间实验不同类型的连接操作可以让你了解数据是如何收集的,并揭示何时存在质量问题。将尝试不同的连接方式作为你探索新数据集的一项常规任务。
继续前进,我们将继续在这些更大的概念基础上深入挖掘,寻找数据集中的信息,并处理数据类型的细节,确保数据的质量。但首先,我们将看看另一个基础元素:采用最佳实践使用 SQL 构建可靠、高效的数据库。
第八章:为你设计的表格

对秩序和细节的执着有时是件好事。当你匆忙出门时,看到钥匙挂在你总是放的地方的钩子上会让你安心。数据库设计同样如此。当你需要从数十个表和数百万行中挖掘出一条信息时,你会感谢这种对细节的执着。有了经过精心组织、命名得当的表格,分析过程变得更加可控。
在本章中,我将在第七章的基础上,介绍如何组织和加速 SQL 数据库的最佳实践,无论是你自己的,还是你继承来进行分析的数据库。我们将深入探讨表格设计,探索命名规则和约定、如何维护数据完整性,以及如何为表格添加索引以加速查询。
遵循命名约定
编程语言往往有自己的风格模式,甚至不同的 SQL 编码者群体在命名表格、列和其他对象(称为标识符)时也偏好某些约定。有些人喜欢使用驼峰式命名法,如 berrySmoothie,其中单词连写,且每个单词的首字母大写(除了第一个单词)。帕斯卡式命名法,如 BerrySmoothie,遵循类似的模式,但首字母也大写。使用蛇形命名法,如 berry_smoothie,所有单词都小写,并用下划线分隔。
你会发现每种命名约定都有热情的支持者,有些偏好与特定的数据库应用程序或编程语言有关。例如,Microsoft 在其 SQL Server 数据库文档中使用帕斯卡式命名法。在本书中,出于 PostgreSQL 相关的原因,我稍后会解释,我们使用蛇形命名法,如 us_counties_pop_est_2019。无论你喜欢哪种约定,或者你被要求使用哪种约定,重要的是要始终如一地应用它。一定要检查你的组织是否有风格指南,或者主动提议一起制定,并严格遵守。
混合风格或不遵循任何约定通常会导致混乱。例如,想象一下连接到数据库并发现以下一组表格:
-
Customers -
customers -
custBackup -
customer_analysis -
customer_test2 -
customer_testMarch2012 -
customeranalysis
你可能会有疑问。例如,哪张表格实际上包含客户的当前数据?一个混乱的命名方案——以及缺乏条理性——使得别人很难进入你的数据,也让你自己在接着上次的工作时感到困难。
让我们探索与命名标识符相关的注意事项以及最佳实践建议。
引号标识符启用混合大小写
无论你提供什么样的大小写,PostgreSQL 都将标识符视为小写,除非你将双引号括住标识符。请看以下 PostgreSQL 的 CREATE TABLE 语句:
CREATE TABLE customers (
customer_id text,
`--snip--`
);
CREATE TABLE Customers (
customer_id text,
`--snip--`
);
当你按顺序执行这些语句时,第一个命令创建了一个名为customers的表。第二个语句并不是创建一个名为Customers的单独表,而是会抛出一个错误:relation "customers" already exists。因为你没有给标识符加引号,PostgreSQL 将customers和Customers视为相同的标识符,不区分大小写。为了保留大写字母并创建一个名为Customers的独立表,你必须将标识符用引号括起来,像这样:
CREATE TABLE "Customers" (
customer_id serial,
`--snip--`
);
然而,因为这要求你在SELECT语句中查询Customers而不是customers时,必须对其名称加引号:
SELECT * FROM "Customers";
这可能很难记住,也容易让用户混淆。确保你的表名清晰,并且与数据库中的其他表区分开。
引用标识符的陷阱
引用标识符还允许你使用其他情况下不被允许的字符,包括空格。对于某些人来说,这可能是一个吸引人的特性,但也有一些负面影响。你可能希望在重新造林数据库中将"trees planted"作为列名,但是这样所有用户在引用该列时都必须加上引号。如果在查询中省略引号,数据库会报错,认为trees和planted是不同的列,并提示trees列不存在。一个更具可读性和可靠性的选择是使用蛇形命名法,例如trees_planted。
引号还允许你使用 SQL 的保留关键字,即在 SQL 中具有特殊意义的单词。你已经遇到过一些,比如TABLE、WHERE或SELECT。大多数数据库开发人员不推荐将保留关键字用作标识符。至少这会造成混淆,最糟糕的是,忽略或忘记稍后引用该关键字时,可能会导致错误,因为数据库会将该单词解释为命令,而不是标识符。
命名标识符的指南
考虑到引用标识符的额外负担及其潜在问题,最好保持标识符名称简单、不加引号且一致。以下是我的建议:
-
使用蛇形命名法(snake case)。蛇形命名法具有良好的可读性和可靠性,如前面提到的
trees_planted示例所示。它在官方 PostgreSQL 文档中被广泛使用,并帮助使多单词的名称更容易理解:video_on_demand比videoondemand一目了然。 -
使名称易于理解,避免使用晦涩的缩写。如果你正在构建一个与旅游相关的数据库,
arrival_time比arv_tm更容易理解。 -
对于表名,使用复数形式。表格包含行,每一行代表实体的一个实例。因此,表名应该使用复数形式,如
teachers、vehicles或departments。有时我会做例外。例如,为了保留导入的 CSV 文件名,我会将它们作为表名,特别是在它们是一次性导入的情况下。 -
注意长度。不同数据库应用程序允许的标识符名称的最大字符数不同:SQL 标准为 128 个字符,但 PostgreSQL 限制为 63 个字符,而旧版 Oracle 系统的最大值为 30。如果你编写的代码可能在其他数据库系统中重用,建议使用较短的标识符名称。
-
在复制表时,请使用有助于以后管理的名称。一种方法是在创建副本时将
_YYYY_MM_DD日期附加到表名中,例如vehicle_parts_2021_04_08。另一个好处是,表名将按日期排序。
使用约束控制列值
你可以通过使用某些约束进一步控制列接受的数据。列的数据类型广泛定义了它将接受的数据类型,例如整数与字符。额外的约束让我们根据规则和逻辑测试进一步指定可接受的值。通过约束,我们可以避免“垃圾进,垃圾出”的现象,即当低质量数据导致分析结果不准确或不完整时。设计良好的约束有助于维护数据质量,并确保表之间关系的完整性。
在第七章中,你学习了主键和外键,它们是最常用的两种约束。SQL 还具有以下约束类型:
-
CHECK仅允许布尔表达式求值为true的行 -
UNIQUE确保某一列或列组合中的值在每一行中都是唯一的 -
NOT NULL防止列中出现NULL值
我们可以通过两种方式添加约束:作为列约束或表约束。列约束仅适用于该列。在CREATE TABLE语句中,我们用列名和数据类型声明它,每次修改该列时都会进行检查。而表约束则适用于一个或多个列。在CREATE TABLE语句中,我们在定义完所有表列后立即声明它,并且每次修改表中行时都会进行检查。
让我们探索这些约束、它们的语法以及它们在表设计中的实用性。
主键:自然主键与代理主键
如第七章所述,主键是一个列或多个列的集合,其值唯一标识表中的每一行。主键是一种约束,它对组成主键的列或列集合施加两条规则:
-
每行的值必须是唯一的。
-
任何列都不能有缺失值。
在存储在仓库中的产品表中,主键可以是一个包含唯一产品代码的列。在第七章“通过键列关联表格”的简单主键示例中,我们的表格有一个由我们(用户)插入的整数构成的单一 ID 列的主键。通常,数据会暗示最佳路径,并帮助我们决定是否使用自然键或替代键作为主键。
使用现有列作为自然键
自然键使用表格中现有的一列或多列,这些列符合主键的标准:每行唯一且永不为空。列中的值可以更改,只要新值不违反约束。
一个自然键可能是由地方车辆管理部门发放的驾驶证号码。在美国这样的一个政府管辖区内,我们可以合理地预期所有司机会在他们的驾驶证上获得一个唯一的 ID,我们可以将其存储为driver_id。然而,如果我们正在编制一个全国性的驾驶证数据库,我们可能无法做出这样的假设;多个州可能会独立地发放相同的 ID 代码。在这种情况下,driver_id列可能没有唯一值,不能作为自然键使用。作为解决方案,我们可以通过将driver_id与存储州名的列结合来创建一个复合主键,这将为每一行提供唯一的组合。例如,表中的这两行有一个唯一的driver_id和st列组合:
driver_id st first_name last_name
---------- -- ---------- ---------
10302019 NY Patrick Corbin
10302019 FL Howard Kendrick
本章将探讨这两种方法,随着你处理数据时,留意那些适合自然键的值。部件号、序列号或书籍的 ISBN 号都是很好的例子。
引入替代键列
替代键是一个单独的列,你可以用人工生成的值填充它;当一个表没有支持创建自然主键的数据时,我们可能会使用它。替代键可能是数据库自动生成的一个顺序号。我们已经通过序列数据类型和IDENTITY语法(在第四章的“自动递增整数”部分中介绍)做了这种操作。使用自动生成整数作为替代键的表可能如下所示:
id first_name last_name
-- ---------- ---------
1 Patrick Corbin
2 Howard Kendrick
3 David Martinez
一些开发人员喜欢使用全局唯一标识符(UUID),它由 32 个十六进制数字组成,按连字符分组。UUID 通常用于标识计算机硬件或软件,格式如下所示:
2911d8a8-6dea-4a46-af23-d64175a08237
PostgreSQL 提供了 UUID 数据类型以及两个生成 UUID 的模块:uuid-ossp和pgcrypto。PostgreSQL 文档www.postgresql.org/docs/current/datatype-uuid.html是深入了解的一个良好起点。
评估键类型的优缺点
使用任一类型主键都有充分的理由,但两者都有缺点。关于自然键需要考虑的要点包括以下几点:
-
数据已经存在于表中,因此你无需添加列来创建键。
-
由于自然键数据本身具有意义,它可以减少查询时表之间联接的需要。
-
如果你的数据发生变化,违反了键的要求——例如突然出现重复值——你将不得不更改表的设置。
这里是关于替代键需要考虑的几点:
-
因为替代键本身没有任何意义,并且其值独立于表中的数据,因此如果数据稍后发生变化,你不受键结构的限制。
-
键值保证是唯一的。
-
为替代键添加一列需要更多的空间。
在理想情况下,表应该有一个或多个列可以作为自然键,例如在产品表中使用唯一的产品代码。但现实中常常会遇到限制。例如,在员工表中,可能很难找到任何单一列,甚至多个列,能在逐行的基础上保持唯一性,作为主键。在无法重新考虑表结构的情况下,可能需要使用替代键。
创建单列主键
让我们通过几个主键示例来分析。在第七章的《理解 JOIN 类型》中,你在 district_2020 和 district_2035 表上创建了主键来尝试不同的 JOIN 类型。实际上,这些都是替代键:在这两个表中,你创建了名为 id 的列作为键,并使用了关键字 CONSTRAINT key_name PRIMARY KEY 来声明它们为主键。
有两种方法来声明约束:作为列约束或作为表约束。在列表 8-1 中,我们尝试了这两种方法,在类似于前面提到的驾驶执照示例的表上声明主键。由于我们预期驾驶执照 ID 始终唯一,我们将使用该列作为自然键。
CREATE TABLE natural_key_example (
1 license_id text CONSTRAINT license_key PRIMARY KEY,
first_name text,
last_name text
);
2 DROP TABLE natural_key_example;
CREATE TABLE natural_key_example (
license_id text,
first_name text,
last_name text,
3 CONSTRAINT license_key PRIMARY KEY (license_id)
);
列表 8-1:将单列自然键声明为主键
我们首先创建一个名为 natural_key_example 的表,并使用列约束语法 CONSTRAINT 声明 license_id 为主键 1,后跟约束名称和关键字 PRIMARY KEY。这种语法可以让你一目了然地了解哪个列被指定为主键。注意,你也可以省略 CONSTRAINT 关键字和主键名称,只使用 PRIMARY KEY:
license_id text PRIMARY KEY
在这种情况下,PostgreSQL 将自行为主键命名,采用表名后跟 _pkey 的命名约定。
接下来,我们使用 DROP TABLE 2 从数据库中删除表,以准备表约束示例。
要添加表约束,我们在列出所有列 3 后声明 CONSTRAINT,并在括号中列出我们想要用作主键的列。(同样,你可以省略 CONSTRAINT 关键字和主键名称。)在这个例子中,我们最终还是将 license_id 列作为主键。当你希望使用多列创建主键时,必须使用表约束语法;在这种情况下,你需要在括号中列出列,并用逗号分隔。我们稍后会详细探讨这个问题。
首先,让我们看看主键的特性——每一行唯一且没有 NULL 值——是如何保护数据完整性的。列表 8-2 中有两个 INSERT 语句。
INSERT INTO natural_key_example (license_id, first_name, last_name)
VALUES ('T229901', 'Gem', 'Godfrey');
INSERT INTO natural_key_example (license_id, first_name, last_name)
VALUES ('T229901', 'John', 'Mitchell');
列表 8-2:主键冲突示例
当你单独执行第一个 INSERT 语句时,服务器会顺利地将一行数据加载到 natural_key_example 表中。你尝试执行第二个时,服务器会返回错误:
ERROR: duplicate key value violates unique constraint "license_key"
DETAIL: Key (license_id)=(T229901) already exists.
在添加行之前,服务器检查表中是否已经存在 T229901 的 license_id。由于它已经存在,并且根据主键的定义,主键必须对每一行唯一,因此服务器拒绝了该操作。虚构的 DMV 规则规定,两个驾驶员不能拥有相同的驾照 ID,因此检查并拒绝重复数据是数据库执行该规则的一种方式。
创建复合主键
如果单列不符合主键的要求,我们可以创建一个 复合主键。
我们将创建一个跟踪学生上学出勤情况的表。student_id 和 school_day 列的组合为每一行提供了一个唯一的值,记录了学生在某一天是否到校,这些信息存储在一个名为 present 的列中。要创建复合主键,你必须使用表约束语法,如列表 8-3 所示。
CREATE TABLE natural_key_composite_example (
student_id text,
school_day date,
present boolean,
CONSTRAINT student_key PRIMARY KEY (student_id, school_day)
);
列表 8-3:将复合主键声明为自然主键
在这里,我们传递两个(或更多)列作为参数,而不是一个。我们将通过尝试插入一行数据来模拟主键冲突,其中两个主键列 student_id 和 school_day 的值在表中并不唯一。逐个运行列表 8-4 中的 INSERT 语句(在 pgAdmin 中高亮显示它们后点击 Execute/Refresh)。
INSERT INTO natural_key_composite_example (student_id, school_day, present)
VALUES(775, '2022-01-22', 'Y');
INSERT INTO natural_key_composite_example (student_id, school_day, present)
VALUES(775, '2022-01-23', 'Y');
INSERT INTO natural_key_composite_example (student_id, school_day, present)
VALUES(775, '2022-01-23', 'N');
列表 8-4:复合主键冲突示例
前两个 INSERT 语句执行正常,因为在主键列的组合中没有值重复。但第三个语句会导致错误,因为它包含的 student_id 和 school_day 值与表中已存在的组合匹配:
ERROR: duplicate key value violates unique constraint "student_key"
DETAIL: Key (student_id, school_day)=(775, 2022-01-23) already exists.
你可以创建包含超过两列的复合主键。你可以使用的列数的限制取决于你的数据库。
创建自增替代主键
正如你在第四章《自增整数》中学到的那样,PostgreSQL 数据库有两种方法可以向列添加自动增长的唯一值。第一种方法是将列设置为 PostgreSQL 特定的序列数据类型之一:smallserial、serial 和 bigserial。第二种方法是使用 IDENTITY 语法;由于它是 ANSI SQL 标准的一部分,我们将在示例中使用这种方法。
使用 IDENTITY 和 smallint、integer、bigint 等整数类型中的一种。对于主键来说,可能会诱使你通过使用 integer 来节省磁盘空间,它可以处理最大为 2,147,483,647 的数字。但是,许多数据库开发人员曾在深夜接到用户的紧急电话,询问为何应用程序崩溃,结果发现数据库尝试生成比数据类型的最大值更大的数字。因此,如果你的表有可能增长超过 21.47 亿行,明智的做法是使用 bigint,它可以接受高达 9.2 * quintillion 的数字。你可以设置并忘记它,就像在 清单 8-5 中定义的第一列那样。
CREATE TABLE surrogate_key_example (
1 order_number bigint GENERATED ALWAYS AS IDENTITY,
product_name text,
order_time timestamp with time zone,
2 CONSTRAINT order_number_key PRIMARY KEY (order_number)
);
3 INSERT INTO surrogate_key_example (product_name, order_time)
VALUES ('Beachball Polish', '2020-03-15 09:21-07'),
('Wrinkle De-Atomizer', '2017-05-22 14:00-07'),
('Flux Capacitor', '1985-10-26 01:18:00-07');
SELECT * FROM surrogate_key_example;
清单 8-5:使用 IDENTITY 声明 bigint 列作为替代键
清单 8-5 显示了如何使用 IDENTITY 语法声明一个自增的 bigint 列,名为 order_number,并将该列设置为主键 2。当你向表 3 中插入数据时,可以从列和值列表中省略 order_number。数据库将在每插入一行时为该列创建一个新值,该值将比已创建的最大值大 1。
运行 SELECT * FROM surrogate_key_example; 来查看该列是如何自动填充的:
order_number product_name order_time
------------ ------------------- ----------------------
1 Beachball Polish 2020-03-15 09:21:00-07
2 Wrinkle De-Atomizer 2017-05-22 14:00:00-07
3 Flux Capacitor 1985-10-26 01:18:00-07
我们在日常购物的收据中看到这些自增的订单号。现在你知道是如何做到的了。
有几个值得注意的细节:如果你删除一行,数据库不会填补 order_number 序列中的空缺,也不会更改该列中的任何现有值。通常,它会将序列中最大的现有值加 1(尽管在某些操作中会有例外情况,包括从备份恢复数据库)。另外,我们使用了语法 GENERATED ALWAYS AS IDENTITY。正如第四章中讨论的那样,这可以防止用户在不手动覆盖设置的情况下向 order_number 插入值。通常,你希望防止这种干预,以避免出现问题。假设用户手动向现有的 surrogate_key_example 表的 order_number 列插入值 4。这个手动插入不会递增 order_number 列的 IDENTITY 序列;只有当数据库生成新值时,才会发生递增。因此,在下一行插入时,数据库也会尝试插入 4,因为它是序列中的下一个数字。结果将会是一个错误,因为重复值违反了主键约束。
然而,你可以通过重新启动IDENTITY序列来允许手动插入。你可能允许这样做,以防需要插入一个误删的行。示例 8-6 显示了如何向表中添加一个order_number为4的行,这个值是序列中的下一个值。
INSERT INTO surrogate_key_example
1 OVERRIDING SYSTEM VALUE
VALUES (4, 'Chicken Coop', '2021-09-03 10:33-07');
2 ALTER TABLE surrogate_key_example ALTER COLUMN order_number RESTART WITH 5;
3 INSERT INTO surrogate_key_example (product_name, order_time)
VALUES ('Aloe Plant', '2020-03-15 10:09-07');
示例 8-6:重新启动IDENTITY序列
你从一个包含关键字OVERRIDING SYSTEM VALUE 1 的INSERT语句开始。接下来,我们包括VALUES子句,并为order_number列在VALUES列表中指定整数4,这将覆盖IDENTITY限制。我们使用4,但我们也可以选择任何一个未在该列中存在的数字。
插入后,你需要重置IDENTITY序列,以便它从比你刚插入的4更大的数字开始。为此,使用ALTER TABLE ... ALTER COLUMN语句,其中包括关键字RESTART WITH 5。ALTER TABLE用于以各种方式修改表和列,更多内容将在第十章《检查和修改数据》中深入探讨。在这里,你用它来改变IDENTITY序列的起始数字;这样,下一个插入表中的行,order_number的值将是5。最后,插入一个新行并省略order_number的值,正如在示例 8-5 中所做的那样。
如果你再次从surrogate_key_example表中选择所有行,你会看到order_number列已经按预期填充:
order_number product_name order_time
------------ ------------------- ----------------------
1 Beachball Polish 2020-03-15 09:21:00-07
2 Wrinkle De-Atomizer 2017-05-22 14:00:00-07
3 Flux Capacitor 1985-10-26 01:18:00-07
4 Chicken Coop 2021-09-03 10:33:00-07
5 Aloe Plant 2020-03-15 10:09:00-07
这个任务不一定是你需要经常处理的,但如果需要的话,知道怎么做是有帮助的。
外键
我们使用外键来建立表之间的关系。外键是一个或多个列,其值与另一个表的主键或其他唯一键中的值匹配。外键的值必须已经存在于它所引用的表的主键或其他唯一键中。如果不存在,则该值会被拒绝。通过这一约束,SQL 强制执行参照完整性——确保相关表中的数据不会变得无关或成为孤立数据。我们不会在一个表中得到与其他可以连接的表中的行没有关系的行。
示例 8-7 显示了一个假设数据库中的两个表,用于追踪机动车活动。
CREATE TABLE licenses (
license_id text,
first_name text,
last_name text,
1 CONSTRAINT licenses_key PRIMARY KEY (license_id)
);
CREATE TABLE registrations (
registration_id text,
registration_date timestamp with time zone,
2 license_id text REFERENCES licenses (license_id),
CONSTRAINT registration_key PRIMARY KEY (registration_id, license_id)
);
3 INSERT INTO licenses (license_id, first_name, last_name)
VALUES ('T229901', 'Steve', 'Rothery');
4 INSERT INTO registrations (registration_id, registration_date, license_id)
VALUES ('A203391', '2022-03-17', 'T229901');
5 INSERT INTO registrations (registration_id, registration_date, license_id)
VALUES ('A75772', '2022-03-17', 'T000001');
示例 8-7:外键示例
第一个表licenses使用驾驶员唯一的license_id 1 作为自然主键。第二个表registrations用于追踪车辆注册。一个许可证 ID 可能会与多个车辆注册相关联,因为每个持证驾驶员可以注册多辆车——这被称为一对多关系(第七章)。
通过 SQL,关系是这样表达的:在registrations表中,我们通过添加REFERENCES关键字,将license_id列指定为外键,后面跟上它引用的表名和列名。
现在,当我们向registrations插入一行时,数据库会检查插入到license_id中的值是否已经存在于licenses表的license_id主键列中。如果不存在,数据库会返回一个错误,这是非常重要的。如果registrations中的任何行与licenses中的行不对应,我们将无法编写查询来找到注册了该车辆的人。
为了查看此约束的实际效果,创建这两个表并逐一执行INSERT语句。第一个语句向licenses 3 中添加一行,其中license_id的值为T229901。第二个语句向registrations 4 中添加一行,其中外键包含相同的值。到目前为止,一切正常,因为该值在两个表中都存在。但是在第三次插入时,我们遇到错误,该插入尝试将一行插入到registrations 5 中,且其license_id值在licenses中不存在:
ERROR: insert or update on table "registrations" violates foreign key constraint "registrations_license_id_fkey"
DETAIL: Key (license_id)=(T000001) is not present in table "licenses".
产生的错误实际上是有帮助的:数据库通过阻止对不存在的许可证持有者进行注册来强制执行引用完整性。但它也表明了一些实际影响。首先,它影响我们插入数据的顺序。在另一个表中包含外键的表在没有相关记录之前不能添加数据,否则我们会遇到错误。在这个例子中,我们必须先创建一个驾驶执照记录,然后再插入相关的注册记录(如果你想一想,这就是你当地的机动车管理部门可能会做的事情)。
其次,当我们删除数据时,情况恰恰相反。为了保持引用完整性,外键约束阻止我们在删除registrations中的任何相关行之前,删除licenses中的一行,因为这样做会留下一个孤立的记录。我们必须先删除registrations中的相关行,然后再删除licenses中的记录。然而,ANSI SQL 提供了一种方法,通过使用ON DELETE CASCADE关键字自动处理这种操作顺序。
如何使用 CASCADE 自动删除相关记录
为了在删除licenses中的一行时自动删除registrations中的相关行,我们可以通过在定义外键约束时添加ON DELETE CASCADE来指定这种行为。
这是我们如何修改列表 8-7 中CREATE TABLE语句以创建registrations表,在license_id列的定义末尾添加关键字的方式:
CREATE TABLE registrations (
registration_id text,
registration_date date,
license_id text REFERENCES licenses (license_id) ON DELETE CASCADE,
CONSTRAINT registration_key PRIMARY KEY (registration_id, license_id)
);
删除licenses中的一行应该也会删除registrations中所有相关的行。这使我们能够删除驾驶执照,而不必先手动删除任何与其关联的注册记录。它还通过确保删除一个执照不会在registrations中留下孤立的行来维护数据完整性。
CHECK约束
CHECK约束评估添加到列中的数据是否符合预期标准,我们通过逻辑测试来指定这些标准。如果标准不符合,数据库将返回错误。CHECK约束非常有价值,因为它可以防止列中加载无意义的数据。例如,棒球运动员的总击球数不应为负数,因此应该限制该数据为零或更大的值。或者,在大多数学校中,Z不是有效的课程成绩(尽管我那时勉强及格的代数成绩感觉像是 Z),因此我们可以插入只接受 A–F 值的约束。
与主键一样,我们可以在列级别或表级别实现CHECK约束。对于列约束,在CREATE TABLE语句中声明它,放在列名和数据类型之后:CHECK (``logical expression``)。作为表约束,使用语法CONSTRAINT constraint_name CHECK (``logical expression``),在所有列定义之后。
Listing 8-8 展示了一个CHECK约束,应用于一个表中的两个列,我们可能会用这个表来追踪员工在组织中的角色和薪资。它使用了主键和CHECK约束的表约束语法。
CREATE TABLE check_constraint_example (
user_id bigint GENERATED ALWAYS AS IDENTITY,
user_role text,
salary numeric(10,2),
CONSTRAINT user_id_key PRIMARY KEY (user_id),
1 CONSTRAINT check_role_in_list CHECK (user_role IN('Admin', 'Staff')),
2 CONSTRAINT check_salary_not_below_zero CHECK (salary >= 0)
);
Listing 8-8: CHECK约束示例
我们创建表并将user_id列设置为自动递增的替代主键。第一个CHECK 1 测试输入到user_role列的值是否符合预定义的两个字符串之一,Admin或Staff,通过使用 SQL 中的IN运算符。第二个CHECK 2 测试输入到salary列的值是否大于或等于 0,因为负数金额是没有意义的。两个测试都是布尔表达式的例子,这是一种评估为真或假的语句。如果约束测试的值为true,则检查通过。
当插入或更新值时,数据库会将其与约束进行检查。如果任一列中的值违反了约束,或者即使违反了主键约束,数据库也会拒绝该更改。
如果我们使用表约束语法,我们还可以在一个CHECK语句中组合多个测试。例如,我们有一个与学生成绩相关的表。我们可以添加如下内容:
CONSTRAINT grad_check CHECK (credits >= 120 AND tuition = 'Paid')
注意,我们通过将两个逻辑测试括在括号中并用AND连接它们来组合这两个逻辑测试。在这里,两个布尔表达式必须都评估为true,整个检查才会通过。你也可以跨列进行值的测试,如下面的例子所示,我们希望确保商品的销售价格是原价的折扣,假设我们有两个列来存储这两个值:
CONSTRAINT sale_check CHECK (sale_price < retail_price)
在括号内,逻辑表达式检查销售价格是否小于零售价格。
UNIQUE 约束
我们还可以通过使用 UNIQUE 约束来确保每一行中的列具有唯一的值。如果确保唯一值听起来与主键的目的相似,确实如此。但是,UNIQUE 有一个重要的区别:在主键中,值不能为 NULL,但 UNIQUE 约束允许列中存在多个 NULL 值。这在某些情况下非常有用,例如当我们并不总是拥有值,但希望确保现有值是唯一的。
为了展示 UNIQUE 的实用性,看看 列表 8-9 中的代码,这是一张用于跟踪联系人信息的表格。
CREATE TABLE unique_constraint_example (
contact_id bigint GENERATED ALWAYS AS IDENTITY,
first_name text,
last_name text,
email text,
CONSTRAINT contact_id_key PRIMARY KEY (contact_id),
1 CONSTRAINT email_unique UNIQUE (email)
);
INSERT INTO unique_constraint_example (first_name, last_name, email)
VALUES ('Samantha', 'Lee', 'slee@example.org');
INSERT INTO unique_constraint_example (first_name, last_name, email)
VALUES ('Betty', 'Diaz', 'bdiaz@example.org');
INSERT INTO unique_constraint_example (first_name, last_name, email)
2 VALUES ('Sasha', 'Lee', 'slee@example.org');
列表 8-9:UNIQUE 约束示例
在这个表中,contact_id 作为替代主键,唯一地标识每一行数据。但我们还有一个 email 列,这是与每个人的主要联系方式。我们希望这个列只包含唯一的电子邮件地址,但这些地址可能随着时间变化。所以,我们使用 UNIQUE 1 来确保每次添加或更新联系人的电子邮件时,不会重复已经存在的地址。如果我们尝试插入一个已存在的电子邮件 2,数据库将返回错误:
ERROR: duplicate key value violates unique constraint "email_unique"
DETAIL: Key (email)=(slee@example.org) already exists.
再次说明,错误信息表明数据库正在为我们工作。
NOT NULL 约束
在第七章中,你学习了 NULL,它是一个特殊的 SQL 值,表示缺失数据或未知值。我们知道,主键的值不能为 NULL,因为主键需要唯一地标识表中的每一行。但在某些情况下,你可能希望在列中禁止空值。例如,在列出学校中每个学生的表格中,要求每一行的名字和姓氏列都必须填写是合理的。为了要求列中必须有值,SQL 提供了 NOT NULL 约束,它简单地禁止列接受空值。
列表 8-10 展示了 NOT NULL 语法。
CREATE TABLE not_null_example (
student_id bigint GENERATED ALWAYS AS IDENTITY,
first_name text NOT NULL,
last_name text NOT NULL,
CONSTRAINT student_id_key PRIMARY KEY (student_id)
);
列表 8-10:NOT NULL 约束示例
在这里,我们为 first_name 和 last_name 列声明了 NOT NULL,因为在跟踪学生信息的表格中,这些信息可能是必需的。如果我们尝试在表中执行 INSERT 并且没有为这些列提供值,数据库会通知我们违反了约束。
如何删除约束或稍后添加它们
你可以使用 ALTER TABLE 删除或稍后向现有表添加约束,就像你在本章“创建自增替代主键”中使用的命令来重置 IDENTITY 序列一样。
要删除主键、外键或 UNIQUE 约束,你需要编写以下格式的 ALTER TABLE 语句:
ALTER TABLE `table_name` DROP CONSTRAINT `constraint_name`;
要删除 NOT NULL 约束,语句作用于列,因此你必须使用额外的 ALTER COLUMN 关键字,如下所示:
ALTER TABLE `table_name` ALTER COLUMN `column_name` DROP NOT NULL;
让我们使用这些语句修改你刚刚创建的 not_null_example 表,如 列表 8-11 所示。
ALTER TABLE not_null_example DROP CONSTRAINT student_id_key;
ALTER TABLE not_null_example ADD CONSTRAINT student_id_key PRIMARY KEY (student_id);
ALTER TABLE not_null_example ALTER COLUMN first_name DROP NOT NULL;
ALTER TABLE not_null_example ALTER COLUMN first_name SET NOT NULL;
列表 8-11:删除和添加主键以及 NOT NULL 约束
一次执行一个语句。每次执行后,你可以通过在 pgAdmin 中单击表名,然后点击查询窗口上方的SQL标签,查看表定义的更改。(请注意,它将显示比你创建表时所使用的语法更详细的表定义语法。)
在第一个ALTER TABLE语句中,我们使用DROP CONSTRAINT移除名为student_id_key的主键。然后,我们使用ADD CONSTRAINT将主键重新添加。我们可以使用相同的语法向任何现有表添加约束。
在第三条语句中,ALTER COLUMN和DROP NOT NULL移除了first_name列的NOT NULL约束。最后,SET NOT NULL添加了该约束。
使用索引加速查询
就像一本书的索引帮助你更快找到信息一样,你也可以通过向表中的一列或多列添加索引—一种由数据库管理的独立数据结构—来加速查询。数据库使用索引作为快捷方式,而不是扫描每一行来查找数据。坦率地说,这只是 SQL 数据库中非平凡话题的一个简化版本。我们可以用好几章来深入探讨 SQL 索引的工作原理以及如何调优数据库性能,但在这里,我会提供关于使用索引的一般指导,并通过一个 PostgreSQL 特定的示例来展示它们的好处。
B-树:PostgreSQL 的默认索引
你已经创建了几个索引,也许你并没有意识到。每次你添加主键或UNIQUE约束时,PostgreSQL(以及大多数数据库系统)会在包含该约束的列上创建一个索引。索引与表数据分开存储,并在你运行查询时自动访问(如果需要),并在每次添加、删除或更新行时更新。
在 PostgreSQL 中,默认的索引类型是B 树索引。它会在指定为主键或UNIQUE约束的列上自动创建,并且它也是使用CREATE INDEX语句时默认创建的索引类型。B 树(balanced tree的缩写)得名于其结构,因为在查找一个值时,它从树的顶部开始,通过分支向下搜索,直到找到该值。(当然,过程远比这更复杂。)B 树索引适用于可以排序并使用相等和范围运算符(如<、<=、=、>=、>和BETWEEN)进行搜索的数据。如果搜索字符串的开头没有通配符,它也适用于LIKE。例如:WHERE chips LIKE 'Dorito%'。
PostgreSQL 还支持其他索引类型,例如广义倒排索引(GIN)和广义搜索树(GiST)。每种索引有不同的用途,我将在后续章节中介绍它们,尤其是在全文搜索和使用几何类型进行查询时。
现在,让我们看看 B-tree 索引如何加速一个简单的搜索查询。在这个练习中,我们将使用一个包含超过 900,000 个纽约市街道地址的大型数据集,这些数据由 OpenAddresses 项目提供,网址为 openaddresses.io/。包含数据的文件 city_of_new_york.csv 可以从 nostarch.com/practical-sql-2nd-edition/ 下载,和本书的所有资源一起提供。
下载文件后,使用 列表 8-12 中的代码创建 new_york_addresses 表并导入地址数据。由于 CSV 文件约为 50MB,导入过程将比您之前加载的小型数据集要慢。
CREATE TABLE new_york_addresses (
longitude numeric(9,6),
latitude numeric(9,6),
street_number text,
street text,
unit text,
postcode text,
id integer CONSTRAINT new_york_key PRIMARY KEY
);
COPY new_york_addresses
FROM '*C:\YourDirectory\*city_of_new_york.csv'
WITH (FORMAT CSV, HEADER);
列表 8-12:导入纽约市地址数据
当数据加载完成后,运行一个快速的 SELECT 查询,目视检查是否有 940,374 行和七列。此数据的常见用途可能是搜索 street 列中的匹配项,因此我们将以此为例来探索索引性能。
使用 EXPLAIN 基准测试查询性能
我们将通过使用 PostgreSQL 特定的 EXPLAIN 命令来测量添加索引前后的性能,该命令列出特定数据库查询的 查询计划。查询计划可能包括数据库计划如何扫描表,是否使用索引等信息。当我们添加 ANALYZE 关键字时,EXPLAIN 将执行查询并显示实际执行时间。
记录一些控制执行时间
我们将使用 列表 8-13 中的三个查询来分析添加索引前后的查询性能。我们使用典型的 SELECT 查询,并在开始时包含带有 WHERE 子句的 EXPLAIN ANALYZE。这些关键字告诉数据库执行查询并显示查询过程的统计信息以及执行所花费的时间,而不是显示结果。
EXPLAIN ANALYZE SELECT * FROM new_york_addresses
WHERE street = 'BROADWAY';
EXPLAIN ANALYZE SELECT * FROM new_york_addresses
WHERE street = '52 STREET';
EXPLAIN ANALYZE SELECT * FROM new_york_addresses
WHERE street = 'ZWICKY AVENUE';
列表 8-13:索引性能基准查询
在我的系统上,第一个查询返回了以下统计信息,显示在 pgAdmin 输出窗格中:
Gather (cost=1000.00..15184.08 rows=3103 width=46) (actual time=9.000..388.448 rows=3336 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Parallel Seq Scan on new_york_addresses (cost=0.00..13873.78 1
rows=1293 width=46) (actual time=2.362..367.258 rows=1112 loops=3)
Filter: (street = 'BROADWAY'::text)
Rows Removed by Filter: 312346
Planning Time: 0.401 ms
Execution Time: 389.232 ms 2
并非所有输出都与此相关,因此我不会解码全部内容,但有两行是相关的。第一行指出,为了找到 street = 'BROADWAY' 的所有行,数据库将对表进行顺序扫描 1。这是全表扫描的同义词:数据库将检查每一行并删除所有 street 不匹配 BROADWAY 的行。执行时间(在我的计算机上大约是 389 毫秒) 2 表示查询运行所需的时间。您的时间将取决于多个因素,包括您的计算机硬件。
对于测试,运行 列表 8-13 中的每个查询多次,并记录每个查询的最快执行时间。您会注意到,相同查询的执行时间在每次运行时会略有不同。这可能是由多个因素造成的,从服务器上其他进程的运行到查询在先前运行后数据被保存在内存中的效果。
添加索引
现在,让我们看看添加索引如何改变查询的搜索方法和执行时间。列表 8-14 显示了使用 PostgreSQL 创建索引的 SQL 语句。
CREATE INDEX street_idx ON new_york_addresses (street);
列表 8-14:在 new_york_addresses 表上创建 B-tree 索引
请注意,这与创建约束的命令类似。我们给出 CREATE INDEX 关键字,后面跟上我们为索引选择的名称,在这个例子中是 street_idx。然后加上 ON,后面是目标表和列。
执行 CREATE INDEX 语句,PostgreSQL 会扫描 street 列中的值,并从中构建索引。我们只需要创建一次索引。当任务完成后,重新运行 列表 8-13 中的每个查询,并记录 EXPLAIN ANALYZE 提供的执行时间。以下是一个示例:
Bitmap Heap Scan on new_york_addresses (cost=76.47..6389.39 rows=3103 width=46) (actual time=1.355..4.802 rows=3336 loops=1)
Recheck Cond: (street = 'BROADWAY'::text)
Heap Blocks: exact=2157
-> Bitmap Index Scan on street_idx (cost=0.00..75.70 rows=3103 width=0) 1
(actual time=0.950..0.950 rows=3336 loops=1)
Index Cond: (street = 'BROADWAY'::text)
Planning Time: 0.109 ms
Execution Time: 5.113 ms 2
你注意到有什么变化吗?首先,我们看到数据库现在使用的是 street_idx 上的索引扫描,而不是逐行扫描。此外,查询速度明显更快了。2 表 8-1 显示了我在添加索引前后计算机上最快的执行时间(已四舍五入)。
表 8-1:衡量索引性能
| 查询筛选器 | 索引前 | 索引后 |
|---|---|---|
WHERE street = 'BROADWAY' |
92 毫秒 | 5 毫秒 |
WHERE street = '52 STREET' |
94 毫秒 | 1 毫秒 |
WHERE street = 'ZWICKY AVENUE' |
93 毫秒 | <1 毫秒 |
执行时间大大缩短,每个查询节省了近十分之一秒甚至更多。十分之一秒真的那么令人印象深刻吗?无论你是通过重复查询在数据中寻找答案,还是为成千上万的用户创建一个数据库系统,这些时间节省加起来都是可观的。
如果你需要从表中移除索引——也许是为了测试几种索引类型的性能——可以使用 DROP INDEX 命令,后面跟上要移除的索引名称。
使用索引时的考虑事项
你已经看到索引具有显著的性能优势,那么这是否意味着你应该为每个表中的列都添加索引呢?别急!索引是有价值的,但并不总是需要的。此外,索引会增大数据库,并对写入数据造成维护成本。以下是一些判断何时使用索引的建议:
-
查阅你所使用的数据库系统的文档,了解可用的索引类型以及在特定数据类型上使用哪些索引。例如,PostgreSQL 除了 B-tree 之外,还拥有五种索引类型。一个叫做 GiST 的索引类型特别适合本书后面讨论的几何数据类型。全文本搜索(你将在第十四章学习)也能从索引中受益。
-
考虑为你将在表连接中使用的列添加索引。在 PostgreSQL 中,主键默认会被索引,但相关表中的外键列则没有,这些列是添加索引的好目标。
-
外键上的索引有助于避免在级联删除时进行昂贵的顺序扫描。
-
为经常出现在查询
WHERE子句中的列添加索引。如你所见,通过索引,搜索性能得到了显著提升。 -
使用
EXPLAIN ANALYZE测试在不同配置下的性能。优化是一个过程!如果数据库没有使用某个索引,并且该索引不是主键或其他约束的支持索引,那么你可以删除它,以减少数据库的大小,并加速插入、更新和删除操作。
总结
通过本章中添加到你工具箱的工具,你已经准备好确保你构建或继承的数据库最适合你的数据收集和探索工作。至关重要的是要定义与数据和用户预期相匹配的约束,通过不允许不合理的值,确保填充所有值,以及建立表之间的正确关系。你还学会了如何让查询更快运行,并如何一致地组织你的数据库对象。这对你和其他共享你数据的人都是一种帮助。
本章结束了本书的第一部分,重点是为你提供深入 SQL 数据库所需的基本知识。接下来,我们将继续在这些基础上构建,探索更复杂的查询和数据分析策略。在下一章中,我们将使用 SQL 聚合函数评估数据集的质量,并从中获取可用信息。
第九章:通过分组和汇总提取信息

每个数据集都在讲述一个故事,而数据分析师的工作就是找到这个故事。在第三章中,你学习了如何通过使用 SELECT 语句对列进行排序、查找不同的值以及筛选结果来“采访”数据。你还学到了 SQL 数学、数据类型、表格设计和连接表格的基础知识。有了这些工具,你已经准备好通过使用 分组 和 聚合函数 来总结数据,获取更多的见解。
通过汇总数据,我们可以识别出仅通过扫描表格的行无法看到的有用信息。在本章中,我们将使用你当地图书馆这一大家熟悉的机构作为示例。
图书馆仍然是全球社区的重要组成部分,但互联网和图书馆技术的进步已经改变了我们使用图书馆的方式。例如,电子书和在线访问数字资料现在与书籍和期刊一起,在图书馆中占据了永久位置。
在美国,博物馆与图书馆服务研究所(IMLS)将图书馆活动作为其年度公共图书馆调查的一部分进行测量。该调查收集来自大约 9,000 个图书馆行政机构的数据,调查定义这些机构为提供图书馆服务给特定地区的单位。一些机构是县级图书馆系统,其他一些则是学校区的一部分。每个机构的数据包括分馆数量、员工数、书籍数量、每年开放小时数等。自 1988 年以来,IMLS 每年都会收集数据,并涵盖所有 50 个州的公共图书馆机构,包括哥伦比亚特区和美国领土,如美属萨摩亚。(阅读更多关于该项目的信息:www.imls.gov/research-evaluation/data-collection/public-libraries-survey/)
对于本次练习,我们将假设自己是一个分析师,刚刚收到了一份新的图书馆数据集,目的是根据数据生成描述趋势的报告。我们将创建三个表格,分别保存 2018 年、2017 年和 2016 年调查的数据。(通常,评估多个年份的数据有助于发现趋势。)接着,我们会对每个表格中更有趣的数据进行汇总,并将这些表格连接起来,看看各项指标如何随时间变化。
创建图书馆调查表
让我们创建三个图书馆调查表并导入数据。我们将为每个列使用适当的数据类型和约束,并在适当的位置添加索引。代码和三个 CSV 文件可以在本书的资源中找到。
创建 2018 年图书馆数据表
我们将从创建 2018 年图书馆数据的表开始。使用CREATE TABLE语句,列表 9-1 构建了pls_fy2018_libraries,这是来自公共图书馆调查的 2018 财年公共图书馆系统数据文件。公共图书馆系统数据文件总结了在机构级别的数据,统计所有机构网点的活动,包括中央图书馆、分馆和流动图书馆。年度调查生成两个额外的文件我们不会使用:一个总结州级数据,另一个包含各个网点的数据。对于本练习来说,这些文件是冗余的,但你可以在www.imls.gov/sites/default/files/2018_pls_data_file_documentation.pdf阅读它们包含的数据。
为了方便,我为表格创建了命名方案:pls代表调查标题,fy2018是数据覆盖的财年,libraries是调查中某个特定文件的名称。为了简便起见,我从原始调查文件中的 166 列中选择了 47 个更相关的列填充pls_fy2018_libraries表,排除了诸如解释个别响应来源的代码等数据。当某个图书馆没有提供数据时,机构使用其他方法推导数据,但我们在本次练习中不需要这些信息。
列表 9-1 为了方便进行了缩写,代码中标注了--snip--,但完整版本已经包含在本书的资源中。
CREATE TABLE pls_fy2018_libraries (
stabr text NOT NULL,
1 fscskey text CONSTRAINT fscskey_2018_pkey PRIMARY KEY,
libid text NOT NULL,
libname text NOT NULL,
address text NOT NULL,
city text NOT NULL,
zip text NOT NULL,
`--snip--`
longitude numeric(10,7) NOT NULL,
latitude numeric(10,7) NOT NULL
);
2 COPY pls_fy2018_libraries
FROM '`C:\YourDirectory\`pls_fy2018_libraries.csv'
WITH (FORMAT CSV, HEADER);
3 CREATE INDEX libname_2018_idx ON pls_fy2018_libraries (libname);
列表 9-1:创建并填充 2018 年公共图书馆调查表
找到列表 9-1 的代码和数据文件后,连接到你的analysis数据库,在 pgAdmin 中运行它。确保记得将C:\YourDirectory\改为你保存pls_fy2018_libraries.csv文件的路径。
首先,代码通过CREATE TABLE语句创建表。我们将一个名为fscskey的列赋予主键约束 1,这是数据字典中为每个图书馆分配的唯一代码。因为它是唯一的、出现在每一行,并且不太可能改变,所以可以作为自然主键。
每个列的定义包括适当的数据类型和NOT NULL约束条件,其中列没有缺失值。startdate和enddate列包含日期,但我们在代码中将其数据类型设置为text;在 CSV 文件中,这些列包含非日期值,如果我们尝试使用date数据类型导入,导入将失败。在第十章,你将学习如何清理像这样的情况。目前,这些列的设置已经可以使用。
创建表后,COPY语句 2 从名为pls_fy2018_libraries.csv的 CSV 文件中导入数据,使用你提供的文件路径。我们为libname列添加了索引 3,以便在搜索特定图书馆时提供更快的结果。
创建 2017 年和 2016 年图书馆数据表
创建 2017 年和 2016 年图书馆调查的表格遵循类似的步骤。我将创建和填充这两个表格的代码合并在了清单 9-2 中。再次提醒,所示的清单是截断的,但完整的代码可以在本书的资源页面找到:nostarch.com/practical-sql-2nd-edition/。
更新COPY语句中的文件路径以导入数据,并执行代码。
CREATE TABLE pls_fy2017_libraries (
stabr text NOT NULL,
1 fscskey text CONSTRAINT fscskey_17_pkey PRIMARY KEY,
libid text NOT NULL,
libname text NOT NULL,
address text NOT NULL,
city text NOT NULL,
zip text NOT NULL,
`--snip--`
longitude numeric(10,7) NOT NULL,
latitude numeric(10,7) NOT NULL
);
CREATE TABLE pls_fy2016_libraries (
stabr text NOT NULL,
fscskey text CONSTRAINT fscskey_16_pkey PRIMARY KEY,
libid text NOT NULL,
libname text NOT NULL,
address text NOT NULL,
city text NOT NULL,
zip text NOT NULL,
`--snip--`
longitude numeric(10,7) NOT NULL,
latitude numeric(10,7) NOT NULL
);
2 COPY pls_fy2017_libraries
FROM '*C:\YourDirectory\*pls_fy2017_libraries.csv'
WITH (FORMAT CSV, HEADER);
COPY pls_fy2016_libraries
FROM '*C:\YourDirectory\*pls_fy2016_libraries.csv'
WITH (FORMAT CSV, HEADER);
3 CREATE INDEX libname_2017_idx ON pls_fy2017_libraries (libname);
CREATE INDEX libname_2016_idx ON pls_fy2016_libraries (libname);
清单 9-2:创建并填充 2017 年和 2016 年公共图书馆调查表格
我们首先创建两个表,在这两个表中,我们再次使用fscskey 1 作为主键。接下来,我们运行COPY命令导入 CSV 文件到表中,最后,我们在两个表的libname列上创建索引。
在查看代码时,你会注意到这三个表格具有相同的结构。大多数正在进行的调查每年都会有一些变化,因为调查的制定者要么提出新的问题,要么修改现有问题,但我为这三个表格选择的列是一致的。调查年份的文档可以在www.imls.gov/research-evaluation/data-collection/public-libraries-survey/找到。现在,让我们挖掘这些数据,发现它们的故事。
使用聚合函数探索图书馆数据
聚合函数将来自多行的值组合在一起,对这些值执行操作,并返回单一的结果。例如,你可能会使用avg()聚合函数返回值的平均值,正如你在第六章中学到的那样。一些聚合函数是 SQL 标准的一部分,而其他一些则特定于 PostgreSQL 和其他数据库管理系统。本章中使用的大多数聚合函数都是 SQL 标准的一部分(PostgreSQL 聚合函数的完整列表请参见:www.postgresql.org/docs/current/functions-aggregate.html)。
在本节中,我们将通过对单列和多列使用聚合函数来处理图书馆数据,然后探索如何通过将返回结果与其他列的值分组,来扩展这些聚合函数的应用。
使用 count()函数计数行数和数值
在导入数据集后,合理的第一步是确保表格中有预期的行数。IMLS 文档中说明我们导入的 2018 年数据文件有 9,261 行;2017 年有 9,245 行;2016 年有 9,252 行。这个差异很可能反映了图书馆的开设、关闭或合并。当我们计算这些表格中的行数时,结果应该与这些数字匹配。
count()聚合函数是 ANSI SQL 标准的一部分,它使得统计行数和执行其他计数任务变得非常简单。如果我们提供一个星号作为输入,例如count(*),那么星号将充当通配符,因此该函数返回表格行数,无论这些行是否包含NULL值。在清单 9-3 中的三个语句都是这样做的。
SELECT count(*)
FROM pls_fy2018_libraries;
SELECT count(*)
FROM pls_fy2017_libraries;
SELECT count(*)
FROM pls_fy2016_libraries;
清单 9-3:使用count(``)统计表格行数
分别运行清单 9-3 中的每个命令,以查看表格行数。对于pls_fy2018_libraries,结果应如下:
count
-----
9261
对于pls_fy2017_libraries,你应该看到以下结果:
count
-----
9245
最终,pls_fy2016_libraries的结果应如下所示:
count
-----
9252
三个结果都与我们预期的行数相符。这是一个良好的第一步,因为它可以提醒我们是否存在缺失行或可能导入了错误的文件等问题。
统计列中出现的值
如果我们向count()提供一个列名而不是星号,它将返回非NULL行的数量。例如,我们可以使用count()来统计pls_fy2018_libraries表格中phone列的非NULL值数量,如清单 9-4 所示。
SELECT count(phone)
FROM pls_fy2018_libraries;
清单 9-4:使用count(``)统计列中值的数量
结果显示有 9,261 行在phone列中有值,这与我们之前找到的总行数相同。
count
-----
9261
这意味着phone列中的每一行都有值。你可能已经猜到这一点,因为该列在CREATE TABLE语句中有NOT NULL约束。但进行这个检查是值得的,因为值的缺失可能会影响你是否继续进行分析的决定。为了全面验证数据,通常检查领域专家并深入挖掘数据是一个好主意;我建议将寻求专家意见作为更广泛分析方法的一部分(有关此主题的更多内容,请参见第二十章)。
统计列中不同值的数量
在第三章中,我讲解了DISTINCT关键字——这是 SQL 标准的一部分——它与SELECT一起使用时返回唯一值的列表。我们可以用它来查看单列中的唯一值,或者查看多列中值的唯一组合。我们也可以将DISTINCT添加到count()函数中,来返回某列中不同值的计数。
清单 9-5 展示了两个查询。第一个查询统计 2018 表格中libname列的所有值。第二个查询也做相同的事情,但在列名前加上了DISTINCT。请分别运行这两个查询。
SELECT count(libname)
FROM pls_fy2018_libraries;
SELECT count(DISTINCT libname)
FROM pls_fy2018_libraries;
清单 9-5:使用count(``)统计列中不同值的数量
第一个查询返回的行数与我们通过清单 9-3 找到的表格行数相匹配:
count
-----
9261
这很好。我们预计每一行都会列出图书馆机构的名称。但是第二个查询返回的结果更小:
count
-----
8478
使用 DISTINCT 去除重复项,将图书馆名称的数量减少到唯一的 8,478 个。对数据的进一步检查显示,2018 年调查中有 526 个图书馆机构与一个或多个其他机构共享名称。十个图书馆机构都被命名为 OXFORD PUBLIC LIBRARY,每个都位于不同州的名为 Oxford 的城市或城镇,包括阿拉巴马州、康涅狄格州、堪萨斯州和宾夕法尼亚州等。我们将编写查询,以查看“使用 GROUP BY 聚合数据”部分中的不同值组合。
使用 max() 和 min() 查找最大值和最小值
max() 和 min() 函数分别给我们返回一列中的最大值和最小值,这些函数有几个用途。首先,它们帮助我们了解报告值的范围。其次,这些函数能揭示数据中的意外问题,正如你现在将看到的。
max() 和 min() 都是以列名作为输入,工作方式相同。 列表 9-6 在 2018 表中使用 max() 和 min(),它使用 visits 列,记录了每年对图书馆机构及其所有分支的访问次数。运行代码。
SELECT max(visits), min(visits)
FROM pls_fy2018_libraries;
列表 9-6:使用 max() 和 min() 查找最多和最少的访问量
查询返回以下结果:
max min
-------- ---
16686945 -3
哦,这很有趣。超过 1,660 万的最大值对一个大城市的图书馆系统来说是合理的,但最小值为 -3 呢?表面上看,这个结果似乎是一个错误,但事实证明,图书馆调查的创建者采用了一种常见但可能存在问题的数据收集惯例,即在某列中放入负数或某个人为的高值,以表示某种特定条件。
在这种情况下,数字列中的负值表示以下内容:
-
-1的值表示对该问题的“未响应”。 -
-3的值表示“不可应用”,当一个图书馆机构暂时或永久关闭时,会使用这个值。
在探索数据时,我们需要考虑并排除负值,因为将负值包含在列的求和中会导致总和错误。我们可以使用 WHERE 子句来过滤这些负值。这也提醒我们,在深入分析前,始终先阅读数据的文档,以提前发现问题,而不是在投入大量时间分析后才后退修正!
使用 GROUP BY 聚合数据
当你使用带有聚合函数的 GROUP BY 子句时,可以根据一个或多个列中的值对结果进行分组。这使我们可以对表中的每个州或每种图书馆机构类型执行如 sum() 或 count() 等操作。
让我们探讨一下如何使用带有聚合函数的 GROUP BY。单独使用 GROUP BY(这也是标准 ANSI SQL 的一部分)会从结果中去除重复值,类似于 DISTINCT。 列表 9-7 显示了 GROUP BY 子句的实际应用。
SELECT stabr
FROM pls_fy2018_libraries
1 GROUP BY stabr
ORDER BY stabr;
列表 9-7:对 stabr 列使用 GROUP BY
我们在FROM子句后添加GROUP BY子句 1,并包括要分组的列名。在这种情况下,我们选择stabr,它包含州的缩写,并按该列进行分组。然后,我们使用ORDER BY stabr,这样分组后的结果将按字母顺序排列。这样就会得到 2018 年表格中唯一的州缩写。以下是部分结果:
``` stabr ----- AK AL AR AS AZ CA `--snip--` WV WY ``` Notice that there are no duplicates in the 55 rows returned. These standard two-letter postal abbreviations include the 50 states plus Washington, DC, and several US territories, such as Guam and the US Virgin Islands. You’re not limited to grouping just one column. In Listing 9-8, we use the `GROUP BY` clause on the 2018 data to specify the `city` and `stabr` columns for grouping. ``` SELECT city, stabr FROM pls_fy2018_libraries GROUP BY city, stabr ORDER BY city, stabr; ``` Listing 9-8: Using `GROUP BY` on the `city` and `stabr` columns The results get sorted by city and then by state, and the output shows unique combinations in that order: ``` city stabr ---------- ----- ABBEVILLE AL ABBEVILLE LA ABBEVILLE SC ABBOTSFORD WI ABERDEEN ID ABERDEEN SD ABERNATHY TX `--snip--` ``` This grouping returns 9,013 rows, 248 fewer than the total table rows. The result indicates that the file includes multiple instances where there’s more than one library agency for a particular city and state combination. #### Combining GROUP BY with count() If we combine `GROUP BY` with an aggregate function, such as `count()`, we can pull more descriptive information from our data. For example, we know 9,261 library agencies are in the 2018 table. We can get a count of agencies by state and sort them to see which states have the most. Listing 9-9 shows how to do this. ``` 1 SELECT stabr, count(*) FROM pls_fy2018_libraries 2 GROUP BY stabr 3 ORDER BY count(*) DESC; ``` Listing 9-9: Using `GROUP BY` with `count(``)` on the `stabr` column We’re now asking for the values in the `stabr` column and a count of how many rows have a given `stabr` value. In the list of columns to query 1, we specify `stabr` and `count()` with an asterisk as its input, which will cause `count()` to include `NULL` values. Also, when we select individual columns along with an aggregate function, we must include the columns in a `GROUP BY` clause 2. If we don’t, the database will return an error telling us to do so, because you can’t group values by aggregating and have ungrouped column values in the same query. To sort the results and have the state with the largest number of agencies at the top, we can use an `ORDER BY` clause 3 that includes the `count()` function and the `DESC` keyword. Run the code in Listing 9-9. The results show New York, Illinois, and Texas as the states with the greatest number of library agencies in 2018: ``` stabr count ----- ----- NY 756 IL 623 TX 560 IA 544 PA 451 MI 398 WI 381 MA 369 `--snip--` ``` Remember that our table represents library agencies that serve a locality. Just because New York, Illinois, and Texas have the greatest number of library agencies doesn’t mean they have the greatest number of outlets where you can walk in and peruse the shelves. An agency might have one central library only, or it might have no central libraries but 23 branches spread around a county. To count outlets, each row in the table also has values in the columns `centlib` and `branlib`, which record the number of central and branch libraries, respectively. To find totals, we would use the `sum()` aggregate function on both columns. #### Using GROUP BY on Multiple Columns with count() We can glean yet more information from our data by combining `GROUP BY` with `count()` and multiple columns. For example, the `stataddr` column in all three tables contains a code indicating whether the agency’s address changed in the last year. The values in `stataddr` are as follows: 1. 00 No change from last year 2. 07 Moved to a new location 3. 15 Minor address change Listing 9-10 shows the code for counting the number of agencies in each state that moved, had a minor address change, or had no change using `GROUP BY` with `stabr` and `stataddr` and adding `count()`. ``` 1 SELECT stabr, stataddr, count(*) FROM pls_fy2018_libraries 2 GROUP BY stabr, stataddr 3 ORDER BY stabr, stataddr; ``` Listing 9-10: Using `GROUP BY` with `count(``)` of the `stabr` and `stataddr` columns The key sections of the query are the column names and the `count()` function after `SELECT` 1, and making sure both columns are reflected in the `GROUP BY` clause 2 to ensure that `count()` will show the number of unique combinations of `stabr` and `stataddr`. To make the output easier to read, let’s sort first by the state and address status codes in ascending order 3. Here are the results: ``` stabr stataddr count ----- -------- ----- AK 00 82 AL 00 220 AL 07 3 AL 15 1 AR 00 58 AR 07 1 AR 15 1 AS 00 1 `--snip--` ``` The first few rows show that code `00` (no change in address) is the most common value for each state. We’d expect that because it’s likely there are more library agencies that haven’t changed address than those that have. The result helps assure us that we’re analyzing the data in a sound way. If code `07` (moved to a new location) was the most frequent in each state, that would raise a question about whether we’ve written the query correctly or whether there’s an issue with the data. #### Revisiting sum() to Examine Library Activity Now let’s expand our techniques to include grouping and aggregating across joined tables using the 2018, 2017, and 2016 libraries data. Our goal is to identify trends in library visits spanning that three-year period. To do this, we need to calculate totals using the `sum()` aggregate function. Before we dig into these queries, let’s address the values `-3` and `-1`, which indicate “not applicable” and “nonresponse.” To prevent these negative numbers from affecting the analysis, we’ll filter them out using a `WHERE` clause to limit the queries to rows where values in `visits` are zero or greater. Let’s start by calculating the sum of annual visits to libraries from the individual tables. Run each `SELECT` statement in Listing 9-11 separately. ``` SELECT sum(visits) AS visits_2018 FROM pls_fy2018_libraries WHERE visits >= 0; SELECT sum(visits) AS visits_2017 FROM pls_fy2017_libraries WHERE visits >= 0; SELECT sum(visits) AS visits_2016 FROM pls_fy2016_libraries WHERE visits >= 0; ``` Listing 9-11: Using the `sum(``)` aggregate function to total visits to libraries in 2016, 2017, and 2018 For 2018, visits totaled approximately 1.29 billion: ``` visits_2018 ----------- 1292348697 ``` For 2017, visits totaled approximately 1.32 billion: ``` visits_2017 ----------- 1319803999 ``` And for 2016, visits totaled approximately 1.36 billion: ``` visits_2016 ----------- 1355648987 ``` We’re onto something here, but it may not be good news for libraries. The trend seems to point downward with visits dropping about 5 percent from 2016 to 2018. Let’s refine this approach. These queries sum visits recorded in each table. But from the row counts we ran earlier in the chapter, we know that each table contains a different number of library agencies: 9,261 in 2018; 9,245 in 2017; and 9,252 in 2016\. The differences are likely due to agencies opening, closing, or merging. So, let’s determine how the sum of visits will differ if we limit the analysis to library agencies that exist in all three tables and have a non-negative value for `visits`. We can do that by joining the tables, as shown in Listing 9-12. ``` 1 SELECT sum(pls18.visits) AS visits_2018, sum(pls17.visits) AS visits_2017, sum(pls16.visits) AS visits_2016 2 FROM pls_fy2018_libraries pls18 JOIN pls_fy2017_libraries pls17 ON pls18.fscskey = pls17.fscskey JOIN pls_fy2016_libraries pls16 ON pls18.fscskey = pls16.fscskey 3 WHERE pls18.visits >= 0 AND pls17.visits >= 0 AND pls16.visits >= 0; ``` Listing 9-12: Using `sum(``)` to total visits on joined 2018, 2017, and 2016 tables This query pulls together a few concepts we covered in earlier chapters, including table joins. At the top, we use the `sum()` aggregate function 1 to total the `visits` columns from each of the three tables. When we join the tables on the tables’ primary keys, we’re declaring table aliases 2 as we explored in Chapter 7—and here, we’re omitting the optional `AS` keyword in front of each alias. For example, we declare `pls18` as the alias for the 2018 table to avoid having to write its lengthier full name throughout the query. Note that we use a standard `JOIN`, also known as an `INNER JOIN`, meaning the query results will only include rows where the values in the `fscskey` primary key match in all three tables. As we did in Listing 9-11, we specify with a `WHERE` clause 3 that the result should include only those rows where `visits` are greater than or equal to 0 in the tables. This will prevent the artificial negative values from impacting the sums. Run the query. The results should look like this: ``` visits_2018 visits_2017 visits_2016 ----------- ----------- ----------- 1278148838 1319325387 1355078384 ``` The results are similar to what we found by querying the tables separately, although these totals are as much as 14 million smaller in 2018\. Still, the downward trend holds. For a full picture of how library use is changing, we’d want to run a similar query on all of the columns that contain performance indicators to chronicle the trend in each. For example, the column `wifisess` shows how many times users connected to the library’s wireless internet. If we use `wifisess` instead of `visits` in Listing 9-11, we get this result: ``` wifi_2018 wifi_2017 wifi_2016 --------- --------- --------- 349767271 311336231 234926102 ``` So, though visits were down, libraries saw a sharp increase in Wi-Fi network use. That provides a keen insight into how the role of libraries is changing. #### Grouping Visit Sums by State Now that we know library visits dropped for the United States as a whole between 2016 and 2018, you might ask yourself, “Did every part of the country see a decrease, or did the degree of the trend vary by region?” We can answer this question by modifying our preceding query to group by the state code. Let’s also use a percent-change calculation to compare the trend by state. Listing 9-13 contains the full code. ``` 1 SELECT pls18.stabr, sum(pls18.visits) AS visits_2018, sum(pls17.visits) AS visits_2017, sum(pls16.visits) AS visits_2016, round( (sum(pls18.visits::numeric) - sum(pls17.visits)) / 2 sum(pls17.visits) * 100, 1 ) AS chg_2018_17, round( (sum(pls17.visits::numeric) - sum(pls16.visits)) / sum(pls16.visits) * 100, 1 ) AS chg_2017_16 FROM pls_fy2018_libraries pls18 JOIN pls_fy2017_libraries pls17 ON pls18.fscskey = pls17.fscskey JOIN pls_fy2016_libraries pls16 ON pls18.fscskey = pls16.fscskey WHERE pls18.visits >= 0 AND pls17.visits >= 0 AND pls16.visits >= 0 3 GROUP BY pls18.stabr 4 ORDER BY chg_2018_17 DESC; ``` Listing 9-13: Using `GROUP BY` to track percent change in library visits by state We follow the `SELECT` keyword with the `stabr` column 1 from the 2018 table; that same column appears in the `GROUP BY` clause 3. It doesn’t matter which table’s `stabr` column we use because we’re only querying agencies that appear in all three tables. After the `visits` columns, we include the now-familiar percent-change calculation you learned in Chapter 6. We use this twice, giving the aliases `chg_2018_17` 2 and `chg_2017_16` for clarity. We end the query with an `ORDER BY` clause 4, sorting by the `chg_2018_17` column alias. When you run the query, the top of the results shows 10 states with an increase in visits from 2017 to 2018\. The rest of the results show a decline. American Samoa, at the bottom of the ranking, had a 28 percent drop! ``` stabr visits_2018 visits_2017 visits_2016 chg_2018_17 chg_2017_16 ----- ----------- ----------- ----------- ----------- ----------- SD 3824804 3699212 3722376 3.4 -0.6 MT 4332900 4215484 4298268 2.8 -1.9 FL 68423689 66697122 70991029 2.6 -6.0 ND 2216377 2162189 2201730 2.5 -1.8 ID 8179077 8029503 8597955 1.9 -6.6 DC 3632539 3593201 3930763 1.1 -8.6 ME 6746380 6731768 6811441 0.2 -1.2 NH 7045010 7028800 7236567 0.2 -2.9 UT 15326963 15295494 16096911 0.2 -5.0 DE 4122181 4117904 4125899 0.1 -0.2 OK 13399265 13491194 13112511 -0.7 2.9 WY 3338772 3367413 3536788 -0.9 -4.8 MA 39926583 40453003 40427356 -1.3 0.1 WA 37338635 37916034 38634499 -1.5 -1.9 MN 22952388 23326303 24033731 -1.6 -2.9 `--snip--` GA 26835701 28816233 27987249 -6.9 3.0 AR 9551686 10358181 10596035 -7.8 -2.2 GU 75119 81572 71813 -7.9 13.6 MS 7602710 8581994 8915406 -11.4 -3.7 HI 3456131 4135229 4490320 -16.4 -7.9 AS 48828 67848 63166 -28.0 7.4 ``` It’s helpful, for context, to also see the percent change in `visits` from 2016 to 2017\. Many of the states, such as Minnesota, show consecutive declines. Others, including several at the top of the list, show gains after substantial decreases the year prior. This is when it’s a good idea investigate what’s driving the changes. Data analysis can sometimes raise as many questions as it answers, but that’s part of the process. It’s always worth a phone call to a person who works closely with the data to review your findings. Sometimes, they’ll have a good explanation. Other times, an expert will say, “That doesn’t sound right.” That answer might send you back to the keeper of the data or the documentation to find out if you overlooked a code or a nuance with the data. #### Filtering an Aggregate Query Using HAVING To refine our analysis, we can examine a subset of states and territories that share similar characteristics. With percent change in visits, it makes sense to separate large states from small states. In a small state like Rhode Island, a single library closing for six months for repairs could have a significant effect. A single closure in California might be scarcely noticed in a statewide count. To look at states with a similar volume in visits, we could sort the results by either of the `visits` columns, but it would be cleaner to get a smaller result set by filtering our query. To filter the results of aggregate functions, we need to use the `HAVING` clause that’s part of standard ANSI SQL. You’re already familiar with using `WHERE` for filtering, but aggregate functions, such as `sum()`, can’t be used within a `WHERE` clause because they operate at the row level, and aggregate functions work across rows. The `HAVING` clause places conditions on groups created by aggregating. The code in Listing 9-14 modifies the query in Listing 9-13 by inserting the `HAVING` clause after `GROUP BY`. ``` SELECT pls18.stabr, sum(pls18.visits) AS visits_2018, sum(pls17.visits) AS visits_2017, sum(pls16.visits) AS visits_2016, round( (sum(pls18.visits::numeric) - sum(pls17.visits)) / sum(pls17.visits) * 100, 1 ) AS chg_2018_17, round( (sum(pls17.visits::numeric) - sum(pls16.visits)) / sum(pls16.visits) * 100, 1 ) AS chg_2017_16 FROM pls_fy2018_libraries pls18 JOIN pls_fy2017_libraries pls17 ON pls18.fscskey = pls17.fscskey JOIN pls_fy2016_libraries pls16 ON pls18.fscskey = pls16.fscskey WHERE pls18.visits >= 0 AND pls17.visits >= 0 AND pls16.visits >= 0 GROUP BY pls18.stabr 1 HAVING sum(pls18.visits) > 50000000 ORDER BY chg_2018_17 DESC; ``` Listing 9-14: Using a `HAVING` clause to filter the results of an aggregate query In this case, we’ve set our query results to include only rows with a sum of visits in 2018 greater than 50 million. That’s an arbitrary value I chose to show only the very largest states. Adding the `HAVING` clause 1 reduces the number of rows in the output to just six. In practice, you might experiment with various values. Here are the results: ``` stabr visits_2018 visits_2017 visits_2016 chg_2018_17 chg_2017_16 ----- ----------- ----------- ----------- ----------- ----------- FL 68423689 66697122 70991029 2.6 -6.0 NY 97921323 100012193 103081304 -2.1 -3.0 CA 146656984 151056672 155613529 -2.9 -2.9 IL 63466887 66166082 67336230 -4.1 -1.7 OH 68176967 71895854 74119719 -5.2 -3.0 TX 66168387 70514138 70975901 -6.2 -0.7 ``` All but one of the six states experienced a decline in visits, but notice that the percent-change variation isn’t as wide as in the full set of states and territories. Depending on what we learn from library experts, looking at the states with the most activity as a group might be helpful in describing trends, as would looking at other groupings. Think of a sentence you might write that would say, “Among states with the most library visits, Florida was the only one to see an increase in activity between 2017 and 2018; the rest saw visits decrease between 2 percent and 6 percent.” You could write similar sentences about medium-sized states and small states. ## Wrapping Up If you’re now inspired to visit your local library and check out a couple of books, ask a librarian whether their branch has seen a rise or drop in visits over the last few years. You can probably guess the answer. In this chapter, you learned how to use standard SQL techniques to summarize data in a table by grouping values and using a handful of aggregate functions. By joining datasets, you were able to identify some interesting trends. You also learned that data doesn’t always come perfectly packaged. The presence of negative values in columns, used as an indicator rather than as an actual numeric value, forced us to filter out those rows. Unfortunately, those sorts of challenges are part of the data analyst’s everyday world, so we’ll spend the next chapter learning how to clean up a dataset that has a number of issues. Later in the book, you’ll also discover more aggregate functions to help you find the stories in your data.
第十章:检查与修改数据

如果我要向刚刚获得认证的数据分析师们敬酒,我会举杯说道:“愿你们的数据完美结构化,且无任何错误!”但现实中,你有时会收到状态糟糕的数据,难以分析,除非先进行修改。这就是所谓的脏数据,是一个总称,用于描述包含错误、缺失值或组织不良的数据,这些数据会使标准查询变得无效。在这一章中,你将使用 SQL 清理一组脏数据,并执行其他有用的维护任务,使数据变得可用。
脏数据可能有多种来源。从一种文件类型转换为另一种文件类型,或将列赋予错误的数据类型,可能导致信息丢失。人在输入或编辑数据时也可能不小心,留下拼写错误和不一致的地方。不论原因是什么,脏数据都是数据分析师的噩梦。
你将学习如何检查数据质量,如何修改数据和表格以便更容易进行分析。但你将学到的技巧不仅仅适用于清理数据。能够对数据和表格进行修改,让你有机会在新信息可用时更新或添加它们,从而使你的数据库从静态集合变成一个动态记录。
让我们从导入数据开始吧。
导入肉类、家禽和蛋类生产商的数据
在这个示例中,我们将使用美国肉类、家禽和蛋类生产商的目录。食品安全和检验局(FSIS),是美国农业部下属的一个机构,负责定期汇编并更新该数据库。FSIS 负责检查超过 6,000 家肉类加工厂、屠宰场、农场等地的动物和食品。如果检查员发现问题,例如细菌污染或标签错误,机构可以发出召回通知。任何对农业业务、食品供应链或食源性疾病爆发感兴趣的人都将发现该目录很有用。你可以在该机构的网站www.fsis.usda.gov/上了解更多信息。
我们将使用的数据来自www.data.gov/,这是美国联邦政府运营的网站,汇集了来自各种联邦机构的数千个数据集(catalog.data.gov/dataset/fsis-meat-poultry-and-egg-inspection-directory-by-establishment-name/)。我已将该网站上发布的 Excel 文件转换为 CSV 格式,你可以在nostarch.com/practical-sql-2nd-edition/找到MPI_Directory_by_Establishment_Name.csv文件和本书的其他资源链接。
要将文件导入到 PostgreSQL 中,使用 清单 10-1 中的代码创建一个名为 meat_poultry_egg_establishments 的表,并使用 COPY 将 CSV 文件添加到表中。像之前的示例一样,使用 pgAdmin 连接到你的 analysis 数据库,然后打开查询工具运行代码。记得更改 COPY 语句中的路径,以反映你的 CSV 文件的位置。
CREATE TABLE meat_poultry_egg_establishments (
1 establishment_number text CONSTRAINT est_number_key PRIMARY KEY,
company text,
street text,
city text,
st text,
zip text,
phone text,
grant_date date,
2 activities text,
dbas text
);
3 COPY meat_poultry_egg_establishments
FROM '`C:\YourDirectory\`MPI_Directory_by_Establishment_Name.csv'
WITH (FORMAT CSV, HEADER);
4 CREATE INDEX company_idx ON meat_poultry_egg_establishments (company);
清单 10-1:导入 FSIS 肉类、家禽和蛋类检查目录
该表有 10 列。我们为 establishment_number 列 1 添加了一个自然主键约束,该列将保存唯一的值以标识每个机构。其余大部分列与公司的名称和位置相关。你将在本章末尾的“动手实践”练习中使用 activities 列 2,它描述了公司的活动。我们将大部分列设置为 text 类型。在 PostgreSQL 中,text 是一个可变长度的数据类型,允许我们存储最多 1GB 的数据(参见第四章)。dbas 列包含超过 1,000 个字符的字符串,因此我们已经准备好处理这些数据。我们导入 CSV 文件 3,然后在 company 列 4 上创建索引,以加速对特定公司的查询。
作为练习,让我们使用第九章中介绍的 count() 聚合函数来检查 meat_poultry_egg_establishments 表中有多少行:
SELECT count(*) FROM meat_poultry_egg_establishments;
结果应该显示 6,287 行。现在,让我们了解数据包含了什么,并确定是否可以直接从中提取有用信息,或者是否需要以某种方式进行修改。
数据集采访
采访数据是我分析中最喜欢的部分。我们采访数据集以发现其细节——它包含了什么,它能回答哪些问题,以及它对我们的目的是否合适——就像工作面试揭示候选人是否具备所需技能一样。
第九章中的聚合查询是一个有用的采访工具,因为它们常常揭示数据集的局限性,或者提出在得出结论并假设结果有效之前你可能想要问的问题。
例如,meat_poultry_egg_establishments 表的行描述了食品生产商。乍一看,我们可能会假设每一行中的每个公司都在一个不同的地址上运营。但在数据分析中,假设永远是不安全的,所以让我们使用 清单 10-2 中的代码来验证。
SELECT company,
street,
city,
st,
count(*) AS address_count
FROM meat_poultry_egg_establishments
GROUP BY company, street, city, st
HAVING count(*) > 1
ORDER BY company, street, city, st;
清单 10-2:查找位于相同地址的多个公司
在这里,我们按 company、street、city 和 st 列的唯一组合对公司进行分组。然后,我们使用 count(*),它返回每个组合的行数,并给它一个别名 address_count。使用第九章介绍的 HAVING 子句,我们筛选结果,只显示那些具有相同值组合的多行数据。这应该返回公司所有重复的地址。
查询返回了 23 行,这意味着大约有两打的记录,其中同一公司在同一地址出现多次:
company street city st address_count
----------------------- ----------------------- ---------- -- -------------
Acre Station Meat Farm 17076 Hwy 32 N Pinetown NC 2
Beltex Corporation 3801 North Grove Street Fort Worth TX 2
Cloverleaf Cold Storage 111 Imperial Drive Sanford NC 2
`--snip--`
这不一定是个问题。公司在同一地址出现多次可能有合理的原因。例如,可能存在两个同名的加工厂。另一方面,我们可能也发现了数据录入错误。不论如何,在依赖数据集之前,消除对其有效性的疑虑是明智的做法,这个结果应该促使我们在得出结论之前,先调查各个具体案例。然而,在我们能够从数据中提取有意义信息之前,这个数据集还有其他问题需要我们注意。我们来通过几个例子探讨一下。
检查缺失值
接下来,我们将检查是否包含了所有州的值,并且通过一个简单的问题来检查是否有行缺少州代码:每个州的肉类、家禽和蛋类加工公司有多少家?我们将使用聚合函数count()并结合GROUP BY来确定这个数字,如清单 10-3 所示。
SELECT st,
count(*) AS st_count
FROM meat_poultry_egg_establishments
GROUP BY st
ORDER BY st;
清单 10-3:按州分组并计数
该查询是一个简单的计数,统计每个州邮政编码(st)在表中出现的次数。你的结果应包括 57 行,按州邮政编码(在st列中)分组。为什么超过 50 个美国州?因为数据中还包括波多黎各和其他未合并的美国领土,例如关岛和美属萨摩亚。阿拉斯加(AK)位于结果顶部,共有 17 家企业:
st st_count
-- --------
AK 17
AL 93
AR 87
AS 1
`--snip--`
WA 139
WI 184
WV 23
WY 1
3
然而,列表底部的这一行在st列中有NULL值,并且st_count中有3。这意味着有三行st的值是NULL。为了查看这些设施的详细信息,我们来查询这些行。
在清单 10-4 中,我们添加了一个WHERE子句,结合st列和IS NULL关键字来查找哪些行缺少州代码。
SELECT establishment_number,
company,
city,
st,
zip
FROM meat_poultry_egg_establishments
WHERE st IS NULL;
清单 10-4:使用IS NULL查找st列中的缺失值
这个查询返回了三行st列没有值的记录:
est_number company city st zip
----------------- ------------------------------- ------ -- -----
V18677A Atlas Inspection, Inc. Blaine 55449
M45319+P45319 Hall-Namie Packing Company, Inc 36671
M263A+P263A+V263A Jones Dairy Farm 53538
这是一个问题,因为任何包含st列的计数都会不准确,例如按州统计的企业数量。当你发现类似的错误时,值得快速检查一下你下载的原始文件。除非你处理的是数吉字节大小的文件,否则通常可以在我在第一章中提到的文本编辑器中打开 CSV 文件并搜索该行。如果你处理的是较大的文件,可以使用grep(在 Linux 和 macOS 上)或findstr(在 Windows 上)等工具查看源数据。在这个例子中,来自www.data.gov/的文件经过可视化检查后,确认确实在文件中的这些行没有列出州名,因此这个错误是数据本身的问题,而不是导入过程中引入的错误。
在对数据的初步审查中,我们发现需要向st列添加缺失的值以清理这个表格。让我们看看数据集中还有哪些问题,并列出清理任务。
检查不一致的数据值
不一致的数据是另一个可能妨碍我们分析的因素。我们可以通过使用GROUP BY和count()来检查列中不一致输入的数据。当你扫描结果中的去重值时,可能会发现名字或其他属性的拼写差异。
例如,我们表格中的 6,200 家公司中,许多是由少数跨国食品公司拥有的多个地点,如嘉吉公司(Cargill)或泰森食品(Tyson Foods)。为了找出每家公司拥有多少个地点,我们可以统计company列中的值。让我们看看使用列表 10-5 中的查询时会发生什么。
SELECT company,
count(*) AS company_count
FROM meat_poultry_egg_establishments
GROUP BY company
ORDER BY company ASC;
列表 10-5:使用GROUP BY和count()来查找不一致的公司名称
滚动查看结果可以发现一些公司的名称以多种不同的方式拼写。例如,注意到 Armour-Eckrich 品牌的条目:
company company_count
--------------------------- -------------
`--snip--`
Armour - Eckrich Meats, LLC 1
Armour-Eckrich Meats LLC 3
Armour-Eckrich Meats, Inc. 1
Armour-Eckrich Meats, LLC 2
`--snip--`
对于七个可能由同一家公司拥有的机构,至少有四种不同的拼写方式。如果我们稍后按公司进行聚合,标准化名称会有所帮助,这样所有计数或求和的项目可以正确分组。让我们将这一点加入需要修复的事项列表。
使用 length()检查格式错误的值
检查列中应该保持一致格式的意外值是个好主意。例如,meat_poultry_egg_establishments表中的zip列每个条目应该按照美国 ZIP 代码格式(五位数字)进行格式化。然而,这并不是我们数据集中所包含的内容。
Solely for the purpose of this example, I replicated a common error I’ve committed before. When I converted the original Excel file to a CSV file, I stored the ZIP code in the default “General” number format instead of as a text value, and any ZIP code that begins with a zero lost its leading zero because an integer can’t start with a zero. As a result, 07502 appears in the table as `7502`. You can make this error in a variety of ways, including by copying and pasting data into Excel columns set to “General.” After being burned a few times, I learned to take extra caution with numbers that should be formatted as text. My deliberate error appears when we run the code in Listing 10-6. The example introduces `length()`, a *string function* that counts the number of characters in a string. We combine `length()` with `count()` and `GROUP BY` to determine how many rows have five characters in the `zip` field and how many have a value other than five. To make it easy to scan the results, we use `length()` in the `ORDER BY` clause. ``` SELECT length(zip), count(*) AS length_count FROM meat_poultry_egg_establishments GROUP BY length(zip) ORDER BY length(zip) ASC; ``` Listing 10-6: Using `length()` and `count()` to test the `zip` column The results confirm the formatting error. As you can see, `496` of the ZIP codes are four characters long, and `86` are three characters long, which likely means these numbers originally had two leading zeros that my conversion erroneously eliminated: ``` length length_count ------ ------------ 3 86 4 496 5 5705 ``` Using the `WHERE` clause, we can see which states these shortened ZIP codes correspond to, as shown in Listing 10-7. ``` SELECT st, count(*) AS st_count FROM meat_poultry_egg_establishments 1 WHERE length(zip) < 5 GROUP BY st ORDER BY st ASC; ``` Listing 10-7: Filtering with `length()` to find short `zip` values We use the `length()` function inside the `WHERE` clause 1 to return a count of rows where the ZIP code is less than five characters for each state code. The result is what we would expect. The states are largely in the Northeast region of the United States where ZIP codes often start with a zero: ``` st st_count -- -------- CT 55 MA 101 ME 24 NH 18 NJ 244 PR 84 RI 27 VI 2 VT 27 ``` Obviously, we don’t want this error to persist, so we’ll add it to our list of items to correct. So far, we need to correct the following issues in our dataset: * Missing values for three rows in the `st` column * Inconsistent spelling of at least one company’s name * Inaccurate ZIP codes due to file conversion Next, we’ll look at how to use SQL to fix these issues by modifying your data. ## Modifying Tables, Columns, and Data Almost nothing in a database, from tables to columns and the data types and values they contain, is set in concrete after it’s created. As your needs change, you can use SQL to add columns to a table, change data types on existing columns, and edit values. Given the issues we discovered in the `meat_poultry_egg_establishments` table, being able to modify our database will come in handy. We’ll use two SQL commands. The first, `ALTER TABLE`, is part of the ANSI SQL standard and provides options to `ADD COLUMN`, `ALTER COLUMN`, and `DROP COLUMN`, among others. The second command, `UPDATE`, also included in the SQL standard, allows you to change values in a table’s columns. You can supply criteria using `WHERE` to choose which rows to update. Let’s explore the basic syntax and options for both commands and then use them to fix the issues in our dataset. ### Modifying Tables with ALTER TABLE We can use the `ALTER TABLE` statement to modify the structure of tables. The following examples show standard ANSI SQL syntax for common operations, starting with the code for adding a column to a table: ``` ALTER TABLE `table` ADD COLUMN `column` `data_type`; ``` We can remove a column with the following syntax: ``` ALTER TABLE `table` DROP COLUMN `column`; ``` To change the data type of a column, we would use this code: ``` ALTER TABLE `table` ALTER COLUMN `column` SET DATA TYPE `data_type`; ``` We add a `NOT NULL` constraint to a column like so: ``` ALTER TABLE `table` ALTER COLUMN `column` SET NOT NULL; ``` Note that in PostgreSQL and some other systems, adding a constraint to the table causes all rows to be checked to see whether they comply with the constraint. If the table has millions of rows, this could take a while. Removing the `NOT NULL` constraint looks like this: ``` ALTER TABLE `table` ALTER COLUMN `column` DROP NOT NULL; ``` When you execute `ALTER TABLE` with the placeholders filled in, you should see a message that reads `ALTER TABLE` in the pgAdmin output screen. If an operation violates a constraint or if you attempt to change a column’s data type and the existing values in the column won’t conform to the new data type, PostgreSQL returns an error. But PostgreSQL won’t give you any warning about deleting data when you drop a column, so use extra caution before dropping a column. ### Modifying Values with UPDATE The `UPDATE` statement, part of the ANSI SQL standard, modifies the data in a column that meets a condition. It can be applied to all rows or a subset of rows. Its basic syntax for updating the data in every row in a column follows this form: ``` UPDATE `table` SET `column` = `value`; ``` We first pass `UPDATE` the name of the table. Then to `SET` we pass the column we want to update. The new `value` to place in the column can be a string, number, the name of another column, or even a query or expression that generates a value. The new value must be compatible with the column data type. We can update values in multiple columns by adding additional columns and source values and separating each with a comma: ``` UPDATE `table` SET `column_a` = `value`, `column_b` = `value`; ``` To restrict the update to particular rows, we add a `WHERE` clause with some criteria that must be met before the update can happen, such as rows where values equal a date or match a string: ``` UPDATE `table` SET `column` = `value` WHERE `criteria`; ``` We can also update one table with values from another table. Standard ANSI SQL requires that we use a *subquery*, a query inside a query, to specify which values and rows to update: ``` UPDATE `table` SET `column` = (SELECT `column` FROM `table_b` WHERE `table.column` = `table_b.column`) WHERE EXISTS (SELECT `column` FROM `table_b` WHERE `table.column` = `table_b.column`); ``` The value portion of `SET`, inside the parentheses, is a subquery. A `SELECT` statement inside parentheses generates the values for the update by joining columns in both tables on matching row values. Similarly, the `WHERE EXISTS` clause uses a `SELECT` statement to ensure that we only update rows where both tables have matching values. If we didn’t use `WHERE EXISTS`, we might inadvertently set some values to `NULL` without planning to. (If this syntax looks somewhat complicated, that’s okay. I’ll cover subqueries in detail in Chapter 13.) Some database managers offer additional syntax for updating across tables. PostgreSQL supports the ANSI standard but also a simpler syntax using a `FROM` clause: ``` UPDATE `table` SET `column` = `table_b.column` FROM `table_b` WHERE `table.column = table_b.column`; ``` When you execute an `UPDATE` statement, you’ll get a message stating `UPDATE` along with the number of rows affected. ### Viewing Modified Data with RETURNING If you add an optional `RETURNING` clause to `UPDATE`, you can view the values that were modified without having to run a second, separate query. The syntax of the clause uses the `RETURNING` keyword followed by a list of columns or a wildcard in the same manner that we name columns following `SELECT`. Here’s an example: ``` UPDATE `table` SET `column_a` = `value` RETURNING `column_a`, `column_b`, `column_c`; ``` Instead of just noting the number of rows modified, `RETURNING` directs the database to show the columns you specify for the rows modified. This is a PostgreSQL-specific implementation that you also can use with `INSERT` and `DELETE FROM`. We’ll try it with some of our examples. ### Creating Backup Tables Before modifying a table, it’s a good idea to make a copy for reference and backup in case you accidentally destroy some data. Listing 10-8 shows how to use a variation of the familiar `CREATE TABLE` statement to make a new table from the table we want to duplicate. ``` CREATE TABLE meat_poultry_egg_establishments_backup AS SELECT * FROM meat_poultry_egg_establishments; ``` Listing 10-8: Backing up a table The result should be a pristine copy of your table with the new specified name. You can confirm this by counting the number of records in both tables at once: ``` SELECT (SELECT count(*) FROM meat_poultry_egg_establishments) AS original, (SELECT count(*) FROM meat_poultry_egg_establishments_backup) AS backup; ``` The results should return the same count from both tables, like this: ``` original backup -------- ------ 6287 6287 ``` If the counts match, you can be sure your backup table is an exact copy of the structure and contents of the original table. As an added measure and for easy reference, we’ll use `ALTER TABLE` to make copies of column data within the table we’re updating. ### Restoring Missing Column Values The query in Listing 10-4 earlier revealed that three rows in the `meat_poultry_egg_establishments` table don’t have a value in the `st` column: ``` est_number company city st zip ----------------- ------------------------------- ------ -- ----- V18677A Atlas Inspection, Inc. Blaine 55449 M45319+P45319 Hall-Namie Packing Company, Inc 36671 M263A+P263A+V263A Jones Dairy Farm 53538 ``` To get a complete count of establishments in each state, we need to fill those missing values using an `UPDATE` statement. #### Creating a Column Copy Even though we’ve backed up this table, let’s take extra caution and make a copy of the `st` column within the table so we still have the original data if we make some dire error somewhere. Let’s create the copy and fill it with the existing `st` column values as in Listing 10-9. ``` 1 ALTER TABLE meat_poultry_egg_establishments ADD COLUMN st_copy text; UPDATE meat_poultry_egg_establishments 2 SET st_copy = st; ``` Listing 10-9: Creating and filling the `st_copy` column with `ALTER TABLE` and `UPDATE` The `ALTER TABLE` statement 1 adds a column called `st_copy` using the same `text` data type as the original `st` column. Next, the `SET` clause 2 in `UPDATE` fills our new `st_copy` column with the values in column `st`. Because we don’t specify any criteria using `WHERE`, values in every row are updated, and PostgreSQL returns the message `UPDATE 6287`. Again, it’s worth noting that on a very large table, this operation could take some time and also substantially increase the table’s size. Making a column copy in addition to a table backup isn’t entirely necessary, but if you’re the patient, cautious type, it can be worthwhile. We can confirm the values were copied properly with a simple `SELECT` query on both columns, as in Listing 10-10. ``` SELECT st, st_copy FROM meat_poultry_egg_establishments WHERE st IS DISTINCT FROM st_copy ORDER BY st; ``` Listing 10-10: Checking values in the `st` and `st_copy` columns To check for differences between values in the columns, we use `IS DISTINCT FROM` in the `WHERE` clause. You’ve used `DISTINCT` before to find unique values in a column (Chapter 3); in this context, `IS DISTINCT FROM` tests whether values in `st` and `st_copy` are different. This keeps us from having to scan every row ourselves. Running this query will return zero rows, meaning the values match throughout the table. Now, with our original data safely stored, we can update the three rows with missing state codes. This is now our in-table backup, so if something goes drastically wrong while we’re updating the original column, we can easily copy the original data back in. I’ll show you how after we apply the first updates. #### Updating Rows Where Values Are Missing To update those rows’ missing values, we first find the values we need with a quick online search: Atlas Inspection is located in Minnesota; Hall-Namie Packing is in Alabama; and Jones Dairy is in Wisconsin. We add those states to the appropriate rows in Listing 10-11. ``` UPDATE meat_poultry_egg_establishments SET st = 'MN' 1 WHERE establishment_number = 'V18677A'; UPDATE meat_poultry_egg_establishments SET st = 'AL' WHERE establishment_number = 'M45319+P45319'; UPDATE meat_poultry_egg_establishments SET st = 'WI' WHERE establishment_number = 'M263A+P263A+V263A' 2 RETURNING establishment_number, company, city, st, zip; ``` Listing 10-11: Updating the `st` column for three establishments Because we want each `UPDATE` statement to affect a single row, we include a `WHERE` clause 1 for each that identifies the company’s unique `establishment_number`, which is the table’s primary key. When we run the first two queries, PostgreSQL responds with the message `UPDATE 1`, showing that only one row was updated for each query. When we run the third, the `RETURNING` clause 2 directs the database to show several columns from the row that was updated: ``` establishment_number company city st zip -------------------- ---------------- -------- -- ----- M263A+P263A+V263A Jones Dairy Farm WI 53538 ``` If we rerun the code in Listing 10-4 to find rows where `st` is `NULL`, the query should return nothing. Success! Our count of establishments by state is now complete. #### Restoring Original Values What happens if we botch an update by providing the wrong values or updating the wrong rows? We’ll just copy the data back from either the full table backup or the column backup. Listing 10-12 shows the two options. ``` 1 UPDATE meat_poultry_egg_establishments SET st = st_copy; 2 UPDATE meat_poultry_egg_establishments original SET st = backup.st FROM meat_poultry_egg_establishments_backup backup WHERE original.establishment_number = backup.establishment_number; ``` Listing 10-12: Restoring original `st` column values To restore the values from the backup column in `meat_poultry_egg_establishments`, run an `UPDATE` query 1 that sets `st` to the values in `st_copy`. Both columns should again have the identical original values. Alternatively, you can create an `UPDATE` 2 that sets `st` to values in the `st` column from the `meat_poultry_egg_establishments_backup` table you made in Listing 10-8. This will obviate the fixes you made to add missing state values, so if you want to try this query, you’ll need to redo the fixes using Listing 10-11. ### Updating Values for Consistency In Listing 10-5, we discovered several cases where a single company’s name was entered inconsistently. These inconsistencies will hinder us if we want to aggregate data by company name, so we’ll fix them. Here are the spelling variations of Armour-Eckrich Meats in Listing 10-5: ``` `--snip--` Armour - Eckrich Meats, LLC Armour-Eckrich Meats LLC Armour-Eckrich Meats, Inc. Armour-Eckrich Meats, LLC `--snip--` ``` We can standardize the spelling using an `UPDATE` statement. To protect our data, we’ll create a new column for the standardized spellings, copy the names in `company` into the new column, and work in the new column. Listing 10-13 has the code for both actions. ``` ALTER TABLE meat_poultry_egg_establishments ADD COLUMN company_standard text; UPDATE meat_poultry_egg_establishments SET company_standard = company; ``` Listing 10-13: Creating and filling the `company_standard` column Now, let’s say we want any name in `company` that starts with the string `Armour` to appear in `company_standard` as `Armour-Eckrich Meats`. (This assumes we’ve checked all Armour entries and want to standardize them.) With Listing 10-14, we can update all the rows matching the string `Armour` using `WHERE`. ``` UPDATE meat_poultry_egg_establishments SET company_standard = 'Armour-Eckrich Meats' 1 WHERE company LIKE 'Armour%' 2 RETURNING company, company_standard; ``` Listing 10-14: Using an `UPDATE` statement to modify column values that match a string The important piece of this query is the `WHERE` clause that uses the `LIKE` keyword 1 for case-sensitive pattern matching introduced in Chapter 3. Including the wildcard syntax `%` at the end of the string `Armour` updates all rows that start with those characters regardless of what comes after them. The clause lets us target all the varied spellings used for the company’s name. The `RETURNING` clause 2 causes the statement to provide the results of the updated `company_standard` column next to the original `company` column: ``` company company_standard --------------------------- -------------------- Armour-Eckrich Meats LLC Armour-Eckrich Meats Armour - Eckrich Meats, LLC Armour-Eckrich Meats Armour-Eckrich Meats LLC Armour-Eckrich Meats Armour-Eckrich Meats LLC Armour-Eckrich Meats Armour-Eckrich Meats, Inc. Armour-Eckrich Meats Armour-Eckrich Meats, LLC Armour-Eckrich Meats Armour-Eckrich Meats, LLC Armour-Eckrich Meats ``` The values for Armour-Eckrich in `company_standard` are now standardized with consistent spelling. To standardize other company names in the table, we would create an `UPDATE` statement for each case. We would also keep the original `company` column for reference. ### Repairing ZIP Codes Using Concatenation Our final fix repairs values in the `zip` column that lost leading zeros. Zip codes in Puerto Rico and the US Virgin Islands begin with two zeros, so we need to restore two leading zeros to the values in `zip`. For the other states, located mostly in New England, we’ll restore a single leading zero. We’ll use `UPDATE` in conjunction with the double-pipe *string concatenation operator* (`||`). Concatenation combines two string values into one (it will also combine a string and a number into a string). For example, inserting `||` between the strings `abc` and `xyz` results in `abcxyz`. The double-pipe operator is a SQL standard for concatenation supported by PostgreSQL. You can use it in many contexts, such as `UPDATE` queries and `SELECT`, to provide custom output from existing as well as new data. First, Listing 10-15 makes a backup copy of the `zip` column as we did earlier. ``` ALTER TABLE meat_poultry_egg_establishments ADD COLUMN zip_copy text; UPDATE meat_poultry_egg_establishments SET zip_copy = zip; ``` Listing 10-15: Creating and filling the `zip_copy` column Next, we use the code in Listing 10-16 to perform the first update. ``` UPDATE meat_poultry_egg_establishments 1 SET zip = '00' || zip 2 WHERE st IN('PR','VI') AND length(zip) = 3; ``` Listing 10-16: Modifying codes in the `zip` column missing two leading zeros We use `SET` to set the value in the `zip` column 1 to the result of the concatenation of `00` and the existing value. We limit the `UPDATE` to only those rows where the `st` column has the state codes `PR` and `VI` 2 using the `IN` comparison operator from Chapter 3 and add a test for rows where the length of `zip` is `3`. This entire statement will then only update the `zip` values for Puerto Rico and the Virgin Islands. Run the query; PostgreSQL should return the message `UPDATE 86`, which is the number of rows we expect to change based on our earlier count in Listing 10-6. Let’s repair the remaining ZIP codes using a similar query in Listing 10-17. ``` UPDATE meat_poultry_egg_establishments SET zip = '0' || zip WHERE st IN('CT','MA','ME','NH','NJ','RI','VT') AND length(zip) = 4; ``` Listing 10-17: Modifying codes in the `zip` column missing one leading zero PostgreSQL should return the message `UPDATE 496`. Now, let’s check our progress. Earlier in Listing 10-6, when we aggregated rows in the `zip` column by length, we found `86` rows with three characters and `496` with four. Using the same query now returns a more desirable result: all the rows have a five-digit ZIP code. ``` length count ------ ----- 5 6287 ``` I’ll discuss additional string functions in Chapter 14 when we consider advanced techniques for working with text. ### Updating Values Across Tables In “Modifying Values with UPDATE” earlier in the chapter, I showed the standard ANSI SQL and PostgreSQL-specific syntax for updating values in one table based on values in another. This syntax is particularly valuable in a relational database where primary keys and foreign keys establish table relationships. In those cases, we may need information in one table to update values in another table. Let’s say we’re setting an inspection deadline for each of the companies in our table. We want to do this by US regions, such as Northeast, Pacific, and so on, but those regional designations don’t exist in our table. However, they *do* exist in the file *state_regions.csv*, included with the book’s resources, that contains matching `st` state codes. Once we load that file into a table, we can use that data in an `UPDATE` statement. Let’s begin with the New England region to see how this works. Enter the code in Listing 10-18, which contains the SQL statements to create a `state_regions` table and fill the table with data: ``` CREATE TABLE state_regions ( st text CONSTRAINT st_key PRIMARY KEY, region text NOT NULL ); COPY state_regions FROM '`C:\YourDirectory\`state_regions.csv' WITH (FORMAT CSV, HEADER); ``` Listing 10-18: Creating and filling a `state_regions` table We’ll create two columns in a `state_regions` table: one containing the two-character state code `st` and the other containing the `region` name. We set the primary key constraint to the `st` column, which holds a unique `st_key` value to identify each state. In the data you’re importing, each state is present and assigned to a census region, and territories outside the United States are labeled as outlying areas. We’ll update the table one region at a time. Next, let’s return to the `meat_poultry_egg_establishments` table, add a column for inspection dates, and then fill in that column with the New England states. Listing 10-19 shows the code. ``` ALTER TABLE meat_poultry_egg_establishments ADD COLUMN inspection_deadline timestamp with time zone; 1 UPDATE meat_poultry_egg_establishments establishments 2 SET inspection_deadline = '2022-12-01 00:00 EST' 3 WHERE EXISTS (SELECT state_regions.region FROM state_regions WHERE establishments.st = state_regions.st AND state_regions.region = 'New England'); ``` Listing 10-19: Adding and updating an `inspection_deadline` column The `ALTER TABLE` statement creates the `inspection_deadline` column in the `meat_poultry_egg_establishments` table. In the `UPDATE` statement, we give the table an alias of `establishments` to make the code easier to read 1 (and do so omitting the optional `AS` keyword). Next, `SET` assigns a timestamp value of `2022-12-01 00:00 EST` to the new `inspection_deadline` column 2. Finally, `WHERE EXISTS` includes a subquery that connects the `meat_poultry_egg_establishments` table to the `state_regions` table we created in Listing 10-18 and specifies which rows to update 3. The subquery (in parentheses, beginning with `SELECT`) looks for rows in the `state_regions` table where the `region` column matches the string `New England`. At the same time, it joins the `meat_poultry_egg_establishments` table with the `state_regions` table using the `st` column from both tables. In effect, the query is telling the database to find all the `st` codes that correspond to the New England region and use those codes to filter the update. When you run the code, you should receive a message of `UPDATE 252`, which is the number of companies in New England states. You can use the code in Listing 10-20 to see the effect of the change. ``` SELECT st, inspection_deadline FROM meat_poultry_egg_establishments GROUP BY st, inspection_deadline ORDER BY st; ``` Listing 10-20: Viewing updated `inspection_date` values The results should show the updated inspection deadlines for all New England companies. The top of the output shows Connecticut has received a deadline timestamp, for example, but states outside New England remain `NULL` because we haven’t updated them yet: ``` st inspection_deadline -- --------------------- `--snip--` CA CO CT 2022-12-01 00:00:00-05 DC `--snip--` ``` To fill in deadlines for additional regions, substitute a different region for `New England` in Listing 10-19 and rerun the query. ## Deleting Unneeded Data The most irrevocable way to modify data is to remove it entirely. SQL includes options to remove rows and columns along with options to delete an entire table or database. We want to perform these operations with caution, removing only data or tables we don’t need. Without a backup, your data is gone for good. In this section, we’ll use a variety of SQL statements to delete data. If you didn’t back up the `meat_poultry_egg_establishments` table using Listing 10-8, now is a good time to do so. Writing and executing these statements is fairly simple, but doing so comes with a caveat. If deleting rows, a column, or a table would cause a violation of a constraint, such as the foreign key constraint covered in Chapter 8, you need to deal with that constraint first. That might involve removing the constraint, deleting data in another table, or deleting another table. Each case is unique and will require a different way to work around the constraint. ### Deleting Rows from a Table To remove rows from a table, we can use either `DELETE FROM` or `TRUNCATE`, which are both part of the ANSI SQL standard. Each offers options that are useful depending on your goals. Using `DELETE FROM`, we can remove all rows from a table, or we can add a `WHERE` clause to delete only the portion that matches an expression we supply. To delete all rows from a table, use the following syntax: ``` DELETE FROM `table_name`; ``` To remove only selected rows, add a `WHERE` clause along with the matching value or pattern to specify which ones you want to delete: ``` DELETE FROM `table_name` WHERE `expression`; ``` For example, to exclude US territories from our processors table, we can remove the companies in those locations using the code in Listing 10-21. ``` DELETE FROM meat_poultry_egg_establishments WHERE st IN('AS','GU','MP','PR','VI'); ``` Listing 10-21: Deleting rows matching an expression Run the code; PostgreSQL should return the message `DELETE 105`. This means the 105 rows where the `st` column held any of the codes designating a territory that you supplied via the `IN` keyword have been removed from the table. With large tables, using `DELETE FROM` to remove all rows can be inefficient because it scans the entire table as part of the process. In that case, you can use `TRUNCATE`, which skips the scan. To empty the table using `TRUNCATE`, use the following syntax: ``` TRUNCATE `table_name`; ``` A handy feature of `TRUNCATE` is the ability to reset an `IDENTITY` sequence, such as one you may have created to serve as a surrogate primary key, as part of the operation. To do that, add the `RESTART IDENTITY` keywords to the statement: ``` TRUNCATE `table_name` RESTART IDENTITY; ``` We’ll skip truncating any tables for now as we need the data for the rest of the chapter. ### Deleting a Column from a Table Earlier we created a backup `zip` column called `zip_copy`. Now that we’ve finished working on fixing the issues in `zip`, we no longer need `zip_copy`. We can remove the backup column, including all the data within the column, from the table using the `DROP` keyword in the `ALTER TABLE` statement. The syntax for removing a column is similar to other `ALTER TABLE` statements: ``` ALTER TABLE `table_name` DROP COLUMN `column_name`; ``` The code in Listing 10-22 removes the `zip_copy` column: ``` ALTER TABLE meat_poultry_egg_establishments DROP COLUMN zip_copy; ``` Listing 10-22: Removing a column from a table using `DROP` PostgreSQL returns the message `ALTER TABLE`, and the `zip_copy` column should be deleted. The database doesn’t actually rewrite the table to remove the column; it just marks the column as deleted in its internal catalog and no longer shows it or adds data to it when new rows are added. ### Deleting a Table from a Database The `DROP TABLE` statement is a standard ANSI SQL feature that deletes a table from the database. This statement might come in handy if, for example, you have a collection of backups, or *working tables*, that have outlived their usefulness. It’s also useful when you need to change the structure of a table significantly; in that case, rather than using too many `ALTER TABLE` statements, you can just remove the table and create a fresh one by running a new `CREATE TABLE` statement and re-importing the data. The syntax for the `DROP TABLE` command is simple: ``` DROP TABLE `table_name`; ``` For example, Listing 10-23 deletes the backup version of the `meat_poultry_egg_establishments` table. ``` DROP TABLE meat_poultry_egg_establishments_backup; ``` Listing 10-23: Removing a table from a database using `DROP` Run the query; PostgreSQL should respond with the message `DROP TABLE` to indicate the table has been removed. ## Using Transactions to Save or Revert Changes So far, our alterations in this chapter have been final. That is, after you run a `DELETE` or `UPDATE` query (or any other query that alters your data or database structure), the only way to undo the change is to restore from a backup. However, there is a way to check your changes before finalizing them and cancel the change if it’s not what you intended. You do this by enclosing the SQL statement within a *transaction*, which includes keywords that allow you to commit your changes if they are successful or roll them back if not. You define a transaction using the following keywords at the beginning and end of the query: 1. `START TRANSACTION` Signals the start of the transaction block. In PostgreSQL, you can also use the non-ANSI SQL `BEGIN` keyword. 2. `COMMIT` Signals the end of the block and saves all changes. 3. `ROLLBACK` Signals the end of the block and reverts all changes. You can include multiple statements between `BEGIN` and `COMMIT` to define a sequence of operations that perform one unit of work in a database. An example is when you buy concert tickets, which might involve two steps: charging your credit card and reserving your seats so someone else can’t buy them. A database programmer would want either both steps in the transaction to happen (say, when your card charge goes through) or neither to happen (if you cancel at checkout). Defining both steps as one transaction—also called a *transaction block*—keeps them as a unit; if one step is canceled or throws an error, the other gets canceled too. You can learn more details about transactions and PostgreSQL at [`www.postgresql.org/docs/current/tutorial-transactions.html`](https://www.postgresql.org/docs/current/tutorial-transactions.html). We can use a transaction block to review changes a query makes and then decide whether to keep or discard them. In our table, let’s say we’re cleaning dirty data related to the company AGRO Merchants Oakland LLC. The table has three rows listing the company, but one row has an extra comma in the name: ``` Company --------------------------- AGRO Merchants Oakland LLC AGRO Merchants Oakland LLC AGRO Merchants Oakland, LLC ``` We want the name to be consistent, so we’ll remove the comma from the third row using an `UPDATE` query, as we did earlier. But this time we’ll check the result of our update before we make it final (and we’ll purposely make a mistake we want to discard). Listing 10-24 shows how to do this using a transaction block. ``` 1 START TRANSACTION; UPDATE meat_poultry_egg_establishments 2 SET company = 'AGRO Merchantss Oakland LLC' WHERE company = 'AGRO Merchants Oakland, LLC'; 3 SELECT company FROM meat_poultry_egg_establishments WHERE company LIKE 'AGRO%' ORDER BY company; 4 ROLLBACK; ``` Listing 10-24: Demonstrating a transaction block Beginning with `START TRANSACTION;` 1, we’ll run each statement separately. The database responds with the message `START TRANSACTION`, letting you know that any succeeding changes you make to data will not be made permanent unless you issue a `COMMIT` command. Next, we run the `UPDATE` statement, which changes the company name in the row where it has an extra comma. I intentionally added an extra `s` in the name used in the `SET` clause 2 to introduce a mistake. When we view the names of companies starting with the letters `AGRO` using the `SELECT` statement 3, we see that, oops, one company name is misspelled now. ``` Company --------------------------- AGRO Merchants Oakland LLC AGRO Merchants Oakland LLC AGRO Merchantss Oakland LLC ``` Instead of rerunning the `UPDATE` statement to fix the typo, we can simply discard the change by running the `ROLLBACK;` 4 command. When we rerun the `SELECT` statement to view the company names, we’re back to where we started: ``` Company --------------------------- AGRO Merchants Oakland LLC AGRO Merchants Oakland LLC AGRO Merchants Oakland, LLC ``` From here, you correct your `UPDATE` statement by removing the extra `s` and rerun it, beginning with the `START TRANSACTION` statement again. If you’re happy with the changes, run `COMMIT;` to make them permanent. Transaction blocks are often used for more complex situations rather than checking simple changes. Here you’ve used them to test whether a query behaves as desired, saving you time and headaches. Next, let’s look at another way to save time when updating lots of data. ## Improving Performance When Updating Large Tables With PostgreSQL, adding a column to a table and filling it with values can quickly inflate the table’s size because the database creates a new version of the existing row each time a value is updated, but it doesn’t delete the old row version. That essentially doubles the table’s size. (You’ll learn how to clean up these old rows when I discuss database maintenance in “Recovering Unused Space with VACUUM” in Chapter 19.) For small datasets, the increase is negligible, but for tables with hundreds of thousands or millions of rows, the time required to update rows and the resulting extra disk usage can be substantial. Instead of adding a column and filling it with values, we can save disk space by copying the entire table and adding a populated column during the operation. Then, we rename the tables so the copy replaces the original, and the original becomes a backup. Thus, we have a fresh table without the added old rows. Listing 10-25 shows how to copy `meat_poultry_egg_establishments` into a new table while adding a populated column. To do this, if you didn’t already drop the `meat_poultry_egg_establishments_backup` table as shown in Listing 10-23, go ahead and drop it. Then run the `CREATE TABLE` statement. ``` CREATE TABLE meat_poultry_egg_establishments_backup AS 1 SELECT *, 2 '2023-02-14 00:00 EST'::timestamp with time zone AS reviewed_date FROM meat_poultry_egg_establishments; ``` Listing 10-25: Backing up a table while adding and filling a new column The query is a modified version of the backup script in Listing 10-8. Here, in addition to selecting all the columns using the asterisk wildcard 1, we also add a column called `reviewed_date` by providing a value cast as a `timestamp` data type 2 and the `AS` keyword. That syntax adds and fills `reviewed_date`, which we might use to track the last time we checked the status of each plant. Then we use Listing 10-26 to swap the table names. ``` 1 ALTER TABLE meat_poultry_egg_establishments RENAME TO meat_poultry_egg_establishments_temp; 2 ALTER TABLE meat_poultry_egg_establishments_backup RENAME TO meat_poultry_egg_establishments; 3 ALTER TABLE meat_poultry_egg_establishments_temp RENAME TO meat_poultry_egg_establishments_backup; ``` Listing 10-26: Swapping table names using `ALTER TABLE` Here we use `ALTER TABLE` with a `RENAME TO` clause to change a table name. The first statement changes the original table name to one that ends with `_temp` 1. The second statement renames the copy we made with Listing 10-24 to the original name of the table 2. Finally, we rename the table that ends with `_temp` to the ending `_backup` 3. The original table is now called `meat_poultry_egg_establishments_backup`, and the copy with the added column is called `meat_poultry_egg_establishments`. This process avoids updating rows and thus inflating the table. ## Wrapping Up Gleaning useful information from data sometimes requires modifying the data to remove inconsistencies, fix errors, and make it more suitable for supporting an accurate analysis. In this chapter you learned some useful tools to help you assess dirty data and clean it up. In a perfect world, all datasets would arrive with everything clean and complete. But such a perfect world doesn’t exist, so the ability to alter, update, and delete data is indispensable. Let me restate the important tasks of working safely. Be sure to back up your tables before you start making changes. Make copies of your columns, too, for an extra level of protection. When I discuss database maintenance for PostgreSQL later in the book, you’ll learn how to back up entire databases. These few steps of precaution will save you a world of pain. In the next chapter, we’ll return to math to explore some of SQL’s advanced statistical functions and techniques for analysis.
第十一章:SQL 中的统计函数

在本章中,我们将探索 SQL 统计函数以及使用它们的指南。当数据分析师需要做的不仅仅是计算总和和平均值时,SQL 数据库通常不是他们的首选工具。通常,首选的软件是功能齐全的统计软件包,如 SPSS 或 SAS,编程语言 R 或 Python,甚至是 Excel。但你不必忽视你的数据库。标准的 ANSI SQL,包括 PostgreSQL 的实现,提供了强大的统计函数和功能,可以在不需要将数据集导出到其他程序的情况下,揭示数据的许多信息。
统计学是一个广泛的学科,值得拥有一本自己的书,因此我们这里只是略微触及表面。不过,你将学会如何应用高级统计概念,通过使用来自美国人口普查局的新数据集,帮助你从数据中提取有意义的信息。你还将学会使用 SQL 创建排名,利用有关商业企业的数据计算比率,并通过滚动平均和总和平滑时间序列数据。
创建人口普查统计表
让我们回到我最喜欢的数据源之一——美国人口普查局。这次,你将使用来自 2014-2018 年美国社区调查(ACS)5 年估算的县级数据,这是该局的另一个产品。
使用 Listing 11-1 中的代码创建 acs_2014_2018_stats 表格,并导入 acs_2014_2018_stats.csv CSV 文件。代码和数据可以通过nostarch.com/practical-sql-2nd-edition/获得,所有书籍资源都可以在该网站上找到。记得将 C:\YourDirectory\ 更改为 CSV 文件的位置。
CREATE TABLE acs_2014_2018_stats (
1 geoid text CONSTRAINT geoid_key PRIMARY KEY,
county text NOT NULL,
st text NOT NULL,
2 pct_travel_60_min numeric(5,2),
pct_bachelors_higher numeric(5,2),
pct_masters_higher numeric(5,2),
median_hh_income integer,
3 CHECK (pct_masters_higher <= pct_bachelors_higher)
);
COPY acs_2014_2018_stats
FROM '`C:\YourDirectory\`acs_2014_2018_stats.csv'
WITH (FORMAT CSV, HEADER);
4 SELECT * FROM acs_2014_2018_stats;
Listing 11-1:创建 2014-2018 年 ACS 5 年估算表并导入数据
acs_2014_2018_stats 表格有七列。前三列包括一个唯一的 geoid,作为主键,county(县)名称,以及 st(州)名称。county 和 st 都带有 NOT NULL 约束,因为每行应该包含一个值。接下来的四列显示我从 ACS 发布的估算数据中为每个县导出的某些百分比数据,还有一个经济指标:
pct_travel_60_min
- 16 岁及以上的工人中,通勤时间超过 60 分钟的百分比。
pct_bachelors_higher
- 25 岁及以上人群中,教育水平为学士学位及以上的百分比。(在美国,学士学位通常是在完成四年制大学教育后授予的。)
pct_masters_higher
- 25 岁及以上人群中,教育水平为硕士及以上的百分比。(在美国,硕士学位是在完成本科学位后获得的第一个高级学位。)
median_hh_income
- 该县 2018 年通货膨胀调整后的家庭收入中位数。如第六章所述,中位数是一个有序数字集合中的中点,半数值大于中点,半数值小于中点。由于平均值可能会受到少数极大或极小值的影响,政府在报告经济数据(如收入)时通常使用中位数。
我们包括了一个CHECK约束 3,确保学士学位的数值等于或高于硕士学位的数值,因为在美国,学士学位通常是在硕士学位之前或与硕士学位同时获得。显示相反情况的县可能表明数据导入不正确或列标签错误。我们的数据经过检查:导入时,未发现任何违反CHECK约束的错误。
我们使用SELECT语句 4 来查看所有导入的 3,142 行数据,每一行都对应于本次人口普查发布中调查的一个县。
接下来,我们将使用 SQL 中的统计函数来更好地理解百分比之间的关系。
使用 corr(Y, X)测量相关性
相关性描述了两个变量之间的统计关系,衡量其中一个变量变化与另一个变量变化之间的关联程度。在本节中,我们将使用 SQL 中的corr(``Y``, X``)函数来衡量县中获得学士学位的百分比与该县家庭收入中位数之间是否存在关系。如果存在,我们还将确定根据数据,受过更好教育的人口是否通常意味着更高的收入,并且如果是,关系的强度如何。
首先,提供一些背景信息。皮尔逊相关系数(通常表示为r)衡量两个变量之间线性关系的强度和方向。具有强线性关系的变量在散点图上绘制时会聚集在一条直线上。r的皮尔逊值介于−1 和 1 之间;范围的任一端表示完全相关,而接近零的值则表示几乎没有相关性,数据呈现随机分布。正的r值表示正相关关系:当一个变量增加时,另一个变量也增加。绘制时,代表每对值的数据点会从左到右呈上升趋势。负的r值表示反向 关系:当一个变量增加时,另一个变量减少。反向关系的点会在散点图上从左到右呈下降趋势。
表格 11-1 提供了关于正负r值的一般解释指南,尽管不同的统计学家可能会提供不同的解释。
表格 11-1:解读相关系数
| 相关系数 (+/−) | 它可能意味着什么 |
|---|---|
| 0 | 无关系 |
| .01 到.29 | 弱关系 |
| .3 到.59 | 中等关系 |
| .6 到 .99 | 强关系到几乎完美关系 |
| 1 | 完美关系 |
在标准的 ANSI SQL 和 PostgreSQL 中,我们通过 corr(``Y``, X``) 来计算 Pearson 相关系数。这是 SQL 中几种 二元聚合函数 之一,之所以如此命名,是因为这些函数接受两个输入。输入 Y 是 因变量,其变化依赖于另一个变量的值,而 X 是 自变量,其值不依赖于其他变量。
我们将使用 corr(``Y``, X``) to discover the relationship between education level and income, with income as our dependent variable and education as our independent variable. Enter the code in Listing 11-2 to use `corr(``Y``,` `X``)` ``with `median_hh_income` and `pct_bachelors_higher` as inputs.``
``````` ``` SELECT corr(median_hh_income, pct_bachelors_higher) AS bachelors_income_r FROM acs_2014_2018_stats; ``` Listing 11-2: Using `corr(``Y``,` `X``)` to measure the relationship between education and income Run the query; your result should be an *r* value of just below 0.70 given as the floating-point `double precision` data type: ``` bachelors_income_r ------------------ 0.6999086502599159 ``` This positive *r* value indicates that as a county’s educational attainment increases, household income tends to increase. The relationship isn’t perfect, but the *r* value shows the relationship is fairly strong. We can visualize this pattern by plotting the variables on a scatterplot using Excel, as shown in Figure 11-1. Each data point represents one US county; the data point’s position on the x-axis shows the percentage of the population ages 25 and older that has a bachelor’s degree or higher. The data point’s position on the y-axis represents the county’s median household income.  Figure 11-1: A scatterplot showing the relationship between education and income Notice that although most of the data points are grouped together in the bottom-left corner of the graph, they do generally slope upward from left to right. Also, the points spread out rather than strictly follow a straight line. If they were in a straight line sloping up from left to right, the *r* value would be 1, indicating a perfect positive linear relationship. ### Checking Additional Correlations Now let’s calculate the correlation coefficients for the remaining variable pairs using the code in Listing 11-3. ``` SELECT 1 round( corr(median_hh_income, pct_bachelors_higher)::numeric, 2 ) AS bachelors_income_r, round( corr(pct_travel_60_min, median_hh_income)::numeric, 2 ) AS income_travel_r, round( corr(pct_travel_60_min, pct_bachelors_higher)::numeric, 2 ) AS bachelors_travel_r FROM acs_2014_2018_stats; ``` Listing 11-3: Using `corr(``Y``,` `X``)` on additional variables This time we’ll round off the decimal values to make the output more readable by wrapping the `corr(``Y``,` `X``)` function inside SQL’s `round()` function 1, which takes two inputs: the `numeric` value to be rounded and an `integer` value indicating the number of decimal places to round the first value. If the second parameter is omitted, the value is rounded to the nearest whole integer. Because `corr(``Y``,` `X``)` ``returns a floating-point value by default, we cast it to the `numeric` type using the `::` notation you learned in Chapter 4. Here’s the output:`` `````` ``` bachelors_income_r income_travel_r bachelors_travel_r ------------------ --------------- ------------------ 0.70 0.06 -0.14 ``` The `bachelors_income_r` value is `0.70`, which is the same as our first run but rounded up to two decimal places. Compared to `bachelors_income_r`, the other two correlations are weak. The `income_travel_r` value shows that the correlation between income and the percentage of those who commute more than an hour to work is practically zero. This indicates that a county’s median household income bears little connection to how long it takes people to get to work. The `bachelors_travel_r` value shows that the correlation of bachelor’s degrees and lengthy commutes is also low at `-0.14`. The negative value indicates an inverse relationship: as education increases, the percentage of the population that travels more than an hour to work decreases. Although this is interesting, a correlation coefficient that is this close to zero indicates a weak relationship. When testing for correlation, we need to note some caveats. The first is that even a strong correlation does not imply causality. We can’t say that a change in one variable causes a change in the other, only that the changes move together. The second is that correlations should be subject to testing to determine whether they’re statistically significant. Those tests are beyond the scope of this book but worth studying on your own. Nevertheless, the SQL `corr(``Y``,` `X``)` `function is a handy tool for quickly checking correlations between variables.` ````` ### Predicting Values with Regression Analysis Researchers also want to predict values using available data. For example, let’s say 30 percent of a county’s population has a bachelor’s degree or higher. Given the trend in our data, what would we expect that county’s median household income to be? Likewise, for each percent increase in education, how much increase, on average, would we expect in income? We can answer both questions using *linear regression*. Simply put, the regression method finds the best linear equation, or straight line, that describes the relationship between an independent variable (such as education) and a dependent variable (such as income). We can then look at points along this line to predict values where we don’t have observations. Standard ANSI SQL and PostgreSQL include functions that perform linear regression. Figure 11-2 shows our previous scatterplot with a regression line added.  Figure 11-2: Scatterplot with least squares regression line showing the relationship between education and income The straight line running through the middle of all the data points is called the *least squares regression line*, which approximates the “best fit” for a straight line that best describes the relationship between the variables. The equation for the regression line is like the *slope-intercept* formula you might remember from high school math but written using differently named variables: *Y* = *bX* + *a*. Here are the formula’s components: 1. **Y** is the predicted value, which is also the value on the y-axis, or dependent variable. 2. **b** is the slope of the line, which can be positive or negative. It measures how many units the y-axis value will increase or decrease for each unit of the x-axis value. 3. **X** represents a value on the x-axis, or independent variable. 4. **a** is the y-intercept, the value at which the line crosses the y-axis when the *X* value is zero. Let’s apply this formula using SQL. Earlier, we questioned the expected median household income in a county where than 30 percent or more of the population had a bachelor’s degree. In our scatterplot, the percentage with bachelor’s degrees falls along the x-axis, represented by *X* in the calculation. Let’s plug that value into the regression line formula in place of *X*: *Y* = *b*(30) + *a* To calculate *Y*, which represents the predicted median household income, we need the line’s slope, *b*, and the y-intercept, *a*. To get these values, we’ll use the SQL functions `regr_slope(``Y``,` `X``)` and `regr_intercept(``Y``,` `X``)`, as shown in Listing 11-4. ``` SELECT round( regr_slope(median_hh_income, pct_bachelors_higher)::numeric, 2 ) AS slope, round( regr_intercept(median_hh_income, pct_bachelors_higher)::numeric, 2 ) AS y_intercept FROM acs_2014_2018_stats; ``` Listing 11-4: Regression slope and intercept functions Using the `median_hh_income` and `pct_bachelors_higher` variables as inputs for both functions, we’ll set the resulting value of the `regr_slope(``Y``,` `X``)` function as `slope` and the output for the `regr_intercept(``Y``,` `X``)` function as `y_intercept`. Run the query; the result should show the following: ``` slope y_intercept ------- ----------- 1016.55 29651.42 ``` The `slope` value shows that for every one-unit increase in bachelor’s degree percentage, we can expect a county’s median household income will increase by $1,016.55\. The `y_intercept` value shows that when the regression line crosses the y-axis, where the percentage with bachelor’s degrees is at 0, the y-axis value is 29,651.42\. Now let’s plug both values into the equation to get our predicted value *Y*: *Y* = 1016.55(30) + 29651.42 *Y* = 60147.92 Based on our calculation, in a county in which 30 percent of people age 25 and older have a bachelor’s degree or higher, we can expect a median household income to be about $60,148\. Of course, our data includes counties whose median income falls above and below that predicted value, but we expect this to be the case because our data points in the scatterplot don’t line up perfectly along the regression line. Recall that the correlation coefficient we calculated was 0.70, indicating a strong but not perfect relationship between education and income. Other factors likely contributed to variations in income, such as the types of jobs available in each county. ### Finding the Effect of an Independent Variable with r-Squared Beyond determining the direction and strength of the relationship between two variables, we can also calculate the extent that the variation in the *x* (independent) variable explains the variation in the *y* (dependent) variable. To do this we square the *r* value to find the *coefficient of determination*, better known as *r-squared*. An *r*-squared indicates the percentage of the variation that is explained by the independent variable, and is a value between zero and one. For example, if *r*-squared equals 0.1, we would say that the independent variable explains 10 percent of the variation in the dependent variable, or not much at all. To find *r*-squared, we use the `regr_r2(``Y``,` `X``)` function in SQL. Let’s apply it to our education and income variables using the code in Listing 11-5. ``` SELECT round( regr_r2(median_hh_income, pct_bachelors_higher)::numeric, 3 ) AS r_squared FROM acs_2014_2018_stats; ``` Listing 11-5: Calculating the coefficient of determination, or *r*-squared This time we’ll round off the output to the nearest thousandth place and alias the result to `r_squared`. The query should return the following result: ``` r_squared --------- 0.490 ``` The *r*-squared value of `0.490` indicates that about 49 percent of the variation in median household income among counties can be explained by the percentage of people with a bachelor’s degree or higher in that county. Any number of factors could explain the other 51 percent, and statisticians will typically test numerous combinations of variables to determine what they are. Before you use these numbers in a headline or presentation, it’s worth revisiting the following points: * Correlation doesn’t prove causality. For verification, do a Google search on “correlation and causality.” Many variables correlate well but have no meaning. (See [`www.tylervigen.com/spurious-correlations`](https://www.tylervigen.com/spurious-correlations) for examples of correlations that don’t prove causality, including the correlation between divorce rate in Maine and margarine consumption.) Statisticians usually perform *significance testing* on the results to make sure values are not simply the result of randomness. * Statisticians also apply additional tests to data before accepting the results of a regression analysis, including whether the variables follow the standard bell curve distribution and meet other criteria for a valid result. Let’s explore two additional concepts before wrapping up our look at statistical functions. ### Finding Variance and Standard Deviation *Variance* and *standard deviation* describe the degree to which a set of values varies from the average of those values. Variance, often used in finance, is the average of each number’s squared distance from the average. The more dispersion in a set of values, the greater the variance. A stock market trader can use variance to measure the volatility of a particular stock—how much its daily closing values tend to vary from the average. That could indicate how risky an investment the stock might be. Standard deviation is the square root of the variance and is most useful for assessing data whose values form a normal distribution, usually visualized as a symmetrical *bell curve*. In a *normal distribution*, about two-thirds of values fall within one standard deviation of the average; 95 percent are within two standard deviations. The standard deviation of a set of values, therefore, helps us understand how close most of our values are to the average. For example, consider a study that found the average height of adult US women is about 65.5 inches with a standard deviation of 2.5 inches. Given that heights are normally distributed, that means about two-thirds of women are within 2.5 inches of the average, or 63 inches to 68 inches tall. When calculating variance and standard deviation, note that they report different units. Standard deviation is expressed in the same units as the values, while variance is not—it reports a number that is larger than the units, on a scale of its own. These are the functions for calculating variance: 1. `var_pop(numeric)` Calculates the population variance of the input values. In this context, *population* refers to a dataset that contains all possible values, as opposed to a sample that just contains a portion of all possible values. 2. `var_samp(numeric)` Calculates the sample variance of the input values. Use this with data that is sampled from a population, as in a random sample survey. For calculating standard deviation, we use these: 1. `stddev_pop(numeric)` Calculates the population standard deviation. 2. `stddev_samp(numeric)` `Calculates the sample standard deviation.` ````With functions covering correlation, regression, and other descriptive statistics, you have a basic toolkit for obtaining a preliminary survey of your data before doing more rigorous analysis. All these topics are worth in-depth study to better understand when you might use them and what they measure. A classic, easy-to-understand resource I recommend is the book *Statistics* by David Freedman, Robert Pisani, and Roger Purves. ## Creating Rankings with SQL Rankings make the news often. You’ll see them used anywhere from weekend box-office charts to sports teams’ league standings. With SQL you can create numbered rankings in your query results, which are useful for tasks such as tracking changes over several years. You can also simply use a ranking as a fact on its own in a report. Let’s explore how to create rankings using SQL. ### Ranking with rank() and dense_rank() Standard ANSI SQL includes several ranking functions, but we’ll just focus on two: `rank()` and `dense_rank()`. Both are *window functions*, which are defined as functions that perform calculations across a set of rows relative to the current row. Unlike aggregate functions, which combine rows to calculate values, with window functions the query first generates a set of rows, and then the window function runs across the result set to calculate the value it will return. The difference between `rank()` and `dense_rank()` is the way they handle the next rank value after a tie: `rank()` includes a gap in the rank order, but `dense_rank()` does not. This concept is easier to understand in action, so let’s look at an example. Consider a Wall Street analyst who covers the highly competitive widget manufacturing market. The analyst wants to rank companies by their annual output. The SQL statements in Listing 11-6 create and fill a table with this data and then rank the companies by widget output. ``` CREATE TABLE widget_companies ( id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY, company text NOT NULL, widget_output integer NOT NULL ); INSERT INTO widget_companies (company, widget_output) VALUES ('Dom Widgets', 125000), ('Ariadne Widget Masters', 143000), ('Saito Widget Co.', 201000), ('Mal Inc.', 133000), ('Dream Widget Inc.', 196000), ('Miles Amalgamated', 620000), ('Arthur Industries', 244000), ('Fischer Worldwide', 201000); SELECT company, widget_output, 1 rank() OVER (ORDER BY widget_output DESC), 2 dense_rank() OVER (ORDER BY widget_output DESC) FROM widget_companies ORDER BY widget_output DESC; ``` Listing 11-6: Using the `rank()` and `dense_rank()` window functions Notice the syntax in the `SELECT` statement that includes `rank()` 1 and `dense_rank()` 2. After the function names, we use the `OVER` clause and in parentheses place an expression that specifies the “window” of rows the function should operate on. The *window* is the set of rows relative to the current row, and in this case, we want both functions to work on all rows of the `widget_output` column, sorted in descending order. Here’s the output: ``` company widget_output rank dense_rank -------------------------- ------------- ---- ---------- Miles Amalgamated 620000 1 1 Arthur Industries 244000 2 2 Fischer Worldwide 201000 3 3 Saito Widget Co. 201000 3 3 Dream Widget Inc. 196000 5 4 Ariadne Widget Masters 143000 6 5 Mal Inc. 133000 7 6 Dom Widgets 125000 8 7 ``` The columns produced by `rank()` and `dense_rank()` show each company’s ranking based on the `widget_output` value from highest to lowest, with Miles Amalgamated at number one. To see how `rank()` and `dense_rank()` differ, check the fifth-row listing, Dream Widget Inc. With `rank()`, Dream Widget Inc. is the fifth-highest-ranking company. Because `rank()` allows a gap in the order when a tie occurs, Dream placing fifth tells us there are four companies with more output. In contrast, `dense_rank()` doesn’t allow a gap in the rank order so it places Dream Widget Inc. in fourth place. This reflects the fact that Dream has the fourth-highest widget output regardless of how many companies produced more. Both ways of handling ties have merit, but in practice `rank()` is used most often. It’s also what I recommend using, because it more accurately reflects the total number of companies ranked, shown by the fact that Dream Widget Inc. has four companies ahead of it in total output, not three. Let’s look at a more complex ranking example. ### Ranking Within Subgroups with PARTITION BY The ranking we just did was a simple overall ranking based on widget output. But sometimes you’ll want to produce ranks within groups of rows in a table. For example, you might want to rank government employees by salary within each department or rank movies by box-office earnings within each genre. To use window functions in this way, we’ll add `PARTITION BY` to the `OVER` clause. A `PARTITION BY` clause divides table rows according to values in a column we specify. Here’s an example using made-up data about grocery stores. Enter the code in Listing 11-7 to fill a table called `store_sales`. ``` CREATE TABLE store_sales ( store text NOT NULL, category text NOT NULL, unit_sales bigint NOT NULL, CONSTRAINT store_category_key PRIMARY KEY (store, category) ); INSERT INTO store_sales (store, category, unit_sales) VALUES ('Broders', 'Cereal', 1104), ('Wallace', 'Ice Cream', 1863), ('Broders', 'Ice Cream', 2517), ('Cramers', 'Ice Cream', 2112), ('Broders', 'Beer', 641), ('Cramers', 'Cereal', 1003), ('Cramers', 'Beer', 640), ('Wallace', 'Cereal', 980), ('Wallace', 'Beer', 988); SELECT category, store, unit_sales, 1 rank() OVER (PARTITION BY category ORDER BY unit_sales DESC) FROM store_sales 2 ORDER BY category, rank() OVER (PARTITION BY category ORDER BY unit_sales DESC); ``` Listing 11-7: Applying `rank()` within groups using `PARTITION BY` In the table, each row includes a store’s product category and sales for that category. The final `SELECT` statement creates a result set showing how each store’s sales ranks within each category. The new element is the addition of `PARTITION BY` in the `OVER` clause 1. In effect, the clause tells the program to create rankings one category at a time, using the store’s unit sales in descending order. To display the results by category and rank, we add an `ORDER BY` clause 2 that includes the `category` column and the same `rank()` function syntax. Here’s the output: ``` category store unit_sales rank --------- ------- ---------- ---- Beer Wallace 988 1 Beer Broders 641 2 Beer Cramers 640 3 Cereal Broders 1104 1 Cereal Cramers 1003 2 Cereal Wallace 980 3 Ice Cream Broders 2517 1 Ice Cream Cramers 2112 2 Ice Cream Wallace 1863 3 ``` Rows for each category are ordered by category unit sales with the `rank` column displaying the ranking. Using this table, we can see at a glance how each store ranks in a food category. For instance, Broders tops sales for cereal and ice cream, but Wallace wins in the beer category. You can apply this concept to many other scenarios: for each auto manufacturer, finding the vehicle with the most consumer complaints; figuring out which month had the most rainfall in each of the last 20 years; finding the team with the most wins against left-handed pitchers; and so on. ## Calculating Rates for Meaningful Comparisons Rankings based on raw counts aren’t always meaningful; in fact, they can be misleading. Consider birth statistics: the US National Center for Health Statistics (NCHS) reported that in 2019, there were 377,599 babies born in the state of Texas and 46,826 born in the state of Utah. So, women in Texas are more likely to have babies, right? Not so fast. In 2019, Texas’ estimated population was nine times as much as Utah’s. Given that context, comparing the plain number of births in the two states isn’t very meaningful. A more accurate way to compare these numbers is to convert them to rates. Analysts often calculate a rate per 1,000 people, or some multiple of that number, to allow an apples-to-apples comparison. For example, the fertility rate—the number of births per 1,000 women ages 15 to 44—was 62.5 for Texas in 2019 and 66.7 for Utah, according to the NCHS. So, despite the smaller number of births, on a per-1,000 rate, women in Utah actually had more children. The math behind this is simple. Let’s say your town had 115 births and a population of 2,200 women ages 15 to 44\. You can find the per-1,000 rate as follows: (115 / 2,200) × 1,000 = 52.3 In your town, there were 52.3 births per 1,000 women ages 15 to 44, which you can now compare to other places regardless of their size. ### Finding Rates of Tourism-Related Businesses Let’s try calculating rates using SQL and census data. We’ll join two tables: the census population estimates you imported in Chapter 5 plus data I compiled about tourism-related businesses from the census’ County Business Patterns program. (You can read about the program methodology at [`www.census.gov/programs-surveys/cbp/about.html`](https://www.census.gov/programs-surveys/cbp/about.html).) Listing 11-8 contains the code to create and fill the business patterns table. Remember to point the script to the location in which you’ve saved the CSV file *cbp_naics_72_establishments.csv*, which you can download from GitHub via the link at [`nostarch.com/practical-sql-2nd-edition/`](https://nostarch.com/practical-sql-2nd-edition/). ``` CREATE TABLE cbp_naics_72_establishments ( state_fips text, county_fips text, county text NOT NULL, st text NOT NULL, naics_2017 text NOT NULL, naics_2017_label text NOT NULL, year smallint NOT NULL, establishments integer NOT NULL, CONSTRAINT cbp_fips_key PRIMARY KEY (state_fips, county_fips) ); COPY cbp_naics_72_establishments FROM '`C:\YourDirectory\`cbp_naics_72_establishments.csv' WITH (FORMAT CSV, HEADER); SELECT * FROM cbp_naics_72_establishments ORDER BY state_fips, county_fips LIMIT 5; ``` Listing 11-8: Creating and filling a table for census county business pattern data Once you’ve imported the data, run the final `SELECT` statement to view the first few rows of the table. Each row contains descriptive information about a county along with the number of business establishments that fall under code 72 of the North American Industry Classification System (NAICS). Code 72 covers “Accommodation and Food Services” establishments, mainly hotels, inns, bars, and restaurants. The number of those businesses in a county is a good proxy for the amount of tourist and recreation activity in the area. Let’s find out which counties have the highest concentration of such businesses per 1,000 population, using the code in Listing 11-9. ``` SELECT cbp.county, cbp.st, cbp.establishments, pop.pop_est_2018, 1 round( (cbp.establishments::numeric / pop.pop_est_2018) * 1000, 1 ) AS estabs_per_1000 FROM cbp_naics_72_establishments cbp JOIN us_counties_pop_est_2019 pop ON cbp.state_fips = pop.state_fips AND cbp.county_fips = pop.county_fips 2 WHERE pop.pop_est_2018 >= 50000 ORDER BY cbp.establishments::numeric / pop.pop_est_2018 DESC; ``` Listing 11-9: Finding business rates per thousand population in counties with 50,000 or more people Overall, this syntax should look familiar. In Chapter 5, you learned that when dividing an integer by an integer, one of the values must be a `numeric` or `decimal` for the result to include decimal places. We do that in the rate calculation 1 with PostgreSQL’s double-colon shorthand. Because we don’t need many decimal places, we wrap the statement in the `round()` function to round off the output to the nearest tenth. Then we give the calculated column an alias of `estabs_per_1000` for easy reference. Also, we use a `WHERE` clause 2 to limit our results to counties with 50,000 or more people. That’s an arbitrary value that lets us see how rates compare within a group of more-populous, better-known counties. Here’s a portion of the results, sorted with highest rates at top: ``` county st establishments pop_est_2018 estabs_per_1000 ------------------ ----------- --------------- ------------- --------------- Cape May County New Jersey 925 92446 10.0 Worcester County Maryland 453 51960 8.7 Monroe County Florida 540 74757 7.2 Warren County New York 427 64215 6.6 New York County New York 10428 1629055 6.4 Hancock County Maine 337 54734 6.2 Sevier County Tennessee 570 97895 5.8 Eagle County Colorado 309 54943 5.6 `--snip--` ``` The counties that have the highest rates make sense. Cape May County, New Jersey, is home to numerous beach resort towns on the Atlantic Ocean and Delaware Bay. Worcester County, Maryland, contains Ocean City and other beach attractions. And Monroe County, Florida, is best known for its vacation hotspot, the Florida Keys. Sense a pattern? ## Smoothing Uneven Data A *rolling average* is an average calculated for each time period in a dataset, using a moving window of rows as input each time. Think of a hardware store: it might sell 20 hammers on Monday, 15 hammers on Tuesday, and just a few the rest of the week. The next week, hammer sales might spike on Friday. To find the big-picture story in such uneven data, we can smooth numbers by calculating the rolling average, sometimes called a *moving average*. Here are two weeks of hammer sales at that hypothetical hardware store: ``` Date Hammer sales Seven-day average ---------- ------------ ----------------- 2022-05-01 0 2022-05-02 20 2022-05-03 15 2022-05-04 3 2022-05-05 6 2022-05-06 1 1 2022-05-07 1 6.6 2 2022-05-08 2 6.9 2022-05-09 18 6.6 2022-05-10 13 6.3 2022-05-11 2 6.1 2022-05-12 4 5.9 2022-05-13 12 7.4 2022-05-14 2 7.6 ``` Let’s say that for every day we want to know the average sales over the last seven days (we can choose any period, but a week is an intuitive unit). Once we have seven days of data 1, we calculate the average of sales over the seven-day period that includes the current day. The average of hammer sales from May 1 to May 7, 2022, is `6.6` per day. The next day 2, we again average sales over the most recent seven days, from May 2 to May 8, 2022\. The result is `6.9` per day. As we continue each day, despite the ups and downs in the daily sales, the seven-day average remains fairly steady. Over a long period of time, we’ll be able to better discern a trend. Let’s use the window function syntax again to perform this calculation using the code in Listing 11-10. The code and data are available with all the book’s resources in GitHub, available via [`nostarch.com/practical-sql-2nd-edition/`](https://nostarch.com/practical-sql-2nd-edition/). Remember to change `C:\YourDirectory\` to the location of the CSV file. ``` 1 CREATE TABLE us_exports ( year smallint, month smallint, citrus_export_value bigint, soybeans_export_value bigint ); 2 COPY us_exports FROM '`C:\YourDirectory\`us_exports.csv' WITH (FORMAT CSV, HEADER); 3 SELECT year, month, citrus_export_value FROM us_exports ORDER BY year, month; 4 SELECT year, month, citrus_export_value, round( 5 avg(citrus_export_value) 6 OVER(ORDER BY year, month 7 ROWS BETWEEN 11 PRECEDING AND CURRENT ROW), 0) AS twelve_month_avg FROM us_exports ORDER BY year, month; ``` Listing 11-10: Creating a rolling average for export data We create a table 1 and use `COPY` 2 to insert data from *us_exports.csv*. This file contains data showing the monthly dollar value of US exports of citrus fruit and soybeans, two commodities whose sales are tied to the growing season. The data comes from the US Census Bureau’s international trade division at [`usatrade.census.gov/`](https://usatrade.census.gov/). The first `SELECT` statement 3 lets you view the monthly citrus export data, which covers every month from 2002 through summer 2020\. The last dozen rows should look like this: ``` year month citrus_export_value ---- ----- ------------------- `--snip--` 2019 9 14012305 2019 10 26308151 2019 11 60885676 2019 12 84873954 2020 1 110924836 2020 2 171767821 2020 3 201231998 2020 4 122708243 2020 5 75644260 2020 6 36090558 2020 7 20561815 2020 8 15510692 ``` Notice the pattern: the value of citrus fruit exports is highest in winter months, when the growing season is paused in the northern hemisphere and countries need imports to meet demand. We’ll use the second `SELECT` statement 4 to compute a 12-month rolling average so we can see, for each month, the annual trend in exports. In the `SELECT` values list, we place an `avg()` 5 function to calculate the average of the values in the `citrus_export_value` column. We follow the function with an `OVER` clause 6 that has two elements in parentheses: an `ORDER BY` clause that sorts the data for the period we plan to average, and the number of rows to average, using the keywords `ROWS BETWEEN 11 PRECEDING AND CURRENT ROW` 7. This tells PostgreSQL to limit the window to the current row and the 11 rows before it—12 total. We wrap the entire statement, from the `avg()` function through the `OVER` clause, in a `round()` function to limit the output to whole numbers. The last dozen rows of your query result should be as follows: ``` year month citrus_export_value twelve_month_avg ---- ----- ------------------- ---------------- `--snip--` 2019 9 14012305 74465440 2019 10 26308151 74756757 2019 11 60885676 74853312 2019 12 84873954 74871644 2020 1 110924836 75099275 2020 2 171767821 78874520 2020 3 201231998 79593712 2020 4 122708243 78278945 2020 5 75644260 77999174 2020 6 36090558 78045059 2020 7 20561815 78343206 2020 8 15510692 78376692 ``` Notice the 12-month average is far more consistent. If we want to see the trend, it’s helpful to graph the results using Excel or a stats program. Figure 11-3 shows the monthly totals from 2015 through August 2020 in bars, with the 12-month average as a line.  Figure 11-3: Monthly citrus fruit exports with 12-month rolling average Based on the rolling average, citrus fruit exports were generally steady until 2019 and then trended down before recovering slightly in 2020\. It’s difficult to discern that movement from the monthly data, but the rolling average makes it apparent. The window function syntax offers multiple options for analysis. For example, instead of calculating a rolling average, you could substitute the `sum()` function to find the rolling total over a time period. If you calculated a seven-day rolling sum, you’d know the weekly total ending on any day in your dataset. SQL offers additional window functions. Check the official PostgreSQL documentation at [`www.postgresql.org/docs/current/tutorial-window.html`](https://www.postgresql.org/docs/current/tutorial-window.html) for an overview of window functions, and check [`www.postgresql.org/docs/current/functions-window.html`](https://www.postgresql.org/docs/current/functions-window.html) for a listing of window functions. ## Wrapping Up Now your SQL analysis toolkit includes ways to find relationships among variables using statistical functions, create rankings from ordered data, smooth spiky data to find trends, and properly compare raw numbers by turning them into rates. That toolkit is starting to look impressive! Next, we’ll dive deeper into date and time data, using SQL functions to extract the information we need.```` ````` `````` ```````
第十二章:处理日期和时间

填充了日期和时间的列可以表示事件发生的时间或持续时间,这可以引导出一些有趣的探究方向。时间轴上的时刻存在哪些模式?哪些事件持续时间最短或最长?某项活动与发生的时间段或季节之间有什么关系?
在本章中,我们将使用 SQL 日期和时间的数据类型及其相关函数来探索这些问题。我们将从更详细地了解与日期和时间相关的数据类型和函数开始。接着,我们将探讨一个纽约市出租车出行数据集,寻找模式并尝试发现数据讲述的故事(如果有的话)。我们还将使用 Amtrak 的数据探索时区,计算美国境内火车旅行的持续时间。
理解日期和时间的数据类型及函数
第四章探讨了主要的 SQL 数据类型,下面是与日期和时间相关的四种数据类型,供复习:
-
timestamp记录日期和时间。你几乎总是希望添加关键字with time zone,以确保存储的时间包含时区信息。否则,全球范围内记录的时间将无法进行比较。timestamp with time zone格式是 SQL 标准的一部分;在 PostgreSQL 中,你可以使用timestamptz来指定相同的数据类型。你可以通过三种不同的格式指定时区:UTC 偏移量、区域/位置标识符,或标准缩写。如果你向timestamptz列提供没有时区的时间,数据库将根据服务器的默认设置自动添加时区信息。 -
date仅记录日期,是 SQL 标准的一部分。PostgreSQL 支持多种日期格式。例如,表示 2022 年 9 月 21 日有效的格式有`September 21, 2022`或`9/21/2022`。我推荐使用`YYYY-MM-DD`(或`2022-09-21`),这是一种 ISO 8601 国际标准格式,也是 PostgreSQL 的默认日期输出格式。使用 ISO 格式有助于避免在国际分享数据时产生混淆。
* `time` ``Records only the time and is part of the SQL standard. Adding `with time zone` makes the column time zone aware, but without a date the time zone will be meaningless. Given that, using `time with time zone` and its PostgreSQL shortcut `timetz` is strongly discouraged. The ISO 8601 format is `HH:MM:SS`, where `HH` represents the hour, `MM` the minutes, and `SS` the seconds.````` * `interval` ``Holds a value that represents a unit of time expressed in the format `quantity unit`. It doesn’t record the start or end of a period, only its duration. Examples include `12 days` or `8 hours`. It’s also part of the SQL standard, although PostgreSQL-specific syntax offers more options.`` ```
`````` The first three data types, `date`, `time`, and `timestamp with time zone` (or `timestamptz`), are known as *datetime types* whose values are called *datetimes*. The `interval` value is an *interval type* whose values are *intervals*. All four data types can track the system clock and the nuances of the calendar. For example, `date` and `timestamp with time zone` recognize that June has 30 days. If you try to use June 31, PostgreSQL will display an error: `date/time field value out of range`. Likewise, the date February 29 is valid only in a leap year, such as 2024. ## Manipulating Dates and Times We can use SQL functions to perform calculations on dates and times or extract their components. For example, we can retrieve the day of the week from a timestamp or extract just the month from a date. ANSI SQL outlines a handful of functions for this purpose, but many database managers (including MySQL and Microsoft SQL Server) deviate from the standard to implement their own date and time data types, syntax, and function names. If you’re using a database other than PostgreSQL, check its documentation. Let’s review how to manipulate dates and times using PostgreSQL functions. ### Extracting the Components of a timestamp Value It’s not unusual to need just one piece of a date or time value for analysis, particularly when you’re aggregating results by month, year, or even minute. We can extract these components using the PostgreSQL `date_part()` function. Its format looks like this: ``` date_part(`text`, `value`) ``` The function takes two inputs. The first is a string in `text` format that represents the part of the date or time to extract, such as `hour`, `minute`, or `week`. The second is the `date`, `time`, or `timestamp` value. To see the `date_part()` function in action, we’ll execute it multiple times on the same value using the code in Listing 12-1. ``` SELECT date_part('year', '2022-12-01 18:37:12 EST'::timestamptz) AS year, date_part('month', '2022-12-01 18:37:12 EST'::timestamptz) AS month, date_part('day', '2022-12-01 18:37:12 EST'::timestamptz) AS day, date_part('hour', '2022-12-01 18:37:12 EST'::timestamptz) AS hour, date_part('minute', '2022-12-01 18:37:12 EST'::timestamptz) AS minute, date_part('seconds', '2022-12-01 18:37:12 EST'::timestamptz) AS seconds, date_part('timezone_hour', '2022-12-01 18:37:12 EST'::timestamptz) AS tz, date_part('week', '2022-12-01 18:37:12 EST'::timestamptz) AS week, date_part('quarter', '2022-12-01 18:37:12 EST'::timestamptz) AS quarter, date_part('epoch', '2022-12-01 18:37:12 EST'::timestamptz) AS epoch; ``` Listing 12-1: Extracting components of a `timestamp` value using `date_part()` Each column statement in this `SELECT` query first uses a string to name the component we want to extract: `year`, `month`, `day`, and so on. The second input uses the string `2022-12-01 18:37:12 EST` cast as a `timestamp with time zone` with the PostgreSQL double-colon syntax and the `timestamptz` shorthand. We specify that this timestamp occurs in the Eastern time zone using the Eastern Standard Time (EST) designation. Here’s the output as shown on my computer. The database converts the values to reflect your PostgreSQL time zone setting, so your output might be different; for example, if it’s set to the US Pacific time zone, the hour will show as `15`: ``` year month day hour minute seconds tz week quarter epoch ---- ----- --- ---- ------ ------- -- ---- ------- ---------- 2022 12 1 18 37 12 -5 48 4 1669937832 ``` Each column contains a single component of the timestamp that represents 6:37:12 pm on December 1, 2022\. The first six values are easy to recognize from the original timestamp, but the last four deserve an explanation. In the `tz` column, PostgreSQL reports back the hours difference, or *offset*, from Coordinated Universal Time (UTC), the time standard for the world. The value of UTC is +/− 00:00, so `-5` specifies a time zone five hours behind UTC. From November through early March, UTC -5 represents the Eastern time zone. In March, when the Eastern time zone moves to daylight saving time and clocks “spring forward” an hour, its UTC offset changes to -4\. (For a map of UTC time zones, see [`en.wikipedia.org/wiki/Coordinated_Universal_Time#/media/File:Standard_World_Time_Zones.tif`](https://en.wikipedia.org/wiki/Coordinated_Universal_Time#/media/File:Standard_World_Time_Zones.tif).) The `week` column shows that December 1, 2022, falls in the 48th week of the year. This number is determined by ISO 8601 standards, which start each week on a Monday. A week at the end of a year can extend from December into January of the following year. The `quarter` column shows that our test date is part of the fourth quarter of the year. The `epoch` column shows a measurement, which is used in computer systems and programming languages, that represents the number of seconds elapsed before or after 12 am, January 1, 1970, at UTC 0\. A positive value designates a time since that point; a negative value designates a time before it. In this example, 1,669,937,832 seconds elapsed between January 1, 1970, and the timestamp. Epoch can be useful for comparing two timestamps mathematically on an absolute scale. PostgreSQL also supports the SQL-standard `extract()` function, which parses datetimes in the same way as the `date_part()` function. I’ve featured `date_part()` here instead for two reasons. First, its name helpfully reminds us what it does. Second, `extract()` isn’t widely supported by other database managers. Most notably, it’s absent in Microsoft’s SQL Server. Nevertheless, if you need to use `extract()`, the syntax takes this form: ``` extract(`text` from `value`) ``` To replicate the first `date_part()` example in Listing 12-1 where we pull the year from the timestamp, we’d set up `extract()` like this (note that we don’t need single quotes around the time unit, in this case `year`): ``` extract(year from '2022-12-01 18:37:12 EST'::timestamptz) ``` PostgreSQL provides additional components you can extract or calculate from dates and times. For the full list of functions, see the documentation at [`www.postgresql.org/docs/current/functions-datetime.html`](https://www.postgresql.org/docs/current/functions-datetime.html). ### Creating Datetime Values from timestamp Components It’s not unusual to come across a dataset in which the year, month, and day exist in separate columns, and you might want to create a datetime value from these components. To perform calculations on a date, it’s helpful to combine and format those pieces correctly into one column. You can use the following PostgreSQL functions to make datetime objects: 1. `make_date(year, month, day)` ``Returns a value of type `date`.`` ````` * `make_time(hour, minute, seconds)` ``Returns a value of type `time` without time zone.````* `make_timestamptz(year, month, day, hour, minute, second, time zone)` Returns a timestamp with time zone.`` ````` ````` ````The variables for these three functions take `integer` types as input, with two exceptions: seconds are of the type `double precision` because you can supply fractions of seconds, and time zones must be specified with a `text` string that names the time zone. Listing 12-2 shows examples of the three functions in action using components of February 22, 2022, for the date, and 6:04:30.3 pm in Lisbon, Portugal for the time. ``` SELECT make_date(2022, 2, 22); SELECT make_time(18, 4, 30.3); SELECT make_timestamptz(2022, 2, 22, 18, 4, 30.3, 'Europe/Lisbon'); ``` Listing 12-2: Three functions for making datetimes from components When I run each query in order, the output on my computer is as follows. Again, yours may differ depending on your PostgreSQL time zone setting: ``` 2022-02-22 18:04:30.3 2022-02-22 13:04:30.3-05 ``` Notice that on my computer the timestamp in the third line shows `13:04:30.3`, which is five hours behind the time input to the function: `18:04:30.3`. That output is appropriate because Lisbon’s time zone is at UTC 0, and my PostgreSQL is set to the Eastern time zone, which is UTC –5 in winter months. We’ll explore working with time zones in more detail, and you’ll learn to adjust its display, in the “Working with Time Zones” section. ### Retrieving the Current Date and Time If you need to record the current date or time as part of a query—when updating a row, for example—standard SQL provides functions for that too. The following functions record the time as of the start of the query: 1. `current_timestamp`Returns the current timestamp with time zone. A shorthand PostgreSQL-specific version is `now()`. 2. `localtimestamp`Returns the current timestamp without time zone. Avoid using `localtimestamp`, as a timestamp without a time zone can’t be placed in a global location and is thus meaningless. 3. `current_``date`Returns the date. 4. `current_time`Returns the current time with time zone. Remember, though, without a date, the time alone with a time zone is useless. 5. `localtime` Returns the current time without time zone. Because these functions record the time at the start of the query (or a collection of queries grouped under a *transaction*—see Chapter 10), they’ll provide that same time throughout the execution of a query regardless of how long the query runs. So, if your query updates 100,000 rows and takes 15 seconds to run, any timestamp recorded at the start of the query will be applied to each row, and so each row will receive the same timestamp. If, instead, you want the date and time to reflect how the clock changes during the execution of the query, you can use the PostgreSQL-specific `clock_timestamp()` function to record the current time as it elapses. That way, if you’re updating 100,000 rows and inserting a timestamp each time, each row gets the time the row updated rather than the time at the start of the query. Note that `clock_timestamp()` can slow large queries and may be subject to system limitations. Listing 12-3 shows `current_timestamp` and `clock_timestamp()` in action when inserting a row in a table. ``` CREATE TABLE current_time_example ( time_id integer GENERATED ALWAYS AS IDENTITY, 1 current_timestamp_col timestamptz, 2 clock_timestamp_col timestamptz ); INSERT INTO current_time_example (current_timestamp_col, clock_timestamp_col) 3 (SELECT current_timestamp, clock_timestamp() FROM generate_series(1,1000)); SELECT * FROM current_time_example; ``` Listing 12-3: Comparing `current_timestamp` and `clock_timestamp()` during row insert The code creates a table that includes two `timestamptz` columns (the PostgreSQL shorthand for `timestamp with time zone`). The first holds the result of the `current_timestamp` function 1, which records the time at the start of the `INSERT` statement that adds 1,000 rows to the table. To do that, we use the `generate_series()` function, which returns a set of integers starting with 1 and ending with 1,000\. The second column holds the result of the `clock_timestamp()` function 2, which records the time of insertion of each row. You call both functions as part of the `INSERT` statement 3. Run the query, and the result from the final `SELECT` statement should show that the time in the `current_timestamp_col` is the same for all rows, whereas the time in `clock_timestamp_col` increases with each row inserted. ## Working with Time Zones Recording a timestamp is most useful when you know where on the globe that time occurred—whether in Asia, Eastern Europe, or one of the 12 time zones of Antarctica. Sometimes, however, datasets contain no time zone data in their datetime columns. This isn’t always a deal-breaker in terms of analyzing the data. If you know that every event happened in the same location—for example, readings from a temperature sensor in Bar Harbor, Maine—you can factor that into your analysis. Better, though, during import is to set your session time zone to represent the time zone of the data and load the datetimes into a `timestamptz` column. That strategy helps ward off dangerous misinterpretation of the data later. Let’s look at some strategies for managing how we work with time zones. ### Finding Your Time Zone Setting When working with timestamps that contain time zones, it’s important to know your current time zone setting. If you installed PostgreSQL on your own computer, the server’s default will be your local time zone. If you’re connecting to a PostgreSQL database elsewhere, perhaps on a cloud provider such as Amazon Web Services, its time zone setting may be different than your own. To help avoid confusion, database administrators often set a shared server’s time zone to UTC. Listing 12-4 shows two ways to view your current time zone setting: the `SHOW` command with `timezone` keyword and the `current_setting()` function with a `timezone` argument. ``` SHOW timezone; SELECT current_setting('timezone'); ``` Listing 12-4: Viewing your current time zone setting Running either statement will display your time zone setting, which will vary according to your operating system and locale. Entering the statements in Listing 12-4 into pgAdmin and running both my macOS and Linux computers returns `America/New_York`, one of several location names that falls into the Eastern time zone, which encompasses eastern Canada and the United States, the Caribbean, and parts of Mexico. On my Windows machine, the setting shows as `US/Eastern`. Though both statements provide the same information, you may find `current_setting()` extra handy as an input to another function such as `make_timestamptz()`: ``` SELECT make_timestamptz(2022, 2, 22, 18, 4, 30.3, current_setting('timezone')); ``` Listing 12-5 shows how to retrieve all time zone names, abbreviations, and their UTC offsets. ``` SELECT * FROM pg_timezone_abbrevs ORDER BY abbrev; SELECT * FROM pg_timezone_names ORDER BY name; ``` Listing 12-5: Showing time zone abbreviations and names You can easily filter either of these `SELECT` statements with a `WHERE` clause to look up specific location names or time zones: ``` SELECT * FROM pg_timezone_names WHERE name LIKE 'Europe%' ORDER BY name; ``` This code should return a table listing that includes the time zone name, abbreviation, UTC offset, and a `boolean` column `is_dst` that notes whether the time zone is currently observing daylight saving time: ``` name abbrev utc_offset is_dst ---------------- ------ ---------- ------ Europe/Amsterdam CEST 02:00:00 true Europe/Andorra CEST 02:00:00 true Europe/Astrakhan +04 04:00:00 false Europe/Athens EEST 03:00:00 true Europe/Belfast BST 01:00:00 true `--snip--` ``` This is a faster way of looking up time zones than using Wikipedia. Now let’s look at how to set the time zone to a particular value. ### Setting the Time Zone When you installed PostgreSQL, the server’s default time zone was set as a parameter in *postgresql.conf*, a file that contains dozens of values read by PostgreSQL each time it starts. The location of *postgresql.conf* in your file system varies depending on your operating system and sometimes on the way you installed PostgreSQL. To make permanent changes to *postgresql.conf*, such as changing your time zone, you need to edit the file and restart the server, which might be impossible if you’re not the owner of the machine. Changes to configurations might also have unintended consequences for other users or applications. Instead, we’ll look at setting the time zone on a per-session basis, which should last as long as you’re connected to the server, and then I’ll cover working with *postgresql.conf* in more depth in Chapter 19. This solution is handy when you want to specify how you view a particular table or handle timestamps in a query. To set the time zone for the current session while using pgAdmin, we use the command `SET TIME ZONE`, as shown in Listing 12-6. ``` 1 SET TIME ZONE 'US/Pacific'; 2 CREATE TABLE time_zone_test ( test_date timestamptz ); 3 INSERT INTO time_zone_test VALUES ('2023-01-01 4:00'); 4 SELECT test_date FROM time_zone_test; 5 SET TIME ZONE 'US/Eastern'; 6 SELECT test_date FROM time_zone_test; 7 SELECT test_date AT TIME ZONE 'Asia/Seoul' FROM time_zone_test; ``` Listing 12-6: Setting the time zone for a client session First, we set the time zone to `US/Pacific` 1, which designates the Pacific time zone that covers western Canada and the United States along with Baja California in Mexico. The syntax `SET TIME ZONE` is part of the ANSI SQL standard. PostgreSQL also supports the nonstandard syntax `SET timezone TO`. Second, we create a one-column table 2 with a data type of `timestamptz` and insert a single row to display a test result. Notice that the value inserted, `2023-01-01 4:00`, is a timestamp with no time zone 3. You’ll encounter timestamps with no time zone often, particularly when you acquire datasets restricted to a specific location. When executed, the first `SELECT` statement 4 returns `2023-01-01 4:00` as a timestamp that now contains time zone data: ``` test_date ---------------------- 2023-01-01 04:00:00-08 ``` Here, the `-08` shows that the Pacific time zone is eight hours behind UTC in January, when standard time is in effect. Because we set the pgAdmin client’s time zone to `US/Pacific` for this session, any value without a time zone entered into a column that is time zone-aware will be set to Pacific time. If we had entered a date that falls during daylight saving time, the UTC offset would be `-07`. Now comes some fun. We change the time zone for this session to the Eastern time zone using the `SET` command 5 and the `US/Eastern` designation. Then, when we execute the `SELECT` statement 6 again, the result should be as follows: ``` test_date ---------------------- 2023-01-01 07:00:00-05 ``` In this example, two components of the timestamp have changed: the time is now `07:00`, and the UTC offset is `-05` because we’re viewing the timestamp from the perspective of the Eastern time zone: 4 am Pacific is 7 am Eastern. The database converts the original Pacific time value to whatever time zone we set at 5. Even more convenient is that we can view a timestamp through the lens of any time zone without changing the session setting. The final `SELECT` statement uses the `AT TIME ZONE` keywords 7 to display the timestamp in our session as the Korea standard time (KST) zone by specifying `Asia/Seoul`: ``` timezone ------------------- 2023-01-01 21:00:00 ``` Now we know that the value of 4 am in `US/Pacific` on January 1, 2023, is equivalent to 9 pm that same day in `Asia/Seoul`. Again, this syntax changes the output data, but the data on the server remains unchanged. When using the `AT TIME ZONE` keywords, also note this quirk: if the original value is a `timestamp with time zone`, the output is a `timestamp` with no time zone. If the original value has no time zone, the output is `timestamp with time zone`. The ability of databases to track time zones is extremely important for accurate calculations of intervals, as you’ll see next. ## Performing Calculations with Dates and Times We can perform simple arithmetic on datetime and interval types the same way we can on numbers. Addition, subtraction, multiplication, and division are all possible in PostgreSQL using the math operators `+`, `-`, `*`, and `/`. For example, you can subtract one date from another date to get an integer that represents the difference in days between the two dates. The following code returns an integer of `3`: ``` SELECT '1929-09-30'::date - '1929-09-27'::date; ``` The result indicates that these two dates are exactly three days apart. Likewise, you can use the following code to add a time interval to a date to return a new date: ``` SELECT '1929-09-30'::date + '5 years'::interval; ``` This code adds five years to the date `1929-09-30` to return a timestamp value of `1934-09-30`. More examples of math functions you can use with dates and times are available in the PostgreSQL documentation at [`www.postgresql.org/docs/current/functions-datetime.html`](https://www.postgresql.org/docs/current/functions-datetime.html). Let’s explore some more practical examples using actual transportation data. ### Finding Patterns in New York City Taxi Data When I visit New York City, I usually take at least one ride in one of the thousands of iconic yellow cars that ferry hundreds of thousands of people across the city’s five boroughs each day. The New York City Taxi and Limousine Commission releases data on monthly yellow taxi trips plus other for-hire vehicles. We’ll use this large, rich dataset to put date functions to practical use. The *nyc_yellow_taxi_trips.csv* file available from the book’s resources on GitHub (via the link at [`nostarch.com/practical-sql-2nd-edition/`](https://nostarch.com/practical-sql-2nd-edition/)) holds one day of yellow taxi trip records from June 1, 2016\. Save the file to your computer and execute the code in Listing 12-7 to build the `nyc_yellow_taxi_trips` table. Remember to change the file path in the `COPY` command to the location where you’ve saved the file and adjust the path format to reflect whether you’re using Windows, macOS, or Linux. ``` 1 CREATE TABLE nyc_yellow_taxi_trips ( trip_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, vendor_id text NOT NULL, tpep_pickup_datetime timestamptz NOT NULL, tpep_dropoff_datetime timestamptz NOT NULL, passenger_count integer NOT NULL, trip_distance numeric(8,2) NOT NULL, pickup_longitude numeric(18,15) NOT NULL, pickup_latitude numeric(18,15) NOT NULL, rate_code_id text NOT NULL, store_and_fwd_flag text NOT NULL, dropoff_longitude numeric(18,15) NOT NULL, dropoff_latitude numeric(18,15) NOT NULL, payment_type text NOT NULL, fare_amount numeric(9,2) NOT NULL, extra numeric(9,2) NOT NULL, mta_tax numeric(5,2) NOT NULL, tip_amount numeric(9,2) NOT NULL, tolls_amount numeric(9,2) NOT NULL, improvement_surcharge numeric(9,2) NOT NULL, total_amount numeric(9,2) NOT NULL ); 2 COPY nyc_yellow_taxi_trips ( vendor_id, tpep_pickup_datetime, tpep_dropoff_datetime, passenger_count, trip_distance, pickup_longitude, pickup_latitude, rate_code_id, store_and_fwd_flag, dropoff_longitude, dropoff_latitude, payment_type, fare_amount, extra, mta_tax, tip_amount, tolls_amount, improvement_surcharge, total_amount ) FROM '`C:\YourDirectory\`nyc_yellow_taxi_trips.csv' WITH (FORMAT CSV, HEADER); 3 CREATE INDEX tpep_pickup_idx ON nyc_yellow_taxi_trips (tpep_pickup_datetime); ``` Listing 12-7: Creating a table and importing NYC yellow taxi data The code in Listing 12-7 builds the table 1, imports the rows 2, and creates an index 3. In the `COPY` statement, we provide the names of columns because the input CSV file doesn’t include the `trip_id` column that exists in the target table. That column is of type `bigint` and set as an auto-incrementing surrogate primary key. After your import is complete, you should have 368,774 rows, one for each yellow cab ride on June 1, 2016\. You can count the rows in your table using the following code: ``` SELECT count(*) FROM nyc_yellow_taxi_trips; ``` Each row includes data on the number of passengers, the location of pickup and drop-off in latitude and longitude, and the fare and tips in US dollars. The data dictionary that describes all columns and codes is available at [`www1.nyc.gov/assets/tlc/downloads/pdf/data_dictionary_trip_records_yellow.pdf`](https://www1.nyc.gov/assets/tlc/downloads/pdf/data_dictionary_trip_records_yellow.pdf). For these exercises, we’re most interested in the timestamp columns `tpep_pickup_datetime` and `tpep_dropoff_datetime`, which represent the start and end times of the ride. (The Technology Passenger Enhancements Project [TPEP] is a program that in part includes automated collection of data about taxi rides.) The values in both timestamp columns include the time zone: `-4`. That’s the UTC offset for the Eastern time zone during summer, when daylight saving time is observed. If your PostgreSQL server isn’t set to default to Eastern time, I suggest setting your time zone using the following code so your results will match mine: ``` SET TIME ZONE 'US/Eastern'; ``` Now let’s explore the patterns in these timestamps. #### The Busiest Time of Day One question you might ask of this data is when taxis provide the most rides. Is it morning or evening rush hour, or is there another time when ridership spikes? You can find the answer with a simple aggregation query that uses `date_part()`. Listing 12-8 contains the query to count rides by hour using the pickup time as the input. ``` SELECT 1 date_part('hour', tpep_pickup_datetime) AS trip_hour, 2 count(*) FROM nyc_yellow_taxi_trips GROUP BY trip_hour ORDER BY trip_hour; ``` Listing 12-8: Counting taxi trips by hour In the query’s first column 1, `date_part()` extracts the hour from `tpep_pickup_datetime` so we can group the number of rides by hour. Then we aggregate the number of rides in the second column via the `count()` function 2. The rest of the query follows the standard patterns for grouping and ordering the results, which should return 24 rows, one for each hour of the day: ``` trip_hour count --------- ----- 0 8182 1 5003 2 3070 3 2275 4 2229 5 3925 6 10825 7 18287 8 21062 9 18975 10 17367 11 17383 12 18031 13 17998 14 19125 15 18053 16 15069 17 18513 18 22689 19 23190 20 23098 21 24106 22 22554 23 17765 ``` Eyeballing the numbers, it’s apparent that on June 1, 2016, New York City taxis had the most passengers between 6 pm and 10 pm, possibly reflecting commutes home plus the plethora of city activities on a summer evening. But to see the overall pattern, it’s best to visualize the data. Let’s do this next. #### Exporting to CSV for Visualization in Excel Charting data with a tool such as Microsoft Excel makes it easier to understand patterns, so I often export query results to a CSV file and work up a quick chart. Listing 12-9 uses the query from the preceding example within a `COPY ... TO` statement, similar to Listing 5-9 in Chapter 5. ``` COPY (SELECT date_part('hour', tpep_pickup_datetime) AS trip_hour, count(*) FROM nyc_yellow_taxi_trips GROUP BY trip_hour ORDER BY trip_hour ) TO '`C:\YourDirectory\`hourly_taxi_pickups.csv' WITH (FORMAT CSV, HEADER); ``` Listing 12-9: Exporting taxi pickups per hour to a CSV file When I load the data into Excel and build a line graph, the day’s pattern becomes more obvious and thought-provoking, as shown in Figure 12-1.  Figure 12-1: NYC yellow taxi pickups by hour Rides bottomed out in the wee hours of the morning before rising sharply between 5 am and 8 am. Volume remained relatively steady throughout the day and increased again for evening rush hour after 5 pm. But there was a dip between 3 pm and 4 pm—why? To answer that question, we would need to dig deeper to analyze data that spanned several days or even several months to see whether our data from June 1, 2016, is typical. We could use the `date_part()` function to compare trip volume on weekdays versus weekends by extracting the day of the week. To be even more ambitious, we could check weather reports and compare trips on rainy days versus sunny days. You can slice a dataset many ways to reach conclusions. #### When Do Trips Take the Longest? Let’s investigate another interesting question: at which hour did taxi trips take the longest? One way to find an answer is to calculate the median trip time for each hour. The median is the middle value in an ordered set of values; it’s often more accurate than an average for making comparisons because a few very small or very large values in the set won’t skew the results as they would with the average. In Chapter 6, we used the `percentile_cont()` function to find medians. We use it again in Listing 12-10 to calculate median trip times. ``` SELECT 1 date_part('hour', tpep_pickup_datetime) AS trip_hour, 2 percentile_cont(.5) 3 WITHIN GROUP (ORDER BY tpep_dropoff_datetime - tpep_pickup_datetime) AS median_trip FROM nyc_yellow_taxi_trips GROUP BY trip_hour ORDER BY trip_hour; ``` Listing 12-10: Calculating median trip time by hour We’re aggregating data by the hour portion of the timestamp column `tpep_pickup_datetime` again, which we extract using `date_part()` 1. For the input to the `percentile_cont()` function 2, we subtract the pickup time from the drop-off time in the `WITHIN GROUP` clause 3. The results show that the 1 pm hour has the highest median trip time of 15 minutes: ``` date_part median_trip --------- ----------- 0 00:10:04 1 00:09:27 2 00:08:59 3 00:09:57 4 00:10:06 5 00:07:37 6 00:07:54 7 00:10:23 8 00:12:28 9 00:13:11 10 00:13:46 11 00:14:20 12 00:14:49 13 00:15:00 14 00:14:35 15 00:14:43 16 00:14:42 17 00:14:15 18 00:13:19 19 00:12:25 20 00:11:46 21 00:11:54 22 00:11:37 23 00:11:14 ``` As we would expect, trip times are shortest in the early morning. This makes sense because less traffic early in the day means passengers are more likely to get to their destinations faster. Now that we’ve explored ways to extract portions of the timestamp for analysis, let’s dig deeper into analysis that involves intervals. ### Finding Patterns in Amtrak Data Amtrak, the nationwide rail service in America, offers several packaged trips across the United States. The All American, for example, is a train that departs from Chicago and stops in New York, New Orleans, Los Angeles, San Francisco, and Denver before returning to Chicago. Using data from the Amtrak website ([`www.amtrak.com/`](https://www.amtrak.com/)), we’ll build a table with information for each segment of the trip. The trip spans four time zones, so we’ll track the time zone with each arrival and departure. Then we’ll calculate the duration of the journey at each segment and figure out the length of the entire trip. #### Calculating the Duration of Train Trips Using Listing 12-11, let’s create a table that tracks the six segments of the All American route. ``` CREATE TABLE train_rides ( trip_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, segment text NOT NULL, departure timestamptz NOT NULL, 1 arrival timestamptz NOT NULL ); INSERT INTO train_rides (segment, departure, arrival) 2 VALUES ('Chicago to New York', '2020-11-13 21:30 CST', '2020-11-14 18:23 EST'), ('New York to New Orleans', '2020-11-15 14:15 EST', '2020-11-16 19:32 CST'), ('New Orleans to Los Angeles', '2020-11-17 13:45 CST', '2020-11-18 9:00 PST'), ('Los Angeles to San Francisco', '2020-11-19 10:10 PST', '2020-11-19 21:24 PST'), ('San Francisco to Denver', '2020-11-20 9:10 PST', '2020-11-21 18:38 MST'), ('Denver to Chicago', '2020-11-22 19:10 MST', '2020-11-23 14:50 CST'); SET TIME ZONE 'US/Central'; 3 SELECT * FROM train_rides; ``` Listing 12-11: Creating a table to hold train trip data First, we use the standard `CREATE TABLE` statement. Note that columns for departure and arrival times are set to `timestamptz` 1. Next, we insert rows that represent the six legs of the trip 2. Each timestamp input reflects the time zone of the city of departure or arrival. Specifying the city’s time zone is the key to getting an accurate calculation of trip duration and accounting for time zone changes. It also accounts for annual changes to and from daylight saving time if they were to occur during the time span you’re examining. Next, we set the session to the Central time zone, the value for Chicago, using the `US/Central` designator 3. We’ll use Central time as our reference when viewing the timestamps so that regardless of your and my machine’s default time zones, we’ll share the same view of the data. The final `SELECT` statement should return the contents of the table like this: ``` trip_id segment departure arrival ------- ---------------------------- ---------------------- ---------------------- 1 Chicago to New York 2020-11-13 21:30:00-06 2020-11-14 17:23:00-06 2 New York to New Orleans 2020-11-15 13:15:00-06 2020-11-16 19:32:00-06 3 New Orleans to Los Angeles 2020-11-17 13:45:00-06 2020-11-18 11:00:00-06 4 Los Angeles to San Francisco 2020-11-19 12:10:00-06 2020-11-19 23:24:00-06 5 San Francisco to Denver 2020-11-20 11:10:00-06 2020-11-21 19:38:00-06 6 Denver to Chicago 2020-11-22 20:10:00-06 2020-11-23 14:50:00-06 ``` All timestamps should now carry a UTC offset of `-06`, reflecting the Central time zone in the United States during November, when standard time is in effect. All time values display in their Central time equivalents. Now that we’ve created segments corresponding to each leg of the trip, we’ll use Listing 12-12 to calculate the duration of each segment. ``` SELECT segment, 1 to_char(departure, 'YYYY-MM-DD HH12:MI a.m. TZ') AS departure, 2 arrival - departure AS segment_duration FROM train_rides; ``` Listing 12-12: Calculating the length of each trip segment This query lists the trip segment, the departure time, and the duration of the segment journey. Before we look at the calculation, notice the additional code around the `departure` column 1. These are PostgreSQL-specific formatting functions that specify how to format different components of the timestamp. In this case, the `to_char()` function turns the `departure` timestamp column into a string of characters formatted as `YYYY-MM-DD HH12:MI a.m. TZ`. The `YYYY-MM-DD` portion specifies the ISO format for the date, and the `HH12:MI a.m.` portion presents the time in hours and minutes. The `HH12` portion specifies the use of a 12-hour clock rather than 24-hour military time. The `a.m.` portion specifies that we want to show morning or night times using lowercase characters separated by periods, and the `TZ` portion denotes the time zone. For a complete list of formatting functions, check out the PostgreSQL documentation at [`www.postgresql.org/docs/current/functions-formatting.html`](https://www.postgresql.org/docs/current/functions-formatting.html). Last, we subtract `departure` from `arrival` to determine the `segment_duration` 2. When you run the query, the output should look like this: ``` segment departure segment_duration ---------------------------- ------------------------- ---------------- Chicago to New York 2020-11-13 09:30 p.m. CST 19:53:00 New York to New Orleans 2020-11-15 01:15 p.m. CST 1 day 06:17:00 New Orleans to Los Angeles 2020-11-17 01:45 p.m. CST 21:15:00 Los Angeles to San Francisco 2020-11-19 12:10 p.m. CST 11:14:00 San Francisco to Denver 2020-11-20 11:10 a.m. CST 1 day 08:28:00 Denver to Chicago 2020-11-22 08:10 p.m. CST 18:40:00 ``` Subtracting one timestamp from another produces an `interval` data type, which was introduced in Chapter 4. As long as the value is less than 24 hours, PostgreSQL presents the interval in the `HH:MM:SS` format. For values greater than 24 hours, it returns the format `1 day 08:28:00`, as shown in the San Francisco to Denver segment. In each calculation, PostgreSQL accounts for the changes in time zones so we don’t inadvertently add or lose hours when subtracting. If we used a `timestamp without time zone` data type, we would end up with an incorrect trip length if a segment spanned multiple time zones. #### Calculating Cumulative Trip Time As it turns out, San Francisco to Denver is the longest leg of the All American train trip. But how long does the entire trip take? To answer this question, we’ll revisit window functions, which you first learned about in “Ranking with rank() and dense_rank()” in Chapter 11. Our prior query produced an interval, which we labeled `segment_duration`. The next natural next step would be to write a query to add those values, creating a cumulative interval after each segment. And indeed, we can use `sum()` as a window function, combined with the `OVER` clause used in Chapter 11, to create running totals. But when we do, the resulting values are odd. To see what I mean, run the code in Listing 12-13. ``` SELECT segment, arrival - departure AS segment_duration, sum(arrival - departure) OVER (ORDER BY trip_id) AS cume_duration FROM train_rides; ``` Listing 12-13: Calculating cumulative intervals using `OVER` In the third column, we sum the intervals generated when we subtract `departure` from `arrival`. The resulting running total in the `cume_duration` column is accurate but formatted in an unhelpful way: ``` segment segment_duration cume_duration ---------------------------- ---------------- --------------- Chicago to New York 19:53:00 19:53:00 New York to New Orleans 1 day 06:17:00 1 day 26:10:00 New Orleans to Los Angeles 21:15:00 1 day 47:25:00 Los Angeles to San Francisco 11:14:00 1 day 58:39:00 San Francisco to Denver 1 day 08:28:00 2 days 67:07:00 Denver to Chicago 18:40:00 2 days 85:47:00 ``` PostgreSQL creates one sum for the day portion of the interval and another for the hours and minutes. So, instead of a more understandable cumulative time of `5 days 13:47:00`, the database reports `2 days 85:47:00`. Both results amount to the same length of time, but `2 days 85:47:00` is harder to decipher. This is an unfortunate limitation of summing the database intervals using this syntax. To get around the limitation, we’ll wrap the window function calculation for the cumulative duration inside the `justify_interval()` function, shown in Listing 12-14. ``` SELECT segment, arrival - departure AS segment_duration, 1 justify_interval(sum(arrival - departure) OVER (ORDER BY trip_id)) AS cume_duration FROM train_rides; ``` Listing 12-14: Using `justify_interval()` to better format cumulative trip duration The `justify_interval()` function 1 standardizes output of interval calculations so that groups of 24 hours are rolled up to days, and groups of 30 days are rolled up to months. So, instead of returning a cumulative duration of `2 days 85:47:00`, as in the previous listing, `justify_interval()` converts 72 of those 85 hours to three days and adds them to the `days` value. The output is easier to understand: ``` segment segment_duration cume_duration ---------------------------- ---------------- -------------- Chicago to New York 19:53:00 19:53:00 New York to New Orleans 1 day 06:17:00 2 days 02:10:00 New Orleans to Los Angeles 21:15:00 2 days 23:25:00 Los Angeles to San Francisco 11:14:00 3 days 10:39:00 San Francisco to Denver 1 day 08:28:00 4 days 19:07:00 Denver to Chicago 18:40:00 5 days 13:47:00 ``` The final `cume_duration` adds all the segments to return the total trip duration of `5 days 13:47:00`. That’s a long time to spend on a train, but I’m sure the scenery is well worth the ride. ## Wrapping Up Handling times and dates in SQL databases adds an intriguing dimension to your analysis, letting you answer questions about when an event occurred along with other temporal concerns in your data. With a solid grasp of time and date formats, time zones, and functions to dissect the components of a timestamp, you can analyze just about any dataset you come across. Next, we’ll look at advanced query techniques that help answer more complex questions.```` ````` ``````
第十三章:高级查询技巧

有时候,数据分析需要一些超越表连接或基础 SELECT 查询的高级 SQL 技巧。在本章中,我们将介绍一些技巧,包括编写查询,利用其他查询的结果作为输入,并在计数之前将数值重新分类为不同的类别。
在这些练习中,我将介绍一个包含美国部分城市温度数据集,并回顾你在前几章中创建的数据集。本书的练习代码及所有资源可通过 nostarch.com/practical-sql-2nd-edition/ 获得。你将继续使用你已经建立的 analysis 数据库。让我们开始吧。
使用子查询
子查询 是嵌套在另一个查询内部的查询。通常,它执行计算或逻辑测试,或生成行以传递给外部的主查询。子查询是标准 ANSI SQL 的一部分,语法并不特别:我们只是将查询括在括号内。例如,我们可以编写一个返回多行的子查询,并将这些结果当作 FROM 子句中的表来使用。或者,我们可以创建一个 标量子查询,它返回一个单一值,并将其作为 表达式 的一部分,用于通过 WHERE、IN 和 HAVING 子句过滤行。相关子查询 是指依赖外部查询中的某个值或表名来执行的子查询。相反,非相关子查询 则不引用主查询中的任何对象。
通过操作数据来理解这些概念会更容易,因此让我们回顾一些早期章节中的数据集,包括 us_counties_pop_est_2019 人口普查县级估算表和 cbp_naics_72_establishments 商业模式表。
在 WHERE 子句中过滤使用子查询
WHERE 子句允许你根据提供的标准过滤查询结果,使用像 WHERE quantity > 1000 这样的表达式。但这要求你已经知道要用来比较的值。如果你不知道该怎么做呢?这时子查询就派上用场了:它让你编写一个查询,生成一个或多个值,用作 WHERE 子句中的表达式的一部分。
为查询表达式生成值
假设你想编写一个查询,显示哪些美国县的人口位于或超过第 90 百分位数,或者是前 10%的县。你不必编写两个独立的查询——一个计算 90 百分位数,另一个查找人口位于或高于这个数值的县——你可以一次性完成这两个任务,使用子查询作为 WHERE 子句的一部分,如列表 13-1 所示。
SELECT county_name,
state_name,
pop_est_2019
FROM us_counties_pop_est_2019
1 WHERE pop_est_2019 >= (
SELECT percentile_cont(.9) WITHIN GROUP (ORDER BY pop_est_2019)
FROM us_counties_pop_est_2019
)
ORDER BY pop_est_2019 DESC;
列表 13-1:在 WHERE 子句中使用子查询
WHERE子句 1 通过总人口列pop_est_2019进行筛选,但没有像通常那样指定一个值。相反,在>=比较操作符之后,我们提供了一个括号中的子查询。这个子查询使用percentile_cont()函数生成一个值:pop_est_2019列中的第 90 百分位数临界点。
这是一个无关子查询的示例。它不依赖于外部查询中的任何值,并且只会执行一次以生成所请求的值。如果你只运行子查询部分,通过在 pgAdmin 中高亮它,它将执行,你应该会看到213707.3的结果。但在运行整个查询示例 13-1 时,你看不到这个数字,因为子查询的结果会直接传递到外部查询的WHERE子句中。
整个查询应该返回 315 行,约占us_counties_pop_est_2019表中 3,142 行的 10%。
county_name state_name pop_est_2019
----------------------- -------------------- ------------
Los Angeles County California 10039107
Cook County Illinois 5150233
Harris County Texas 4713325
Maricopa County Arizona 4485414
San Diego County California 3338330
`--snip--`
Cabarrus County North Carolina 216453
Yuma County Arizona 213787
结果包括所有人口大于或等于213707.3的县,这是子查询生成的值。
使用子查询来识别要删除的行
我们可以在DELETE语句中使用相同的子查询来指定从表中删除的内容。在示例 13-2 中,我们使用你在第十章学到的方法创建了一个人口普查表的副本,然后从该备份中删除除前 10%人口的 315 个县以外的所有数据。
CREATE TABLE us_counties_2019_top10 AS
SELECT * FROM us_counties_pop_est_2019;
DELETE FROM us_counties_2019_top10
WHERE pop_est_2019 < (
SELECT percentile_cont(.9) WITHIN GROUP (ORDER BY pop_est_2019)
FROM us_counties_2019_top10
);
示例 13-2:在WHERE子句中使用子查询与DELETE
执行示例 13-2 中的代码,然后执行SELECT count(*) FROM us_counties_2019_top10;来计算剩余的行数。结果应该是 315 行,即原始的 3,142 行减去子查询所识别的 2,827 行。
使用子查询创建派生表
如果你的子查询返回行和列,你可以将其放入FROM子句中,创建一个新的表,称为派生表,你可以像查询常规表一样查询或与其他表进行连接。这是另一个无关子查询的示例。
让我们看一个简单的例子。在第六章中,你学习了平均数和中位数的区别。中位数通常更能准确表示数据集的中心值,因为少数几个极大或极小的值(或异常值)会偏移平均值。因此,我常常比较二者。如果它们接近,数据更可能符合正态分布(熟悉的钟形曲线),此时平均值可以很好地代表中心值。如果平均值和中位数差距很大,则可能存在异常值的影响,或者数据分布是偏斜的,非正态的。
查找美国县的平均人口和中位数人口以及二者之间的差异是一个两步过程。我们需要计算平均值和中位数,然后将二者相减。我们可以通过在FROM子句中使用子查询一次性完成这两个操作,如示例 13-3 所示。
SELECT round(calcs.average, 0) AS average,
calcs.median,
round(calcs.average - calcs.median, 0) AS median_average_diff
FROM (
1 SELECT avg(pop_est_2019) AS average,
percentile_cont(.5)
WITHIN GROUP (ORDER BY pop_est_2019)::numeric AS median
FROM us_counties_pop_est_2019
)
2 AS calcs;
示例 13-3:作为派生表的子查询用于FROM子句
产生派生表的子查询 1 很直接。我们使用avg()和percentile_cont()函数来计算人口普查表中pop_est_2019列的平均值和中位数,并为每一列命名一个别名。然后,我们将派生表命名为calcs 2,这样我们就可以在主查询中引用它。
在主查询中,我们将子查询返回的median与average相减。结果会被四舍五入并标记为别名median_average_diff。执行查询后,结果应该如下所示:
average median median_average_diff
------- ------- -------------------
104468 25726 78742
中位数和平均数之间的差异 78,742 几乎是中位数的三倍。这表明我们有一些高人口的县城拉高了平均数。
连接派生表
连接多个派生表可以让你在主查询的最终计算之前执行多个预处理步骤。例如,在第十一章中,我们计算了每千人中与旅游相关的企业数量。假设我们想在州一级进行相同的计算。在我们计算这个比率之前,我们需要知道每个州的旅游企业数量和每个州的人口。 示例 13-4 展示了如何为这两个任务编写子查询,并将它们连接起来计算整体比率。
SELECT census.state_name AS st,
census.pop_est_2018,
est.establishment_count,
1 round((est.establishment_count/census.pop_est_2018::numeric) * 1000, 1)
AS estabs_per_thousand
FROM
(
2 SELECT st,
sum(establishments) AS establishment_count
FROM cbp_naics_72_establishments
GROUP BY st
)
AS est
JOIN
(
3 SELECT state_name,
sum(pop_est_2018) AS pop_est_2018
FROM us_counties_pop_est_2019
GROUP BY state_name
)
AS census
4 ON est.st = census.state_name
ORDER BY estabs_per_thousand DESC;
示例 13-4:连接两个派生表
你在第十一章中学过如何计算比率,所以外部查询中寻找estabs_per_thousand 1 的数学运算和语法应该是熟悉的。我们将企业数量除以人口,然后将商数乘以千。对于输入,我们使用从两个派生表生成的值。
第一个 2 使用sum()聚合函数找出每个州的企业数量。我们将这个派生表命名为est,以便在查询的主部分引用。第二个 3 使用sum()计算每个州的 2018 年估算人口,基于pop_est_2018列。我们将这个派生表命名为census。
接下来,我们通过将est中的st列与census中的state_name列连接起来,连接这两个派生表 4。然后,我们根据比率按降序列出结果。以下是 51 行中的一个样本,展示了最高和最低的比率:
st pop_est_2018 establishment_count estabs_per_thousand
-------------------- ------------ ------------------- -------------------
District of Columbia 701547 2754 3.9
Montana 1060665 3569 3.4
Vermont 624358 1991 3.2
Maine 1339057 4282 3.2
Wyoming 577601 1808 3.1
`--snip--`
Arizona 7158024 13288 1.9
Alabama 4887681 9140 1.9
Utah 3153550 6062 1.9
Mississippi 2981020 5645 1.9
Kentucky 4461153 8251 1.8
排在第一位的是华盛顿特区,这并不令人意外,因为国会大厦、博物馆、纪念碑以及其他旅游景点产生了大量游客活动。蒙大拿州排第二似乎令人惊讶,但它是一个人口较少的州,拥有主要的旅游目的地,包括冰川国家公园和黄石国家公园。密西西比州和肯塔基州是每千人中旅游相关企业最少的州。
使用子查询生成列
你也可以在SELECT后面的列列表中放置子查询,以生成查询结果中该列的值。子查询必须只生成一行数据。例如,清单 13-5 中的查询从us_counties_pop_est_2019中选择地理和人口信息,然后添加一个无关的子查询,将所有县的中位数添加到新列us_median的每一行中。
SELECT county_name,
state_name AS st,
pop_est_2019,
(SELECT percentile_cont(.5) WITHIN GROUP (ORDER BY pop_est_2019)
FROM us_counties_pop_est_2019) AS us_median
FROM us_counties_pop_est_2019;
清单 13-5:向列列表中添加子查询
结果集的前几行应该如下所示:
county_name st pop_est_2019 us_median
--------------------------------- -------------------- ------------ ---------
Autauga County Alabama 55869 25726
Baldwin County Alabama 223234 25726
Barbour County Alabama 24686 25726
Bibb County Alabama 22394 25726
Blount County Alabama 57826 25726
`--snip--`
单独来看,重复的us_median值并不是特别有用。更有趣和实用的是生成一些值,表示每个县的人口与中位数值的偏差。让我们看看如何使用相同的子查询技巧来实现这一点。清单 13-6 在清单 13-5 的基础上,替换了一个子查询,计算每个县人口与中位数之间的差异。
SELECT county_name,
state_name AS st,
pop_est_2019,
pop_est_2019 - (SELECT percentile_cont(.5) WITHIN GROUP (ORDER BY pop_est_2019) 1
FROM us_counties_pop_est_2019) AS diff_from_median
FROM us_counties_pop_est_2019
WHERE (pop_est_2019 - (SELECT percentile_cont(.5) WITHIN GROUP (ORDER BY pop_est_2019) 2
FROM us_counties_pop_est_2019))
BETWEEN -1000 AND 1000;
清单 13-6:在计算中使用子查询
子查询 1 现在成为一个计算的一部分,该计算将子查询的结果从pop_est_2019(总人口)中减去,为列指定了别名diff_from_median。为了使这个查询更加有用,我们可以过滤结果,显示人口接近中位数的县。为此,我们在WHERE子句 2 中重复子查询计算,并使用BETWEEN -1000 AND 1000表达式过滤结果。
结果应该会显示 78 个县。以下是前五行:
county_name st pop_est_2019 diff_from_median
----------------------- -------------- ------------ ----------------
Cherokee County Alabama 26196 470
Geneva County Alabama 26271 545
Cleburne County Arkansas 24919 -807
Johnson County Arkansas 26578 852
St. Francis County Arkansas 24994 -732
`--snip--`
请记住,子查询可能会增加整体查询执行时间。在清单 13-6 中,我从清单 13-5 中移除了显示列us_median的子查询,以避免第三次重复子查询。对于我们的数据集,影响是最小的;如果我们处理的是数百万行数据,剔除一些不必要的子查询可能会显著提高速度。
理解子查询表达式
你也可以使用子查询通过评估条件是否为true或false来过滤行。为此,我们可以使用子查询表达式,这是一种将关键字与子查询结合的表达式,通常用于WHERE子句中,根据另一个表中是否存在某些值来过滤行。
PostgreSQL 文档在www.postgresql.org/docs/current/functions-subquery.html中列出了可用的子查询表达式,但在这里我们将检查两种最常用的子查询语法:IN和EXISTS。在此之前,运行清单 13-7 中的代码,创建一个名为retirees的小表,我们将与第七章中构建的employees表一起查询。我们假设已经从供应商处收到数据,列出了申请退休福利的人。
CREATE TABLE retirees (
id int,
first_name text,
last_name text
);
INSERT INTO retirees
VALUES (2, 'Janet', 'King'),
(4, 'Michael', 'Taylor');
清单 13-7:创建并填充retirees表
现在让我们在一些子查询表达式中使用这个表。
为 IN 运算符生成值
子查询表达式 IN (``subquery``) 的工作方式与第三章中 IN 运算符的例子类似,只不过我们使用一个子查询来提供检查的值列表,而不是手动输入一个。在清单 13-8 中,我们使用一个不相关的子查询,它将执行一次,生成来自 retirees 表的 id 值。它返回的值成为 WHERE 子句中 IN 运算符的列表。这使我们能够找到也出现在退休人员表中的员工。
SELECT first_name, last_name
FROM employees
WHERE emp_id IN (
SELECT id
FROM retirees)
ORDER BY emp_id;
清单 13-8:生成 IN 运算符的值
运行查询时,输出将显示 employees 中的两个人,他们的 emp_id 在 retirees 表中有匹配的 id:
first_name last_name
---------- ---------
Janet King
Michael Taylor
检查值是否存在
子查询表达式 EXISTS (``subquery``) 如果括号中的子查询返回至少一行,则返回 true,如果没有返回任何行,则 EXISTS 评估为 false。
在清单 13-9 中的 EXISTS 子查询表达式展示了一个关联子查询的例子——它在 WHERE 子句中包含一个需要外部查询数据的表达式。此外,由于子查询是关联的,它会对外部查询返回的每一行执行一次,每次检查 retirees 中是否有一个与 employees 中的 emp_id 匹配的 id。如果有匹配,EXISTS 表达式返回 true。
SELECT first_name, last_name
FROM employees
WHERE EXISTS (
SELECT id
FROM retirees
WHERE id = employees.emp_id);
清单 13-9:使用 WHERE EXISTS 的关联子查询
当你运行代码时,它应该返回与清单 13-8 中相同的结果。使用这种方法特别有帮助,特别是当你需要连接多个列时,这是 IN 表达式无法做到的。你还可以在 EXISTS 中添加 NOT 关键字来执行相反的操作,查找员工表中没有与 retirees 表中的记录相对应的行,如清单 13-10 所示。
SELECT first_name, last_name
FROM employees
WHERE NOT EXISTS (
SELECT id
FROM retirees
WHERE id = employees.emp_id);
清单 13-10:使用 WHERE NOT EXISTS 的关联子查询
这应该会产生以下结果:
first_name last_name
---------- ---------
Julia Reyes
Arthur Pappas
使用 NOT 和 EXISTS 的技巧对于查找缺失值或评估数据集是否完整非常有用。
使用 LATERAL 的子查询
在 FROM 子句中放置 LATERAL 关键字在子查询之前,增加了几个功能,这有助于简化本来复杂的查询。
使用 LATERAL 与 FROM
首先,前面加上 LATERAL 的子查询可以引用在 FROM 子句中出现在它之前的表和其他子查询,这可以通过使计算易于重用来减少冗余代码。
清单 13-11 通过两种方式计算从 2018 年到 2019 年县人口的变化:原始变化数和百分比变化。
SELECT county_name,
state_name,
pop_est_2018,
pop_est_2019,
raw_chg,
round(pct_chg * 100, 2) AS pct_chg
FROM us_counties_pop_est_2019,
1 LATERAL (SELECT pop_est_2019 - pop_est_2018 AS raw_chg) rc,
2 LATERAL (SELECT raw_chg / pop_est_2018::numeric AS pct_chg) pc
ORDER BY pct_chg DESC;
清单 13-11:在 FROM 子句中使用 LATERAL 子查询
在FROM子句中,在指定us_counties_pop_est_2019表后,我们添加了第一个LATERAL子查询 1。在括号内,我们编写一个查询,计算 2018 年人口估算与 2019 年估算的差值,并将结果别名为raw_chg。由于LATERAL子查询可以引用FROM子句中之前列出的表,而无需指定其名称,因此我们可以省略us_counties_pop_est_2019表在子查询中的引用。FROM中的子查询必须有别名,因此我们将其标记为rc。
第二个LATERAL子查询 2 计算 2018 年到 2019 年人口的百分比变化。为了找到百分比变化,我们必须知道原始变化量。我们可以通过引用前一个子查询中的raw_chg值来避免重新计算它。这有助于使我们的代码更简洁、更易读。
查询结果应如下所示:
county_name state_name pop_est_2018 pop_est_2019 raw_chg pct_chg
---------------- ------------ ------------ ------------ ------- -------
Loving County Texas 148 169 21 14.19
McKenzie County North Dakota 13594 15024 1430 10.52
Loup County Nebraska 617 664 47 7.62
Kaufman County Texas 128279 136154 7875 6.14
Williams County North Dakota 35469 37589 2120 5.98
`--snip--`
LATERAL与JOIN
将LATERAL与JOIN结合使用,创建了类似于编程语言中for 循环的功能:对于LATERAL连接前由查询生成的每一行,在LATERAL连接之后的子查询或函数将被评估一次。
我们将重新使用第二章中的teachers表,并创建一个新表记录每次教师刷卡解锁实验室门的时间。我们的任务是找到教师访问实验室的两个最近时间。Listing 13-12 展示了代码。
1 ALTER TABLE teachers ADD CONSTRAINT id_key PRIMARY KEY (id);
2 CREATE TABLE teachers_lab_access (
access_id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
access_time timestamp with time zone,
lab_name text,
teacher_id bigint REFERENCES teachers (id)
);
3 INSERT INTO teachers_lab_access (access_time, lab_name, teacher_id)
VALUES ('2022-11-30 08:59:00-05', 'Science A', 2),
('2022-12-01 08:58:00-05', 'Chemistry B', 2),
('2022-12-21 09:01:00-05', 'Chemistry A', 2),
('2022-12-02 11:01:00-05', 'Science B', 6),
('2022-12-07 10:02:00-05', 'Science A', 6),
('2022-12-17 16:00:00-05', 'Science B', 6);
SELECT t.first_name, t.last_name, a.access_time, a.lab_name
FROM teachers t
4 LEFT JOIN LATERAL (SELECT *
FROM teachers_lab_access
5 WHERE teacher_id = t.id
ORDER BY access_time DESC
LIMIT 2)6 a
7 ON true
ORDER BY t.id;
Listing 13-12: 使用子查询与LATERAL连接
首先,我们使用ALTER TABLE为teachers表添加主键 1(在第二章中我们没有在此表上设置约束,因为我们只是介绍了创建表的基础知识)。接下来,我们创建一个简单的teachers_lab_access表 2,包含记录实验室名称和访问时间戳的列。该表有一个替代主键access_id,并且外键teacher_id引用了teachers表中的id。最后,我们使用INSERT3 语句向表中添加了六行数据。
现在我们准备查询数据。在我们的SELECT语句中,我们通过LEFT JOIN将teachers连接到一个子查询。我们添加了LATERAL4 关键字,这意味着对于从teachers返回的每一行,子查询将执行,返回该教师访问的两个最近的实验室及其访问时间。使用LEFT JOIN将返回所有来自teachers的行,无论子查询是否找到匹配的教师在teachers_lab_access中。
在WHERE子句中,子查询通过外键teacher_lab_access引用外部查询。此LATERAL连接语法要求子查询具有别名 6,这里为a,并且在JOIN子句的ON部分需要设置true的值 7。在这种情况下,true使我们能够创建连接,而不需要指定连接的特定列。
执行查询后,结果应如下所示:
first_name last_name access_time lab_name
---------- --------- ---------------------- ------------
Janet Smith
Lee Reynolds 2022-12-21 09:01:00-05 Chemistry A
Lee Reynolds 2022-12-01 08:58:00-05 Chemistry B
Samuel Cole
Samantha Bush
Betty Diaz
Kathleen Roush 2022-12-17 16:00:00-05 Science B
Kathleen Roush 2022-12-07 10:02:00-05 Science A
访问表中两个教师的 ID 显示了他们最近的两次实验室访问时间。没有访问实验室的教师显示 NULL 值;如果我们希望从结果中删除这些教师,可以用 INNER JOIN(或直接使用 JOIN)代替 LEFT JOIN。
接下来,让我们探索另一种处理子查询的语法。
使用公用表表达式
公用表表达式(CTE)是标准 SQL 中的一个相对较新的功能,它允许你使用一个或多个 SELECT 查询预定义临时表,并在主查询中根据需要反复引用这些临时表。CTE 非正式地称为 WITH 查询,因为它们是通过 WITH ... AS 语句定义的。以下示例展示了使用 CTE 的一些优势,包括代码更简洁、冗余更少。
列表 13-13 显示了一个基于我们普查估算数据的简单公用表表达式(CTE)。该代码确定了每个州中有多少县的人口达到 100,000 或以上。我们来逐步分析这个例子。
1 WITH large_counties (county_name, state_name, pop_est_2019)
AS (
2 SELECT county_name, state_name, pop_est_2019
FROM us_counties_pop_est_2019
WHERE pop_est_2019 >= 100000
)
3 SELECT state_name, count(*)
FROM large_counties
GROUP BY state_name
ORDER BY count(*) DESC;
列表 13-13:使用简单的 CTE 计算大县
WITH ... AS 语句 1 定义了临时表 large_counties。在 WITH 后,我们命名表并在括号中列出其列名。与 CREATE TABLE 语句中的列定义不同,我们不需要提供数据类型,因为临时表继承了来自子查询 2 的数据类型,子查询用括号括起来并位于 AS 后面。子查询必须返回与临时表中定义的列数相同的列,但列名不需要匹配。如果不重新命名列,则列列表是可选的;这里我列出了它以便你看到语法。
主查询 3 按 state_name 对 large_counties 中的行进行计数和分组,然后按降序排列计数。结果的前六行应该是这样的:
state_name count
-------------------- -----
Texas 40
Florida 36
California 35
Pennsylvania 31
New York 28
North Carolina 28
`--snip--`
德克萨斯州、佛罗里达州和加利福尼亚州是人口在 100,000 以上的县最多的州之一。
列表 13-14 使用 CTE 将 列表 13-4 中派生表的连接(查找每个州每千人中的旅游相关企业比率)改写成更易读的格式。
WITH
1 counties (st, pop_est_2018) AS
(SELECT state_name, sum(pop_est_2018)
FROM us_counties_pop_est_2019
GROUP BY state_name),
2 establishments (st, establishment_count) AS
(SELECT st, sum(establishments) AS establishment_count
FROM cbp_naics_72_establishments
GROUP BY st)
SELECT counties.st,
pop_est_2018,
establishment_count,
round((establishments.establishment_count /
counties.pop_est_2018::numeric(10,1)) * 1000, 1)
AS estabs_per_thousand
3 FROM counties JOIN establishments
ON counties.st = establishments.st
ORDER BY estabs_per_thousand DESC;
列表 13-14:在表连接中使用 CTE
在 WITH 关键字之后,我们使用子查询定义了两个表。第一个子查询 counties 1 返回每个州的 2018 年人口。第二个子查询 establishments 2 返回每个州的旅游相关企业数量。定义了这些表之后,我们通过每个表中的 st 列将它们连接 3,并计算每千人的比率。结果与 列表 13-4 中连接的派生表相同,但 列表 13-14 更容易理解。
另一个例子是,你可以使用 CTE 简化那些包含冗余代码的查询。例如,在示例 13-6 中,我们在两个位置使用了带有percentile_cont()函数的子查询来查找中位数县人口。在示例 13-15 中,我们可以将这个子查询作为 CTE 只写一次。
1 WITH us_median AS
(SELECT percentile_cont(.5)
WITHIN GROUP (ORDER BY pop_est_2019) AS us_median_pop
FROM us_counties_pop_est_2019)
SELECT county_name,
state_name AS st,
pop_est_2019,
2 us_median_pop,
3 pop_est_2019 - us_median_pop AS diff_from_median
4 FROM us_counties_pop_est_2019 CROSS JOIN us_median
5 WHERE (pop_est_2019 - us_median_pop)
BETWEEN -1000 AND 1000;
示例 13-15:使用 CTE 来减少冗余代码
在WITH关键字之后,我们定义了us_median 1,作为示例 13-6 中相同子查询的结果,该子查询使用percentile_cont()查找中位数人口。然后,我们单独引用us_median_pop列 2,作为计算列 3 的一部分,并在WHERE子句 5 中使用它。为了在SELECT过程中将该值提供给us_counties_pop_est_2019表中的每一行,我们使用了第七章中学到的CROSS JOIN 4。
这个查询返回的结果与示例 13-6 中的结果相同,但我们只需要写一次查找中位数的子查询。另一个优点是,你可以更容易地修改查询。例如,要查找人口接近 90 百分位的县,只需要在percentile_cont()的输入中将.5替换为.9,并且只需在一个地方进行修改。
可读性强的代码、更少的冗余和更容易的修改通常是使用 CTE 的理由。另一个超出本书范围的理由是能够添加RECURSIVE关键字,使 CTE 可以在 CTE 内部循环查询结果——当处理按层次组织的数据时,这个功能特别有用。一个例子是公司的人员列表,可能你想找出所有向某位高管汇报的人员。递归 CTE 会从该高管开始,然后向下循环遍历各行,找到她的直接下属,再找到这些下属的下属。你可以通过 PostgreSQL 文档了解更多关于递归查询语法的信息,网址是www.postgresql.org/docs/current/queries-with.html。
执行交叉表
交叉表提供了一种简单的方法,通过将变量以表格布局或矩阵的形式展示,从而总结和比较它们。矩阵中的行代表一个变量,列代表另一个变量,而每个行列交点处的单元格则包含一个值,例如计数或百分比。
你经常会看到交叉表格,也叫做透视表或交叉表,它们用于报告调查结果的汇总或比较成对的变量。一个常见的例子发生在选举期间,当候选人的选票按照地理位置进行统计时:
candidate ward 1 ward 2 ward 3
--------- ------ ------ ------
Collins 602 1,799 2,112
Banks 599 1,398 1,616
Rutherford 911 902 1,114
在这种情况下,候选人的名字是一个变量,选区(或城市区)是另一个变量,而交点处的单元格则保存该候选人在该选区的得票总数。让我们看看如何生成交叉表格。
安装 crosstab()函数
标准 ANSI SQL 没有交叉表函数,但 PostgreSQL 作为一个你可以轻松安装的 模块 提供了此功能。模块是 PostgreSQL 的附加功能,不是核心应用程序的一部分;它们包括与安全性、文本搜索等相关的函数。你可以在 www.postgresql.org/docs/current/contrib.html 查找 PostgreSQL 模块的列表。
PostgreSQL 的 crosstab() 函数是 tablefunc 模块的一部分。要安装 tablefunc,请在 pgAdmin 中执行以下命令:
CREATE EXTENSION tablefunc;
PostgreSQL 应该返回消息 CREATE EXTENSION。(如果你使用的是其他数据库管理系统,请查阅其文档以获取类似功能。例如,Microsoft SQL Server 有 PIVOT 命令。)
接下来,我们将创建一个基本的交叉表,以便你可以学习语法,然后我们将处理一个更复杂的情况。
汇总调查结果
假设你的公司需要一个有趣的员工活动,因此你协调了在三个办公室举办的冰淇淋聚会。问题是,人们对冰淇淋口味很挑剔。为了选择每个办公室人们喜欢的口味,你决定进行一项调查。
CSV 文件 ice_cream_survey.csv 包含了对你调查的 200 个回应。你可以在 nostarch.com/practical-sql-2nd-edition/ 下载此文件以及本书的所有资源。每一行包括一个 response_id、office 和 flavor。你需要计算每个办公室每种口味的选择人数,并以可读的方式共享结果。
在你的 analysis 数据库中,使用 示例 13-16 中的代码创建表并加载数据。确保更改文件路径为你在计算机上保存 CSV 文件的位置。
CREATE TABLE ice_cream_survey (
response_id integer PRIMARY KEY,
office text,
flavor text
);
COPY ice_cream_survey
FROM '`C:\YourDirectory\`ice_cream_survey.csv'
WITH (FORMAT CSV, HEADER);
示例 13-16:创建并填充 ice_cream_survey 表
如果你想检查数据,运行以下命令查看前五行:
SELECT *
FROM ice_cream_survey
ORDER BY response_id
LIMIT 5;
数据应该像这样:
response_id office flavor
----------- -------- ----------
1 Uptown Chocolate
2 Midtown Chocolate
3 Downtown Strawberry
4 Uptown Chocolate
5 Midtown Chocolate
看起来巧克力口味领先!但让我们通过使用 示例 13-17 中的代码生成交叉表来确认这一选择。
SELECT *
1 FROM crosstab('SELECT 2 office,
3 flavor,
4 count(*)
FROM ice_cream_survey
GROUP BY office, flavor
ORDER BY office',
5 'SELECT flavor
FROM ice_cream_survey
GROUP BY flavor
ORDER BY flavor')
6 AS (office text,
chocolate bigint,
strawberry bigint,
vanilla bigint);
示例 13-17:生成冰淇淋调查交叉表
查询以 SELECT * 语句开始,该语句从 crosstab() 函数的内容中选择所有内容。我们将两个查询作为参数传递给 crosstab() 函数;请注意,由于这些查询是参数,我们将它们放在单引号内。第一个查询生成交叉表的数据,并包含三个必需的列。第一列,office,提供交叉表的行名称。第二列,flavor,提供与第三列中提供的值相关联的类别(或列)名称。这些值将显示在表中行与列交叉的每个单元格中。在这种情况下,我们希望交叉的单元格显示每个办公室选择的每种口味的 count()。这个第一个查询本身创建了一个简单的聚合列表。
第二个查询参数 5 生成列的类别名称。crosstab() 函数要求第二个子查询只返回一列,因此我们使用 SELECT 来检索 flavor,并使用 GROUP BY 来返回该列的唯一值。
然后,我们在 AS 关键字后指定交叉表输出列的名称和数据类型 6。该列表必须与查询生成的行和列的名称顺序一致。例如,由于提供类别列的第二个查询按字母顺序排列口味,输出列列表也必须如此。
当我们运行代码时,数据将以干净、易读的交叉表形式显示:
office chocolate strawberry vanilla
-------- --------- ---------- -------
Downtown 23 32 19
Midtown 41 23
Uptown 22 17 23
一目了然,Midtown 办公室偏爱巧克力,但对草莓毫无兴趣,草莓的得票数为 NULL,表示草莓没有收到任何投票。而草莓是 Downtown 的首选,Uptown 办公室则在三种口味之间较为均衡。
城市温度读数汇总
让我们创建另一个交叉表,这次我们将使用真实数据。temperature_readings.csv 文件也可以在本书的所有资源中找到,网址为 nostarch.com/practical-sql-2nd-edition/,其中包含来自美国三个观测站点(芝加哥、西雅图和威基基——位于檀香山市区南岸的一个社区)的为期一年的每日温度读数。数据来自美国国家海洋和大气管理局(NOAA),网址为 www.ncdc.noaa.gov/cdo-web/datatools/findstation/。
CSV 文件中的每一行包含四个值:站点名称、日期以及当天的最高和最低温度。所有温度均为华氏度。对于每个城市的每个月,我们希望使用中位数最高温度来比较气候。Listing 13-18 中的代码用于创建 temperature_readings 表并导入 CSV 文件。
CREATE TABLE temperature_readings (
station_name text,
observation_date date,
max_temp integer,
min_temp integer,
CONSTRAINT temp_key PRIMARY KEY (station_name, observation_date)
);
COPY temperature_readings
FROM '`C:\YourDirectory\`temperature_readings.csv'
WITH (FORMAT CSV, HEADER);
Listing 13-18:创建并填充 temperature_readings 表
表格包含来自 CSV 文件的四列;我们使用站点名称和观测日期添加了一个自然主键。快速计数应返回 1,077 行数据。现在,让我们看看使用 Listing 13-19 交叉汇总数据会产生什么效果。
SELECT *
FROM crosstab('SELECT
1 station_name,
2 date_part(''month'', observation_date),
3 percentile_cont(.5)
WITHIN GROUP (ORDER BY max_temp)
FROM temperature_readings
GROUP BY station_name,
date_part(''month'', observation_date)
ORDER BY station_name',
'SELECT month
FROM 4 generate_series(1,12) month')
AS (station text,
jan numeric(3,0),
feb numeric(3,0),
mar numeric(3,0),
apr numeric(3,0),
may numeric(3,0),
jun numeric(3,0),
jul numeric(3,0),
aug numeric(3,0),
sep numeric(3,0),
oct numeric(3,0),
nov numeric(3,0),
dec numeric(3,0)
);
Listing 13-19:生成温度读数交叉表
交叉表的结构与 清单 13-18 中相同。crosstab() 内部的第一个子查询生成交叉表的数据,查找每个月的中位数最高温度。它提供了三个必需的列。第一个列 station_name 1 命名了行。第二列使用了第十二章中的 date_part() 函数 2,从 observation_date 中提取月份,作为交叉表的列。然后我们使用 percentile_cont(.5) 3 来查找 max_temp 的第 50 个百分位数,即中位数。我们按照站点名称和月份进行分组,以便获得每个站点每个月的中位数 max_temp。
如 清单 13-18 中所示,第二个子查询生成列的类别名称集合。我使用一个叫做 generate_series() 的函数,按照官方 PostgreSQL 文档中的方式创建从 1 到 12 的数字列表,这些数字与 date_part() 从 observation_date 提取的月份数字相匹配。
在 AS 后,我们提供了交叉表输出列的名称和数据类型。每列都是 numeric 类型,匹配分位数函数的输出。以下输出几乎如同诗篇:
station jan feb mar apr may jun jul aug sep oct nov dec
------------------------------ --- --- --- --- --- --- --- --- --- --- --- ---
CHICAGO NORTHERLY ISLAND IL US 34 36 46 50 66 77 81 80 77 65 57 35
SEATTLE BOEING FIELD WA US 50 54 56 64 66 71 76 77 69 62 55 42
WAIKIKI 717.2 HI US 83 84 84 86 87 87 88 87 87 86 84 82
我们将一组原始的每日读数转化为一个简洁的表格,展示每个站点每月的中位数最高温度。一眼看去,我们可以发现怀基基的温度始终如春,而芝加哥的中位数最高温度则从接近冰点到相当宜人不等。西雅图的温度介于两者之间。
设置交叉表确实需要时间,但将数据集以矩阵的形式展示,往往比以垂直列表的形式查看相同数据更容易进行比较。请记住,crosstab() 函数是资源密集型的,因此在查询包含百万或十亿行的集合时要小心。
使用 CASE 重新分类值
ANSI 标准 SQL 的 CASE 语句是一个 条件表达式,意味着它允许你在查询中添加“如果这样,则……”的逻辑。你可以以多种方式使用 CASE,但对于数据分析而言,它非常方便,用于将值重新分类为不同类别。你可以根据数据中的范围创建类别,并根据这些类别对值进行分类。
CASE 语法遵循以下模式:
1 CASE WHEN `condition` THEN `result`
2 WHEN `another_condition` THEN `result`
3 ELSE `result`
4 END
我们给 CASE 关键字一个值 1,然后提供至少一个 WHEN condition THEN result 子句,其中 condition 是任何数据库可以评估为 true 或 false 的表达式,例如 county = 'Dutchess County' 或 date > '1995-08-09'。如果条件为 true,则 CASE 语句返回 result 并停止检查任何进一步的条件。结果可以是任何有效的数据类型。如果条件为 false,则数据库继续评估下一个条件。
为了评估更多条件,我们可以添加可选的 WHEN ... THEN 子句 2。我们还可以提供一个可选的 ELSE 子句 3,以在没有条件为 true 时返回一个结果。如果没有 ELSE 子句,当没有条件为 true 时,语句将返回 NULL。语句以 END 关键字 4 结束。
清单 13-20 显示了如何使用 CASE 语句将温度读数重新分类为描述性分组(这些分组根据我自己对寒冷天气的偏见来命名)。
SELECT max_temp,
CASE WHEN max_temp >= 90 THEN 'Hot'
WHEN max_temp >= 70 AND max_temp < 90 THEN 'Warm'
WHEN max_temp >= 50 AND max_temp < 70 THEN 'Pleasant'
WHEN max_temp >= 33 AND max_temp < 50 THEN 'Cold'
WHEN max_temp >= 20 AND max_temp < 33 THEN 'Frigid'
WHEN max_temp < 20 THEN 'Inhumane'
ELSE 'No reading'
END AS temperature_group
FROM temperature_readings
ORDER BY station_name, observation_date;
清单 13-20:使用 CASE 重新分类温度数据
我们为 temperature_readings 中的 max_temp 列创建了六个范围,通过比较运算符来定义。CASE 语句会评估每个值,检查六个表达式中是否有任何一个为 true。如果是,语句会输出相应的文本。请注意,这些范围涵盖了列中的所有可能值,没有任何遗漏。如果没有任何条件为 true,则 ELSE 子句将值分配到 No reading 类别。
运行代码;输出的前五行应该如下所示:
max_temp temperature_group
-------- -----------------
31 Frigid
34 Cold
32 Frigid
32 Frigid
34 Cold
`--snip--`
现在我们已经将数据集压缩成六个类别,让我们用这些类别来比较表中三个城市的气候。
在公用表表达式中使用 CASE
我们在上一节中使用 CASE 对温度数据进行的操作是一个很好的示例,展示了你可以在 CTE 中使用的预处理步骤。现在我们已经将温度分组,接下来让我们在 CTE 中按城市统计这些分组,看看每个温度类别包含了多少天。
清单 13-21 显示了重新分类日最高温度的代码,重新生成 temps_collapsed CTE 并将其用于分析。
1 WITH temps_collapsed (station_name, max_temperature_group) AS
(SELECT station_name,
CASE WHEN max_temp >= 90 THEN 'Hot'
WHEN max_temp >= 70 AND max_temp < 90 THEN 'Warm'
WHEN max_temp >= 50 AND max_temp < 70 THEN 'Pleasant'
WHEN max_temp >= 33 AND max_temp < 50 THEN 'Cold'
WHEN max_temp >= 20 AND max_temp < 33 THEN 'Frigid'
WHEN max_temp < 20 THEN 'Inhumane'
ELSE 'No reading'
END
FROM temperature_readings)
2 SELECT station_name, max_temperature_group, count(*)
FROM temps_collapsed
GROUP BY station_name, max_temperature_group
ORDER BY station_name, count(*) DESC;
清单 13-21:在 CTE 中使用 CASE
这段代码重新分类了温度,然后按站点名称进行计数和分组,以找到每个城市的一般气候分类。WITH 关键字定义了 temps_collapsed CTE 1,它有两列:station_name 和 max_temperature_group。然后我们在 CTE 2 上运行 SELECT 查询,执行简单的 count(*) 和 GROUP BY 操作。结果应该如下所示:
station_name max_temperature_group count
------------------------------ --------------------- -----
CHICAGO NORTHERLY ISLAND IL US Warm 133
CHICAGO NORTHERLY ISLAND IL US Cold 92
CHICAGO NORTHERLY ISLAND IL US Pleasant 91
CHICAGO NORTHERLY ISLAND IL US Frigid 30
CHICAGO NORTHERLY ISLAND IL US Inhumane 8
CHICAGO NORTHERLY ISLAND IL US Hot 8
SEATTLE BOEING FIELD WA US Pleasant 198
SEATTLE BOEING FIELD WA US Warm 98
SEATTLE BOEING FIELD WA US Cold 50
SEATTLE BOEING FIELD WA US Hot 3
WAIKIKI 717.2 HI US Warm 361
WAIKIKI 717.2 HI US Hot 5
使用这一分类方案,令人惊讶的一致的威基基天气,361 天的 Warm 最高气温,证明了它作为度假胜地的吸引力。从温度角度来看,西雅图也不错,几乎有 300 天的 Pleasant 或 Warm 高温(尽管这与西雅图传奇般的降雨量相矛盾)。芝加哥有 30 天的 Frigid 最高气温和 8 天的 Inhumane 最高气温,可能不适合我。
总结
在本章中,你学习了如何让查询更好地为你服务。你现在可以在多个位置添加子查询,从而在分析主查询之前对数据进行更精细的过滤或预处理。你还可以使用交叉制表法将数据可视化为矩阵,并将数据重新分类为不同的组;这两种技术都为你提供了更多使用数据来发现和讲述故事的方法。干得不错!
在接下来的章节中,我们将深入探讨更多专门针对 PostgreSQL 的 SQL 技巧。我们将从处理和搜索文本以及字符串开始。
第十四章:挖掘文本以找到有意义的数据

接下来,您将学习如何使用 SQL 来转换、搜索和分析文本。您将从简单的文本整理,包括字符串格式化和模式匹配开始,然后转向更高级的分析。我们将使用两个数据集:华盛顿 DC 附近一个警长部门的少量犯罪报告和美国总统发表的演讲集。
文本提供了丰富的分析可能性。您可以从 非结构化 数据 —— 演讲、报告、新闻稿和其他文档中的段落 —— 中提取意义,通过将其转换为 结构化数据 —— 在表格中的行和列中。或者,您可以使用高级文本分析功能,例如 PostgreSQL 的全文搜索。借助这些技术,普通文本可以揭示否则可能隐藏的事实或趋势。
使用字符串函数格式化文本
PostgreSQL 具有超过 50 个内置的字符串函数,用于处理常规但必要的任务,例如大写字母、组合字符串和删除不需要的空格。其中一些是 ANSI SQL 标准的一部分,而其他一些是特定于 PostgreSQL 的。您可以在 www.postgresql.org/docs/current/functions-string.html 找到完整的字符串函数列表,但在本节中,我们将介绍您可能经常使用的几个函数。
您可以在简单查询中尝试每个函数,将函数放在 SELECT 后面,就像这样: SELECT upper('hello');。本章节中所有列表的示例和代码均可在 nostarch.com/practical-sql-2nd-edition/ 查看。
大小写格式化
大写函数会格式化文本的大小写。 upper(``string``) 函数将传递给它的字符串的所有字母字符大写。非字母字符(如数字)保持不变。例如, upper('Neal7') 返回 NEAL7。 lower(``string``) 函数将所有字母字符转换为小写,同时保持非字母字符不变。例如, lower('Randy') 返回 randy。
initcap(``string``) 函数会将每个单词的首字母大写。例如, initcap('at the end of the day') 返回 At The End Of The Day。此函数在格式化书籍或电影标题时很有用,但由于不识别缩写,因此并非始终完美。例如, initcap('Practical SQL') 返回 Practical Sql,因为它无法识别 SQL 作为缩写。
upper() 和 lower() 函数是 ANSI SQL 标准命令,但 initcap() 是特定于 PostgreSQL 的。这三个函数为您提供了足够的选项,可以将文本列重新格式化为您喜欢的大小写。请注意,大小写格式化不适用于所有语言或区域设置。
字符信息
几个函数返回关于字符串的数据,它们单独使用或与其他函数结合使用都很有帮助。char_length(``string``)函数返回字符串中字符的数量,包括任何空格。例如,char_length(' Pat ')返回值为5,因为Pat中的三个字母加上两边的空格总共是五个字符。你还可以使用非 ANSI SQL 函数length(``string``)来计算字符串的长度,这个函数有一个变体可以计算二进制字符串的长度。
position(``substring in string``)函数返回字符串中子字符串的位置。例如,position(', ' in 'Tan, Bella')返回4,因为逗号和空格字符(, )作为第一个参数传递的子字符串从主字符串Tan, Bella的第四个索引位置开始。
char_length()和position()都是 ANSI SQL 标准中的函数。
移除字符
trim(``characters from string``)函数从字符串的开头和结尾移除字符。要指定要移除的一个或多个字符,请将它们添加到函数中,并在之后添加关键字from和要更改的字符串。移除leading字符(在字符串前端)、trailing字符(在字符串末端)或both使得该函数非常灵活。
例如,trim('s' from 'socks')移除开头和结尾的s字符,返回ock。要仅移除字符串结尾的s,请在要修剪的字符前添加trailing关键字:trim(trailing 's' from 'socks')返回sock。
如果你没有指定要移除的字符,trim()默认移除字符串两端的空格。例如,trim(' Pat ')返回Pat,去除了前后的空格。为了确认修剪后字符串的长度,我们可以像这样将trim()嵌套在char_length()内部使用:
SELECT char_length(trim(' Pat '));
这个查询应该返回3,即Pat中字母的数量,这是trim(' Pat ')的结果。
ltrim(``string``, characters``)和rtrim(``string``, characters``)函数是 PostgreSQL 特定的trim()函数变体。它们从字符串的左端或右端移除字符。例如,rtrim('socks', 's')通过只移除字符串右端的s返回sock。
提取和替换字符
left(``string``, number``)和right(``string``, number``)函数,都是 ANSI SQL 标准中的,从字符串中提取并返回选定的字符。例如,从电话号码703-555-1212中提取703区号,可以使用left('703-555-1212', 3)指定从左边起第一个字符开始的前三个字符。同样,right('703-555-1212', 8)返回从右边起的八个字符:555-1212。
要在字符串中替换字符,可以使用 replace(``string``, from``, to``) 函数。例如,要将 bat 改为 cat,可以使用 replace('bat', 'b', 'c') 来指定将 bat 中的 b 替换为 c。
现在你已经掌握了字符串操作的基本函数,接下来我们来看看如何在文本中匹配更复杂的模式,并将这些模式转化为可以分析的数据。
匹配文本模式与正则表达式
正则表达式(或 regex)是一种描述文本模式的符号语言。如果你有一个明显的字符串模式(例如,四个数字后跟一个连字符,再后面是两个数字),你可以编写一个正则表达式来匹配这个模式。然后,你可以在 WHERE 子句中使用该表示法来按模式筛选行,或者使用正则表达式函数提取和处理包含相同模式的文本。
正则表达式对于初学者来说可能显得难以理解;它们需要练习才能掌握,因为它们使用的单字符符号并不直观。让表达式匹配一个模式可能需要反复尝试,而且每种编程语言在处理正则表达式时都有细微的差别。尽管如此,学习正则表达式仍然是一个值得的投资,因为你将获得类似超能力的能力,可以在许多编程语言、文本编辑器和其他应用程序中搜索文本。
在这一部分,我将提供足够的正则表达式基础,帮助你完成练习。如果想了解更多内容,推荐使用交互式在线代码测试工具,如 regexr.com/ 或 www.regexpal.com/,它们提供了表示法参考。
正则表达式表示法
使用正则表达式表示法匹配字母和数字是直接的,因为字母、数字(和某些符号)是文字,它们表示相同的字符。例如,Al 匹配 Alicia 中的前两个字符。
对于更复杂的模式,你将使用正则表达式元素的组合,如表格 14-1 中所示。
表格 14-1:正则表达式表示法基础
| 表达式 | 描述 |
|---|---|
. |
点号是一个通配符,匹配除了换行符以外的任何字符。 |
[FGz] |
方括号中的任何字符。这里是 F、G 或 z。 |
[a-z] |
字符范围。这里是小写的 a 到 z。 |
[^a-z] |
插入符号(^)表示匹配的否定。这里,表示不匹配小写的 a 到 z。 |
\w |
任何单词字符或下划线。等同于 [A-Za-z0-9_]。 |
\d |
任何数字。 |
\s |
空格字符。 |
\t |
制表符字符。 |
\n |
换行字符。 |
\r |
回车字符。 |
^ |
匹配字符串的开头。 |
| ` | 表达式 |
| --- | --- |
. |
点号是一个通配符,匹配除了换行符以外的任何字符。 |
[FGz] |
方括号中的任何字符。这里是 F、G 或 z。 |
[a-z] |
字符范围。这里是小写的 a 到 z。 |
[^a-z] |
插入符号(^)表示匹配的否定。这里,表示不匹配小写的 a 到 z。 |
\w |
任何单词字符或下划线。等同于 [A-Za-z0-9_]。 |
\d |
任何数字。 |
\s |
空格字符。 |
\t |
制表符字符。 |
\n |
换行字符。 |
\r |
回车字符。 |
^ |
匹配字符串的开头。 |
| 匹配字符串的结尾。 | |
? |
获取前一个匹配项零次或一次。 |
* |
获取前一个匹配项零次或多次。 |
+ |
获取前一个匹配项一到多次。 |
{``m``} |
获取前一个匹配项恰好 m 次。 |
{``m``,``n``} |
获取前一个匹配项,匹配次数介于 m 和 n 之间。 |
a``|``b |
管道符表示交替。找到 a 或 b。 |
( ) |
创建并报告捕获组或设置优先级。 |
(?: ) |
否定捕获组的报告。 |
使用这些正则表达式,你可以匹配各种字符,并指定匹配的次数和位置。例如,将字符放入方括号([])中,可以匹配任何单一字符或字符范围。因此,[FGz] 可以匹配单个 F、G 或 z,而 [A-Za-z] 会匹配任何大写或小写字母。
反斜杠(\)用于表示特殊字符的设计符号,例如制表符(\t)、数字(\d)或换行符(\n),后者是文本文件中的行结束字符。
有几种方法可以指定字符匹配的次数。将数字放入花括号中表示你希望匹配该次数。例如,\d{4} 匹配四个连续的数字,\d{1,4} 匹配一到四个数字。
?、* 和 + 字符为你提供了一个便捷的匹配次数表示法。例如,字符后面的加号(+)表示匹配一次或多次。因此,表达式 a+ 会在字符串 aardvark 中找到 aa 字符。
此外,圆括号表示 捕获组,你可以使用它来标识整个匹配表达式中的一部分。例如,如果你在文本中寻找 HH``:``MM``:``SS 的时间格式,并且只想报告小时部分,可以使用诸如 (\d{2}):\d{2}:\d{2} 的表达式。它会寻找两个数字(\d{2})表示小时,后跟冒号,再是两个数字表示分钟和冒号,最后是两位数字表示秒。通过将第一个 \d{2} 放入圆括号中,你可以仅提取这两个数字,尽管整个表达式匹配的是完整的时间。
表格 14-2 显示了结合正则表达式以捕获句子 “The game starts at 7 p.m. on May 2, 2024.” 中不同部分的示例。
表格 14-2:正则表达式匹配示例
| 表达式 | 匹配内容 | 结果 |
|---|---|---|
.+ |
匹配任意字符一次或多次 | The game starts at 7 p.m. on May 2, 2024. |
\d{1,2} (?:a.m.|p.m.) |
一个或两个数字后跟空格和非捕获组中的 a.m. 或 p.m. | 7 p.m. |
^\w+ |
以一个或多个单词字符开始 | The |
| `\w+. | 表达式 | 匹配内容 |
| --- | --- | --- |
.+ |
匹配任意字符一次或多次 | The game starts at 7 p.m. on May 2, 2024. |
\d{1,2} (?:a.m.|p.m.) |
一个或两个数字后跟空格和非捕获组中的 a.m. 或 p.m. | 7 p.m. |
^\w+ |
以一个或多个单词字符开始 | The |
| 以一个或多个单词字符后跟任意字符结尾 | 2024. |
|
May|June |
匹配 May 或 June 中的任意一个 | May |
\d{4} |
四个数字 | 2024 |
May \d, \d{4} |
May 后跟一个空格、数字、逗号、空格和四个数字 | May 2, 2024 |
这些结果展示了正则表达式在匹配我们感兴趣的字符串部分时的实用性。例如,为了查找时间,我们使用表达式 \d{1,2} (?:a.m.|p.m.) 来查找一个或两个数字,因为时间可能是一个或两个数字,后面跟着一个空格。然后我们查找 a.m. 或 p.m.;管道符号分隔这两个术语表示“或者”的条件,括号将它们与表达式的其余部分分开。我们需要 ?: 符号来表示我们不希望将括号中的术语作为捕获组处理,这样只会返回 a.m. 或 p.m.。?: 确保返回完整的匹配项。
你可以通过将文本和正则表达式放入 substring(``string from pattern``) 函数中来使用这些正则表达式,以返回匹配的文本。例如,要查找四位数的年份,可以使用以下查询:
SELECT substring('The game starts at 7 p.m. on May 2, 2024.' from '\d{4}');
这个查询应该返回 2024,因为我们指定了模式应查找连续的四个数字,而 2024 是该字符串中唯一符合这些条件的部分。你可以查看本书代码资源中表格 14-2 的所有示例 substring() 查询,网址为 nostarch.com/practical-sql-2nd-edition/。
在 WHERE 中使用正则表达式
你已经在 WHERE 子句中使用过 LIKE 和 ILIKE 进行查询过滤。在本节中,你将学习如何在 WHERE 子句中使用正则表达式,从而进行更复杂的匹配。
我们使用波浪号(~)对正则表达式进行区分大小写的匹配,使用波浪号星号(~*)进行不区分大小写的匹配。你可以通过在前面加上感叹号来取反这两种表达式。例如,!~* 表示 不 匹配不区分大小写的正则表达式。清单 14-1 显示了如何使用之前练习中的 2019 年美国人口普查估算数据表 us_counties_pop_est_2019 来演示这个过程。
SELECT county_name
FROM us_counties_pop_est_2019
1 WHERE county_name ~* '(lade|lare)'
ORDER BY county_name;
SELECT county_name
FROM us_counties_pop_est_2019
2 WHERE county_name ~* 'ash' AND county_name !~ 'Wash'
ORDER BY county_name;
清单 14-1:在 WHERE 子句中使用正则表达式
第一个 WHERE 子句 1 使用波浪号星号(~*)对正则表达式 (lade|lare) 进行不区分大小写的匹配,以查找包含 lade 或 lare 的县名。结果应该显示八行:
county_name
-------------------
Bladen County
Clare County
Clarendon County
Glades County
Langlade County
Philadelphia County
Talladega County
Tulare County
如你所见,每个县名都包含字母 lade 或 lare。
第二个 WHERE 子句 2 使用波浪号星号(~*)以及取反的波浪号(!~)来查找包含字母 ash 但不包含 Wash 的县名。这个查询应该返回以下结果:
county_name
--------------
Ashe County
Ashland County
Ashland County
Ashley County
Ashtabula County
Nash County
Wabash County
Wabash County
Wabasha County
这个输出中的九个县名都包含字母 ash,但没有一个包含 Wash。
这些是相对简单的示例,但你可以使用正则表达式进行更复杂的匹配,这是仅使用 LIKE 和 ILIKE 的通配符无法完成的。
正则表达式函数用于替换或拆分文本
示例 14-2 展示了三个替换和拆分文本的正则表达式函数。
1 SELECT regexp_replace('05/12/2024', '\d{4}', '2023');
2 SELECT regexp_split_to_table('Four,score,and,seven,years,ago', ',');
3 SELECT regexp_split_to_array('Phil Mike Tony Steve', ' ');
示例 14-2:用于替换和拆分文本的正则表达式函数
regexp_replace(``string``, pattern``, replacement text``) 函数允许你用替代文本替换匹配的模式。在示例 1 中,我们使用 \d{4} 在日期字符串 05/12/2024 中查找任何连续的四个数字。找到后,我们将它们替换为替代文本 2023。该查询的结果是返回文本 05/12/2023。
regexp_split_to_table(``string``, pattern``) 函数将分隔的文本拆分成行。示例 14-2 使用此函数按逗号拆分字符串 'Four,score,and,seven,years,ago',结果是生成一个行集,每行包含一个单词:
regexp_split_to_table
---------------------
Four
score
and
seven
years
ago
在完成本章末尾的“自己动手试试”练习时,记得牢记这个函数。
regexp_split_to_array(``string``, pattern``) 函数将分隔的文本拆分成一个数组。示例将字符串 Phil Mike Tony Steve 按照空格拆分成 3 部分,返回的文本数组在 pgAdmin 中应如下所示:
regexp_split_to_array
----------------------
{Phil,Mike,Tony,Steve}
pgAdmin 的列标题中的 text[] 符号和结果周围的花括号确认这确实是一个数组类型,这为进一步分析提供了另一种方式。例如,你可以使用 array_length() 函数来计算单词的数量,如示例 14-3 所示。
SELECT array_length(regexp_split_to_array('Phil Mike Tony Steve', ' '), 1 1);
示例 14-3:查找数组长度
regexp_split_to_array() 生成的数组是一维的;也就是说,结果包含一个名称列表。数组可以有更多的维度——例如,二维数组可以表示一个包含行和列的矩阵。因此,我们将 1 作为第二个参数传递给 array_length(),表示我们希望获得数组第一维(也是唯一维度)的长度。查询应返回 4,因为该数组有四个元素。你可以在 www.postgresql.org/docs/current/functions-array.html 中阅读更多关于 array_length() 和其他数组函数的信息。
如果你能在文本中识别出模式,可以使用正则表达式符号的组合来定位它。当文本中存在重复模式时,这种技术特别有用,特别是当你想将其转换为一组数据进行分析时。让我们通过一个实际例子来练习如何使用正则表达式函数。
使用正则表达式函数将文本转换为数据
华盛顿特区某郊区的一个警长部门发布每日报告,详细说明该部门调查的事件的日期、时间、地点和描述。这些报告本可以很好地进行分析,但它们将信息发布在保存为 PDF 文件的 Microsoft Word 文档中,这种格式并不适合导入到数据库中。
如果我将 PDF 中的事件复制并粘贴到文本编辑器中,结果将是类似清单 14-4 的文本块。
1 4/16/17-4/17/17
2 2100-0900 hrs.
3 46000 Block Ashmere Sq.
4 Sterling
5 Larceny: 6 The victim reported that a
bicycle was stolen from their opened
garage door during the overnight hours.
7 C0170006614
04/10/17
1605 hrs.
21800 block Newlin Mill Rd.
Middleburg
Larceny: A license plate was reported
stolen from a vehicle.
SO170006250
清单 14-4:犯罪报告文本
每段文本包括日期 1、时间 2、街道地址 3、城市或城镇 4、犯罪类型 5 和事件描述 6。最后一项信息是一个代码 7,可能是事件的唯一标识符,尽管我们需要向警长办公室确认才能确保。存在一些小的不一致。例如,第一段文本有两个日期(4/16/17-4/17/17)和两个时间段(2100-0900 hrs.),这意味着事件的具体时间不确定,很可能发生在这个时间段内。第二段则只有一个日期和时间。
如果您定期汇编这些报告,您可以期望找到一些有价值的见解,帮助回答一些重要问题:犯罪通常发生在哪里?哪些犯罪类型最常发生?它们是更多发生在周末还是工作日?在您开始回答这些问题之前,您需要使用正则表达式将文本提取到表格列中。
创建犯罪报告表
我已经将五个犯罪事件收集到一个名为crime_reports.csv的文件中,您可以通过本书资源链接nostarch.com/practical-sql-2nd-edition/下载该文件并保存在您的计算机上。下载文件后,使用清单 14-5 中的代码构建一个表格,其中每一列用于解析文本中的数据元素,使用正则表达式进行解析。
CREATE TABLE crime_reports (
crime_id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
case_number text,
date_1 timestamptz,
date_2 timestamptz,
street text,
city text,
crime_type text,
description text,
original_text text NOT NULL
);
COPY crime_reports (original_text)
FROM '`C:\YourDirectory\`crime_reports.csv'
WITH (FORMAT CSV, HEADER OFF, QUOTE '"');
清单 14-5:创建并加载crime_reports表
运行清单 14-5 中的CREATE TABLE语句,然后使用COPY将文本加载到original_text列中。其余的列将为NULL,直到我们填写它们。
当您在 pgAdmin 中运行SELECT original_text FROM crime_reports;时,结果网格应显示五行数据,并展示每份报告的前几个单词。当您双击任何单元格时,pgAdmin 会显示该行的所有文本,如图 14-1 所示。

图 14-1:在 pgAdmin 结果网格中显示额外文本
现在,您已经加载了要解析的文本,让我们使用 PostgreSQL 的正则表达式函数来探索这些数据。
匹配犯罪报告日期模式
我们想从original_text中提取的第一项数据是犯罪的日期或日期。大多数报告只有一个日期,尽管有一个报告有两个日期。这些报告还包括相关的时间,我们将把提取的日期和时间组合成一个时间戳。我们将把date_1填充为每份报告的第一个(或唯一)日期和时间。如果存在第二个日期或第二个时间,我们将把它添加到date_2中。
我们将使用regexp_match(``string``, pattern``)函数,它与substring()类似,但有几个例外。其中一个例外是它将每个匹配项作为数组中的文本返回。此外,如果没有匹配项,它将返回NULL。正如您可能从第六章中回忆的那样,您可以使用数组将值列表传递给percentile_cont()函数来计算四分位数。在我们解析犯罪报告时,我将向您展示如何处理返回为数组的结果。
要开始,请使用regexp_match()在每个五起事件中查找日期。用于匹配的一般模式是MM``/``DD``/``YY,尽管月份和日期都可能有一到两位数字。下面是匹配此模式的正则表达式:
\d{1,2}\/\d{1,2}\/\d{2}
在此表达式中,第一个\d{1,2}表示月份。花括号中的数字指定您希望至少有一个数字,最多有两个数字。接下来,您要查找一个正斜杠(/),但由于正斜杠在正则表达式中具有特殊含义,您必须转义该字符,方法是在其前面加上反斜杠(\),如\/所示。在这种上下文中转义字符的意思是我们希望将其视为字面值,而不是让其具有特殊含义。因此,反斜杠和正斜杠的组合(\/)表示您希望一个正斜杠。
另一个\d{1,2}后跟一个单或双位数的月份。表达式以第二个转义的正斜杠和\d{2}结尾,表示两位数年份。让我们将表达式\d{1,2}\/\d{1,2}\/\d{2}传递给regexp_match(),如图示 14-6 所示。
SELECT crime_id,
regexp_match(original_text, '\d{1,2}\/\d{1,2}\/\d{2}')
FROM crime_reports
ORDER BY crime_id;
图示 14-6:使用regexp_match()查找第一个日期
在 pgAdmin 中运行该代码,结果应如下所示:
crime_id regexp_match
-------- ------------
1 {4/16/17}
2 {4/8/17}
3 {4/4/17}
4 {04/10/17}
5 {04/09/17}
请注意,每行显示了事件的第一个列出日期,因为regexp_match()默认返回它找到的第一个匹配项。还请注意,每个日期都用花括号括起来。这是 PostgreSQL 指示regexp_match()将每个结果返回为数组类型或元素列表。稍后在“从regexp_match()结果中提取文本”部分,我将向您展示如何访问数组中的元素。您还可以在www.postgresql.org/docs/current/arrays.html上阅读有关 PostgreSQL 数组的更多信息。
当存在第二个日期时进行匹配
我们已成功匹配了每份报告中的第一个日期。但请记住,五起事件中有一起还有第二个日期。要查找并显示文本中的所有日期,您必须使用相关的regexp_matches()函数,并传递一个选项,形式为标志g,如图示 14-7 所示。
SELECT crime_id,
regexp_matches(original_text, '\d{1,2}\/\d{1,2}\/\d{2}', 'g'1)
FROM crime_reports
ORDER BY crime_id;
图示 14-7:使用带g标志的regexp_matches()函数
regexp_matches()函数在提供g标志 1 时,与regexp_match()不同,它会将表达式找到的每个匹配项作为结果的行返回,而不仅仅是第一个匹配项。
再次运行代码并进行此修订;现在你应该能看到犯罪编号为1的事件有两个日期,像这样:
crime_id regexp_matches
-------- --------------
1 {4/16/17}
1 {4/17/17}
2 {4/8/17}
3 {4/4/17}
4 {04/10/17}
5 {04/09/17}
每当犯罪报告中有第二个日期时,我们希望将其及相关的时间加载到date_2列中。尽管添加g标志可以显示所有日期,但为了提取报告中的第二个日期,我们可以使用两日期存在时常见的模式。在列表 14-4 中,第一段文本显示了两个通过连字符分隔的日期,像这样:
4/16/17-4/17/17
这意味着你可以切换回regexp_match(),编写一个正则表达式来查找一个连字符后跟日期,如列表 14-8 所示。
SELECT crime_id,
regexp_match(original_text, '-\d{1,2}\/\d{1,2}\/\d{2}')
FROM crime_reports
ORDER BY crime_id;
列表 14-8:使用regexp_match()查找第二个日期
尽管此查询找到第一个项中的第二个日期(并对其余项返回NULL),但它也有一个意外的后果:它会显示连字符。
crime_id regexp_match
-------- ------------
1 {-4/17/17}
2
3
4
5
你不想包括连字符,因为它是timestamp数据类型的无效格式。幸运的是,你可以通过在正则表达式的目标部分周围加上括号来指定确切的返回部分,从而创建一个捕获组,像这样:
-(\d{1,2}/\d{1,2}/\d{1,2})
这个符号仅返回正则表达式中你想要的部分。运行修改后的查询,在列表 14-9 中仅报告括号内的数据。
SELECT crime_id,
regexp_match(original_text, '-(\d{1,2}\/\d{1,2}\/\d{2})')
FROM crime_reports
ORDER BY crime_id;
列表 14-9:使用捕获组仅返回日期
在列表 14-9 中的查询应该仅返回第二个日期,而不带前导的连字符,如下所示:
crime_id regexp_match
-------- ------------
1 {4/17/17}
2
3
4
5
你刚刚完成的过程是典型的。你从要分析的文本开始,然后编写和优化正则表达式,直到它找到你想要的数据。到目前为止,我们已经创建了正则表达式,用于匹配第一个日期和第二个日期(如果存在)。现在,让我们使用正则表达式提取更多的数据元素。
匹配附加的犯罪报告元素
在这一部分中,我们将从犯罪报告中提取时间、地址、犯罪类型、描述和案件编号。以下是用于捕获这些信息的表达式:
第一个小时 \/\d{2}\n(\d{4})
- 第一个小时,即犯罪发生的时间或时间范围的开始,始终跟在每个犯罪报告中的日期后面,像这样:
4/16/17-4/17/17
2100-0900 hrs.
- 为了找到第一个小时,我们从一个转义的正斜杠和
\d{2}开始,\d{2}代表第一个日期前的两位数年份(17)。\n字符表示换行符,因为小时总是从新的一行开始,\d{4}表示四位数字的小时(2100)。由于我们只想返回四个数字,我们将\d{4}放入括号中作为捕获组。
第二小时 \/\d{2}\n\d{4}-(\d{4})
- 如果第二小时存在,它将跟在连字符后面,因此我们在刚刚为第一个小时创建的表达式中添加了一个连字符和另一个
\d{4}。再次强调,第二个\d{4}放入捕获组中,因为我们只希望返回0900这个小时。
街道 hrs.\n(\d+ .+(?:Sq.|Plz.|Dr.|Ter.|Rd.))
- 在这些数据中,街道总是跟随时间的
hrs.标识符,并在换行符(\n)之后,如下所示:
04/10/17
1605 hrs.
21800 block Newlin Mill Rd.
- 街道地址总是以某个长度不等的数字开头,并以某种缩写后缀结尾。为了描述这个模式,我们使用
\d+来匹配出现一次或多次的任何数字。然后我们指定一个空格,使用点通配符和加号(.+)符号来查找任何字符,出现一次或多次。表达式以一系列由交替管道符号分隔的术语结尾,格式如下:(?:Sq.|Plz.|Dr.|Ter.|Rd.)。这些术语被括号括起来,因此表达式会匹配这些术语中的任何一个。当我们像这样分组术语时,如果我们不希望括号作为捕获组,我们需要在括号前加上?:来消除这一效果。
城市 (?:Sq.|Plz.|Dr.|Ter.|Rd.)\n(\w+ \w+|\w+)\n
- 因为城市总是跟随街道后缀,我们会重用之前为街道创建的由交替符号分隔的术语。接着,我们添加一个换行符(
\n),然后使用捕获组查找两个单词或一个单词(\w+ \w+|\w+),之后是一个最终的换行符,因为一个城镇或城市名称可能不止一个单词。
犯罪类型 \n(?:\w+ \w+|\w+)\n(.*):
- 犯罪类型总是位于冒号前(每个报告中唯一使用冒号的地方),并可能由一个或多个单词组成,如下所示:
`--snip--`
Middleburg
Larceny: A license plate was reported
stolen from a vehicle.
SO170006250
`--snip--`
- 要创建一个匹配此模式的表达式,我们在换行符后添加一个非报告捕获组,查找一个或两个单词的城市名称。然后我们再添加一个换行符,并匹配冒号前的任何字符,使用
(.*):。
描述 :\s(.+)(?:C0|SO)
- 犯罪描述总是位于犯罪类型和案件编号之间的冒号后面。该表达式从冒号开始,接着是一个空格字符(
\s),然后是一个捕获组,用于查找出现一次或多次的任何字符,使用.+符号。非报告捕获组(?:C0|SO)告诉程序当遇到C0或SO时停止查找,这两个字符对是每个案件编号的开头(C后跟零,S后跟大写字母O)。我们必须这样做,因为描述中可能有一个或多个换行符。
案件编号 (?:C0|SO)[0-9]+
- 案件编号以
C0或SO开头,后跟一组数字。为了匹配这个模式,表达式首先在一个非报告捕获组中查找C0或SO,然后查找任意数字(0 到 9 之间)出现一次或多次,使用[0-9]范围符号。
现在让我们将这些正则表达式传递给regexp_match(),看看它们的实际效果。Listing 14-10 展示了一个示例regexp_match()查询,用于检索案件编号、日期、犯罪类型和城市。
SELECT
regexp_match(original_text, '(?:C0|SO)[0-9]+') AS case_number,
regexp_match(original_text, '\d{1,2}\/\d{1,2}\/\d{2}') AS date_1,
regexp_match(original_text, '\n(?:\w+ \w+|\w+)\n(.*):') AS crime_type,
regexp_match(original_text, '(?:Sq.|Plz.|Dr.|Ter.|Rd.)\n(\w+ \w+|\w+)\n')
AS city
FROM crime_reports
ORDER BY crime_id;
Listing 14-10:匹配案件编号、日期、犯罪类型和城市
运行代码,结果应如下所示:
case_number date_1 crime_type city
------------- ---------- ------------------------- ------------
{C0170006614} {4/16/17} {Larceny} {Sterling}
{C0170006162} {4/8/17} {"Destruction of Property"} {Sterling}
{C0170006079} {4/4/17} {Larceny} {Sterling}
{SO170006250} {04/10/17} {Larceny} {Middleburg}
{SO170006211} {04/09/17} {"Destruction of Property"} {Sterling}
经过所有这些处理后,我们已将文本转化为更适合分析的结构。当然,您需要包含更多的事件,以便按城市计算犯罪类型的频率,或按月计算犯罪数量,以识别任何趋势。
为了将每个解析的元素加载到表的列中,我们将创建一个 UPDATE 查询。但是,在您能够将文本插入列之前,您需要学习如何从 regexp_match() 返回的数组中提取文本。
从 regexp_match() 结果中提取文本
在“匹配犯罪报告日期模式”中,我提到过 regexp_match() 返回的数据是一个包含文本的数组类型。两个线索表明这些是数组类型。第一个线索是列头的数据类型标识显示为 text[] 而不是 text。第二个线索是每个结果都被大括号括起来。图 14-2 显示了 pgAdmin 如何展示列表 14-10 中的查询结果。

图 14-2:pgAdmin 结果网格中的数组值
我们想要更新的 crime_reports 列不是数组类型,因此我们需要先从数组中提取值,而不是直接传递 regexp_match() 返回的数组值。我们通过使用数组表示法来做到这一点,如列表 14-11 所示。
SELECT
crime_id,
1 (regexp_match(original_text, '(?:C0|SO)[0-9]+'))[1] 2
AS case_number
FROM crime_reports
ORDER BY crime_id;
列表 14-11:从数组中检索值
首先,我们将 regexp_match() 函数 1 用括号括起来。然后,在最后,我们提供一个值 1,表示数组中的第一个元素,并用方括号 2 括起来。查询应产生以下结果:
crime_id case_number
-------- -----------
1 C0170006614
2 C0170006162
3 C0170006079
4 SO170006250
5 SO170006211
现在,pgAdmin 列头中的数据类型标识应该显示为 text,而不是 text[],并且这些值不再被大括号括起来。我们现在可以使用 UPDATE 查询将这些值插入到 crime_reports 中。
更新 crime_reports 表中的提取数据
要开始更新 crime_reports 中的列,列表 14-12 将提取的第一个日期和时间合并成一个单一的 timestamp 值,用于 date_1 列。
UPDATE crime_reports
1 SET date_1 =
(
2 (regexp_match(original_text, '\d{1,2}\/\d{1,2}\/\d{2}'))[1]
3 || ' ' ||
4 (regexp_match(original_text, '\/\d{2}\n(\d{4})'))[1]
5 ||' US/Eastern'
6 )::timestamptz
RETURNING crime_id, date_1, original_text;
列表 14-12:更新 crime_reports 中的 date_1 列
因为date_1列是timestamp类型,我们必须提供该数据类型的输入。为此,我们将使用 PostgreSQL 的双管道符号(||)连接操作符,将提取的日期和时间组合成timestamp with time zone类型输入可接受的格式。在SET子句 1 中,我们从匹配第一个日期的正则表达式模式开始 2。接下来,我们使用两个单引号 3 将日期与空格连接,并重复连接操作符。这一步将日期与空格结合,然后再将其与匹配时间的正则表达式模式连接 4。然后,我们通过在字符串末尾使用US/Eastern时区标识符来包含华盛顿特区的时区 5。连接这些元素后,我们得到一个格式为MM``/``DD``/``YY``HH:MM``TIMEZONE的字符串,这是一个可接受的timestamp输入格式。我们使用 PostgreSQL 的双冒号简写和timestamptz缩写将该字符串转换为timestamp with time zone数据类型 6。
当你运行UPDATE语句时,RETURNING子句将显示我们指定的已更新行中的列,包括现在已填充的date_1列,以及original_text列的一部分,类似这样:
crime_id date_1 original_text
-------- ---------------------- --------------------------------------------
1 2017-04-16 21:00:00-04 4/16/17-4/17/17
2100-0900 hrs.
46000 Block Ashmere Sq.
Sterling
Larceny: The victim reported that a
bicycle was stolen from their opened
garage door during the overnight hours.
C0170006614
2 2017-04-08 16:00:00-04 4/8/17
1600 hrs.
46000 Block Potomac Run Plz.
Sterling
Destruction of Property: The victim
reported that their vehicle was spray
painted and the trim was ripped off while
it was parked at this location.
C0170006162
*--snip--*
一眼看去,你可以看到date_1准确地捕捉到原始文本中出现的第一个日期和时间,并将其转换为我们可以分析的格式——例如,量化一天中犯罪最常发生的时间。请注意,如果你不在东部时区,时间戳将反映你 pgAdmin 客户端的时区。另外,在 pgAdmin 中,你可能需要双击original_text列中的单元格才能查看完整的文本。
使用 CASE 处理特殊情况
你可以为每个剩余的数据元素写一个UPDATE语句,但将这些语句合并成一个会更高效。清单 14-13 使用单个语句更新所有crime_reports列,同时处理数据中的不一致值。
UPDATE crime_reports
SET date_11 =
(
(regexp_match(original_text, '\d{1,2}\/\d{1,2}\/\d{2}'))[1]
|| ' ' ||
(regexp_match(original_text, '\/\d{2}\n(\d{4})'))[1]
||' US/Eastern'
)::timestamptz,
date_22 =
CASE3
WHEN4 (SELECT regexp_match(original_text, '-(\d{1,2}\/\d{1,2}\/\d{2})') IS NULL5)
AND (SELECT regexp_match(original_text, '\/\d{2}\n\d{4}-(\d{4})') IS NOT NULL6)
THEN7
((regexp_match(original_text, '\d{1,2}\/\d{1,2}\/\d{2}'))[1]
|| ' ' ||
(regexp_match(original_text, '\/\d{2}\n\d{4}-(\d{4})'))[1]
||' US/Eastern'
)::timestamptz
WHEN8 (SELECT regexp_match(original_text, '-(\d{1,2}\/\d{1,2}\/\d{2})') IS NOT NULL)
AND (SELECT regexp_match(original_text, '\/\d{2}\n\d{4}-(\d{4})') IS NOT NULL)
THEN
((regexp_match(original_text, '-(\d{1,2}\/\d{1,2}\/\d{1,2})'))[1]
|| ' ' ||
(regexp_match(original_text, '\/\d{2}\n\d{4}-(\d{4})'))[1]
||' US/Eastern'
)::timestamptz
END,
street = (regexp_match(original_text, 'hrs.\n(\d+ .+(?:Sq.|Plz.|Dr.|Ter.|Rd.))'))[1],
city = (regexp_match(original_text,
'(?:Sq.|Plz.|Dr.|Ter.|Rd.)\n(\w+ \w+|\w+)\n'))[1],
crime_type = (regexp_match(original_text, '\n(?:\w+ \w+|\w+)\n(.*):'))[1],
description = (regexp_match(original_text, ':\s(.+)(?:C0|SO)'))[1],
case_number = (regexp_match(original_text, '(?:C0|SO)[0-9]+'))[1];
清单 14-13:更新所有crime_reports列
这个UPDATE语句看起来可能令人畏惧,但如果我们按列分解,它并不会那么复杂。首先,我们使用清单 14-9 中的相同代码来更新date_1列 1。但要更新date_2列 2,我们需要考虑第二个日期和时间的不一致性。在我们有限的数据集中,存在三种可能性:
-
存在第二个小时,但没有第二个日期。当报告涵盖同一日期的多个小时范围时,就会发生这种情况。
-
存在第二个日期和第二个小时。当报告涵盖多个日期时,会发生这种情况。
-
既没有第二个日期,也没有第二个小时。
为了在每种情况下插入正确的date_2值,我们使用CASE语句来测试每一种可能性。在CASE关键字 3 后,我们使用一系列WHEN ... THEN语句来检查前两个条件并提供插入的值;如果没有符合条件的情况,CASE语句默认返回NULL。
第一个 WHEN 语句 4 检查 regexp_match() 是否返回第二个日期的 NULL 5,以及第二个小时的值(使用 IS NOT NULL 6)。如果该条件为 true,则 THEN 语句 7 会将第一个日期与第二个小时拼接,创建更新时间戳。
第二个 WHEN 语句 8 检查 regexp_match() 是否返回第二个小时和第二个日期的值。如果为 true,那么 THEN 语句会将第二个日期与第二个小时拼接,生成时间戳。
如果两个 WHEN 语句都没有返回 true,那么 CASE 语句将返回 NULL,因为只有第一个日期和第一个时间。
当我们在清单 14-13 中运行完整查询时,PostgreSQL 应该会报告 UPDATE 5。成功!现在,我们已经更新了所有列,并且在考虑到有附加数据的元素时,我们可以检查表的所有列,找到从 original_text 中解析出的元素。清单 14-14 查询了其中的四列。
SELECT date_1,
street,
city,
crime_type
FROM crime_reports
ORDER BY crime_id;
清单 14-14:查看选定的犯罪数据
查询结果应该显示一个组织良好的数据集,类似于以下内容:
date_1 street city crime_type
---------------------- --------------------------------- ---------- ------------------
2017-04-16 21:00:00-04 46000 Block Ashmere Sq. Sterling Larceny
2017-04-08 16:00:00-04 46000 Block Potomac Run Plz. Sterling Destruction of ...
2017-04-04 14:00:00-04 24000 Block Hawthorn Thicket Ter. Sterling Larceny
2017-04-10 16:05:00-04 21800 block Newlin Mill Rd. Middleburg Larceny
2017-04-09 12:00:00-04 470000 block Fairway Dr. Sterling Destruction of ...
你已经成功地将原始文本转化为一个可以回答问题并揭示该地区犯罪故事的表。
过程的价值
编写正则表达式和编写更新表的查询可能需要时间,但通过这种方式识别和收集数据是有价值的。事实上,您遇到的一些最好的数据集正是您自己构建的。每个人都可以下载相同的数据集,但您构建的数据集是您独有的。您可以成为第一个发现并讲述数据背后故事的人。
此外,在设置好数据库和查询后,您可以反复使用它们。在这个示例中,您可以每天收集犯罪报告(无论是手动收集还是通过使用如 Python 等编程语言自动下载),以便获取一个可以不断挖掘趋势的持续数据集。
在下一部分,我们将继续探索文本,通过使用 PostgreSQL 实现一个搜索引擎。
PostgreSQL 中的全文搜索
PostgreSQL 提供了一个强大的全文搜索引擎,能够处理大量文本的搜索,类似于在线搜索工具和技术,支持像 Factiva 这样的研究数据库的搜索。让我们通过一个简单的示例来演示如何设置一个用于文本搜索的表和相关的搜索功能。
在这个例子中,我收集了自二战以来的 79 篇美国总统演讲。这些演讲大多数来自国情咨文,这些公开的文本可以通过互联网档案馆(archive.org/)和加利福尼亚大学的美国总统项目(www.presidency.ucsb.edu/)访问。你可以在president_speeches.csv文件中找到数据,以及本书的相关资源,地址为nostarch.com/practical-sql-2nd-edition/。
让我们从全文搜索特有的数据类型开始。
文本搜索数据类型
PostgreSQL 的全文搜索实现包含两种数据类型。tsvector数据类型表示要搜索的文本,并将其存储为标准化的形式。tsquery数据类型表示搜索查询词和操作符。我们来详细看看这两种类型。
使用 tsvector 存储文本为词元
tsvector数据类型将文本简化为一个排序的词元列表,词元是某种语言中的语言单位。将词元看作是没有后缀变化的词根是很有帮助的。例如,tsvector类型的列会将洗涤、已洗和正在洗存储为词元wash,同时标注每个单词在原始文本中的位置。将文本转换为tsvector还会去除那些通常在搜索中不起作用的小停用词,如the或it。
为了查看这种数据类型的工作方式,让我们将一个字符串转换为tsvector格式。示例 14-15 使用了 PostgreSQL 搜索功能to_tsvector(),该函数将文本“I am walking across the sitting room to sit with you”使用english语言搜索配置标准化为词元(lexemes)。
SELECT to_tsvector('english', 'I am walking across the sitting room to sit with you.');
示例 14-15:将文本转换为tsvector数据
执行代码后,它应该返回以下tsvector数据类型的输出:
'across':4 'room':7 'sit':6,9 'walk':3
to_tsvector()函数将单词数量从十一减少到四,去除了诸如I、am和the等无助于搜索的词。该函数还去除了词尾变化,将walking转为walk,将sitting转为sit。它按字母顺序排列单词,冒号后面的数字表示它在原始字符串中的位置,并考虑了停用词。请注意,sit被识别为出现在两个位置,一个对应sitting,一个对应sit。
使用 tsquery 创建搜索词
tsquery数据类型表示全文搜索查询,同样优化为词元。它还提供用于控制搜索的操作符。操作符示例包括用于 AND 的与号(&)、用于 OR 的管道符号(|)和用于 NOT 的感叹号(!)。<->后跟操作符可以让你搜索相邻的单词或一定距离内的单词。
示例 14-16 展示了to_tsquery()函数如何将搜索词转换为tsquery数据类型。
SELECT to_tsquery('english', 'walking & sitting');
示例 14-16:将搜索词转换为tsquery数据
运行代码后,您应该会看到生成的tsquery数据类型已将术语标准化为词元,这与要搜索的数据格式相匹配:
'walk' & 'sit'
现在,您可以使用作为tsquery存储的术语来搜索已优化为tsvector的文本。
使用@@匹配操作符进行搜索
将文本和搜索词转换为全文搜索数据类型后,您可以使用双@符号(@@)匹配操作符来检查查询是否与文本匹配。清单 14-17 中的第一个查询使用to_tsquery()来评估文本是否同时包含walking和sitting,我们通过&操作符将它们结合起来。由于to_tsvector()转换的文本中包含walking和sitting的词元,因此返回布尔值true。
SELECT to_tsvector('english', 'I am walking across the sitting room') @@
to_tsquery('english', 'walking & sitting');
SELECT to_tsvector('english', 'I am walking across the sitting room') @@
to_tsquery('english', 'walking & running');
清单 14-17:使用tsquery查询tsvector类型
然而,第二个查询返回false,因为walking和running都不在文本中。现在让我们为搜索演讲创建一个表。
创建全文搜索的表
清单 14-18 中的代码创建并填充president_speeches,该表包含一个原始文本列和一个tsvector类型的列。导入后,我们将把演讲文本转换为tsvector数据类型。请注意,为了适应我设置 CSV 文件的方式,COPY中的WITH子句有一组与我们通常使用的参数不同的参数。它是管道分隔的,并且使用&符号进行引用。
CREATE TABLE president_speeches (
president text NOT NULL,
title text NOT NULL,
speech_date date NOT NULL,
speech_text text NOT NULL,
search_speech_text tsvector,
CONSTRAINT speech_key PRIMARY KEY (president, speech_date)
);
COPY president_speeches (president, title, speech_date, speech_text)
FROM '`C:\YourDirectory\`president_speeches.csv'
WITH (FORMAT CSV, DELIMITER '|', HEADER OFF, QUOTE '@');
清单 14-18:创建并填充president_speeches表
执行查询后,运行SELECT * FROM president_speeches;以查看数据。在 pgAdmin 中,双击任何单元格以查看结果网格中不可见的额外单词。您应该会看到speech_text列中的每一行都有大量文本。
接下来,我们使用清单 14-19 中的UPDATE查询,将speech_text的内容复制到tsvector列search_speech_text中,并同时将其转换为该数据类型:
UPDATE president_speeches
1 SET search_speech_text = to_tsvector('english', speech_text);
清单 14-19:将演讲转换为tsvector并存储在search_speech_text列中
SET子句 1 使用to_tsvector()的输出填充search_speech_text。该函数的第一个参数指定了用于解析词元的语言。我们在这里使用english,但您也可以替换为spanish、german、french等其他语言(某些语言可能需要您查找并安装额外的词典)。将语言设置为simple将删除停用词,但不会将单词还原为词元。第二个参数是输入列的名称。运行代码以填充该列。
最后,我们需要对search_speech_text列进行索引,以加速搜索。你在第八章学习了索引,内容集中在 PostgreSQL 的默认索引类型——B 树。对于全文搜索,PostgreSQL 文档推荐使用广义倒排索引(GIN)。根据文档,GIN 索引包含“每个单词(词素)的索引条目,以及匹配位置的压缩列表”。有关详细信息,请参见www.postgresql.org/docs/current/textsearch-indexes.html。
你可以在列表 14-20 中使用CREATE INDEX添加 GIN 索引。
CREATE INDEX search_idx ON president_speeches USING gin(search_speech_text);
列表 14-20:为文本搜索创建 GIN 索引
现在你已经准备好使用搜索功能了。
搜索演讲文本
近 80 年的总统演讲是探索历史的沃土。例如,列表 14-21 中的查询列出了总统讨论越南问题的演讲。
SELECT president, speech_date
FROM president_speeches
1 WHERE search_speech_text @@ to_tsquery('english', 'Vietnam')
ORDER BY speech_date;
列表 14-21:查找包含Vietnam(越南)一词的演讲
在WHERE子句中,查询使用双@符号(@@)匹配运算符,将search_speech_text列(数据类型为tsvector)和查询词Vietnam进行匹配,to_tsquery()函数将其转换为tsquery数据。结果应该列出 19 个演讲,显示越南首次出现在 1961 年约翰·F·肯尼迪的国会特别报告中,并且从 1966 年起成为美国越南战争升级后反复提及的话题。
president speech_date
----------------- -----------
John F. Kennedy 1961-05-25
Lyndon B. Johnson 1966-01-12
Lyndon B. Johnson 1967-01-10
Lyndon B. Johnson 1968-01-17
Lyndon B. Johnson 1969-01-14
Richard M. Nixon 1970-01-22
Richard M. Nixon 1972-01-20
Richard M. Nixon 1973-02-02
Gerald R. Ford 1975-01-15
*--snip--*
在我们尝试更多搜索之前,让我们添加一个方法来显示搜索词在文本中的位置。
显示搜索结果位置
要查看我们的搜索词在文本中出现的位置,可以使用ts_headline()函数。该函数显示一个或多个高亮的搜索词,并用相邻的单词围绕,提供格式化显示选项、显示匹配搜索词周围的单词数以及每行文本显示多少个匹配结果。列表 14-22 展示了如何使用ts_headline()显示特定实例的tax(税收)一词的搜索结果。
SELECT president,
speech_date,
1 ts_headline(speech_text, to_tsquery('english', 'tax'),
2 'StartSel = <,
StopSel = >,
MinWords=5,
MaxWords=7,
MaxFragments=1')
FROM president_speeches
WHERE search_speech_text @@ to_tsquery('english', 'tax')
ORDER BY speech_date;
列表 14-22:使用ts_headline()显示搜索结果
为了声明ts_headline(),我们将原始的speech_text列传递给它,而不是我们在搜索函数中使用的tsvector列作为第一个参数。然后,作为第二个参数,我们传递一个to_tsquery()函数,用来指定要高亮的单词。接着,我们传递第三个参数,列出由逗号分隔的可选格式化参数。这里,我们指定了标识匹配搜索词的开始和结束字符(StartSel和StopSel)。我们还设置了显示的最小和最大总单词数(包括匹配的单词)(MinWords和MaxWords),以及使用MaxFragments显示的最大匹配片段数(或匹配实例)。这些设置是可选的,你可以根据需要调整它们。
这个查询的结果应该显示每篇演讲中最多七个单词,突出显示以tax为词根的单词:
president speech_date ts_headline
-------------------- ----------- ---------------------------------------------------------
Harry S. Truman 1946-01-21 price controls, increased <taxes>, savings bond campaigns
Harry S. Truman 1947-01-06 excise <tax> rates which, under the present
Harry S. Truman 1948-01-07 increased-after <taxes>-by more than
Harry S. Truman 1949-01-05 Congress enact new <tax> legislation to bring
Harry S. Truman 1950-01-04 considered <tax> reduction of the 80th Congress
Harry S. Truman 1951-01-08 major increase in <taxes> to meet
Harry S. Truman 1952-01-09 This means high <taxes> over the next
Dwight D. Eisenhower 1953-02-02 reduction of the <tax> burden;
Dwight D. Eisenhower 1954-01-07 brought under control. <Taxes> have begun
Dwight D. Eisenhower 1955-01-06 prices and materials. <Tax> revisions encouraged increased
*--snip--*
现在,我们可以快速看到我们搜索的术语的上下文。你也可以使用此功能为网页应用程序提供灵活的搜索展示选项。还要注意,我们不仅仅找到了精确匹配。搜索引擎识别了tax以及taxes、Tax和Taxes——这些词都是以tax为词根,不管大小写。
让我们继续尝试不同的搜索形式。
使用多个搜索条件
另一个例子是,我们可以查找总统提到transportation但没有讨论roads的演讲。我们可能希望这样做,是为了找到关注更广泛政策而非具体道路项目的演讲。为此,我们可以使用列表 14-23 中的语法。
SELECT president,
speech_date,
1 ts_headline(speech_text,
to_tsquery('english', 'transportation & !roads'),
'StartSel = <,
StopSel = >,
MinWords=5,
MaxWords=7,
MaxFragments=1')
FROM president_speeches
2 WHERE search_speech_text @@
to_tsquery('english', 'transportation & !roads')
ORDER BY speech_date;
列表 14-23:查找包含transportation但不包含roads的演讲
我们再次使用ts_headline()1 来突出显示我们搜索到的术语。在WHERE子句 2 中的to_tsquery()函数里,我们传递了transportation和roads,并用&运算符将它们组合在一起。我们在roads前面使用了感叹号(!),表示我们希望查找不包含这个词的演讲。这个查询应该能找到 15 篇符合条件的演讲。以下是前四行:
president speech_date ts_headline
----------------- ----------- -----------------------------------------------------------
Harry S. Truman 1947-01-06 such industries as <transportation>, coal, oil, steel
Harry S. Truman 1949-01-05 field of <transportation>.
John F. Kennedy 1961-01-30 Obtaining additional air <transport> mobility--and obtaining
Lyndon B. Johnson 1964-01-08 reformed our tangled <transportation> and transit policies
`--snip--`
请注意,ts_headline列中突出显示的单词包括transportation和transport。同样,to_tsquery()将transportation转换为词形transport作为搜索条件。这种数据库行为对于帮助查找相关的相关词非常有用。
查找相邻单词
最后,我们将使用距离(<->)运算符,它由小于号和大于号之间的连字符组成,用来查找相邻的单词。或者,你可以在符号之间放置一个数字,来查找相隔多个单词的术语。例如,列表 14-24 搜索任何包含military紧接着defense的演讲。
SELECT president,
speech_date,
ts_headline(speech_text,
to_tsquery('english', 'military <-> defense'),
'StartSel = <,
StopSel = >,
MinWords=5,
MaxWords=7,
MaxFragments=1')
FROM president_speeches
WHERE search_speech_text @@
to_tsquery('english', 'military <-> defense')
ORDER BY speech_date;
列表 14-24:查找defense紧跟在military后面的演讲
这个查询应该能找到五篇演讲,因为to_tsquery()将搜索条件转换为词形,演讲中识别的单词应该包括复数形式,例如military defenses。以下显示了包含相邻术语的演讲:
president speech_date ts_headline
-------------------- ----------- ---------------------------------------------------
Dwight D. Eisenhower 1956-01-05 system our <military> <defenses> are designed
Dwight D. Eisenhower 1958-01-09 direct <military> <defense> efforts, but likewise
Dwight D. Eisenhower 1959-01-09 survival--the <military> <defense> of national life
Richard M. Nixon 1972-01-20 <defense> spending. Strong <military> <defenses>
Jimmy Carter 1979-01-23 secure. Our <military> <defenses> are strong
如果你将查询条件更改为military <2> defense,数据库将返回两个词之间正好相隔两个单词的匹配项,例如短语“我们的军事和防务承诺”。
按相关性排名查询结果
你还可以使用 PostgreSQL 的两种全文搜索功能来按相关性对搜索结果进行排名。当你想了解哪段文字或演讲与你的搜索条件最相关时,这些功能会非常有帮助。
一个函数ts_rank()根据你所搜索的词汇在文本中出现的频率生成一个排名值(以可变精度的real数据类型返回)。另一个函数ts_rank_cd()则考虑了搜索词汇彼此之间的接近度。两个函数都可以接受可选参数来考虑文档长度和其他因素。它们生成的排名值是一个任意的小数,虽然有助于排序,但没有内在的意义。例如,在一次查询中生成的0.375的值,不能直接与在另一查询中生成的相同值进行比较。
例如,示例 14-25 使用ts_rank()对包含所有词汇战争、安全、威胁和敌人的演讲进行排名。
SELECT president,
speech_date,
1 ts_rank(search_speech_text,
to_tsquery('english', 'war & security & threat & enemy'))
AS score
FROM president_speeches
2 WHERE search_speech_text @@
to_tsquery('english', 'war & security & threat & enemy')
ORDER BY score DESC
LIMIT 5;
示例 14-25:使用ts_rank()进行相关性评分
在此查询中,ts_rank()函数 1 接收两个参数:search_speech_text列和一个包含搜索词的to_tsquery()函数的输出。该函数的输出被赋予别名score。在WHERE子句 2 中,我们筛选出仅包含指定搜索词的演讲。然后,我们按score的降序排列结果,并仅返回排名前五的演讲。结果应该如下所示:
president speech_date score
------------------ ----------- ----------
William J. Clinton 1997-02-04 0.35810584
George W. Bush 2004-01-20 0.29587495
George W. Bush 2003-01-28 0.28381455
Harry S. Truman 1946-01-21 0.25752166
William J. Clinton 2000-01-27 0.22214262
比尔·克林顿 1997 年的国情咨文中,战争、安全、威胁和敌人这些词出现的频率比其他演讲更高,因为他讨论了冷战和其他话题。然而,这也是表中较长的演讲之一(你可以通过使用char_length()来确定这一点,如本章前面所述)。演讲的长度会影响这些排名,因为ts_rank()会考虑给定文本中匹配词汇的数量。乔治·W·布什的两场演讲,分别发生在伊拉克战争前后,排名紧随其后。
理想情况下,我们可以比较相同长度演讲中的词频,以获得更准确的排名,但这并不总是可行的。然而,我们可以通过将标准化代码作为ts_rank()函数的第三个参数来考虑每个演讲的长度,如示例 14-26 所示。
SELECT president,
speech_date,
ts_rank(search_speech_text,
to_tsquery('english', 'war & security & threat & enemy'), 21)::numeric
AS score
FROM president_speeches
WHERE search_speech_text @@
to_tsquery('english', 'war & security & threat & enemy')
ORDER BY score DESC
LIMIT 5;
示例 14-26:按演讲长度标准化ts_rank()
添加可选代码2 1 指示该函数将score除以search_speech_text列中数据的长度。这个商值随后代表一个按文档长度标准化的分数,使得各个演讲之间可以进行公平比较。PostgreSQL 文档列出了所有可用于文本搜索的选项,包括使用文档长度和除以唯一词汇的数量。
在运行示例 14-26 中的代码后,排名应该发生变化:
president speech_date score
------------------ ----------- ----------
George W. Bush 2004-01-20 0.0001028060
William J. Clinton 1997-02-04 0.0000982188
George W. Bush 2003-01-28 0.0000957216
Jimmy Carter 1979-01-23 0.0000898701
Lyndon B. Johnson 1968-01-17 0.0000728288
与清单 14-25 中的排名结果相比,乔治·W·布什 2004 年的演讲现在位居榜首,而杜鲁门 1946 年的演讲则不再进入前五名。这可能是一个比第一个示例输出更有意义的排名,因为我们通过长度对其进行了标准化。但在两个排名结果中,五个排名最高的演讲中有三个是相同的,你可以合理地确信,这三篇演讲都值得更深入的研究,以便更好地理解包含战争术语的总统演讲。
总结
文本不仅不乏味,反而为数据分析提供了丰富的机会。在本章中,你已经学到了将普通文本转化为可以提取、量化、搜索和排序的数据的技术。在你的工作或学习中,要留意那些将事实埋藏在文本块中的日常报告。你可以使用正则表达式将它们挖掘出来,转化为结构化数据,并进行分析以发现趋势。你还可以使用搜索功能来分析文本。
在下一章,你将学习如何使用 PostgreSQL 来分析地理信息。
第十五章:使用 PostGIS 分析空间数据

现在我们来探讨 空间数据,它是指关于物体位置、形状和属性的信息——例如点、线或多边形——在地理空间中的表示。本章中,你将学习如何使用 SQL 构建和查询空间数据,并将介绍 PostgreSQL 的 PostGIS 扩展,它支持空间数据类型和功能。
空间数据已经成为我们世界数据生态系统中的一个关键组成部分。一个手机应用可以通过查询空间数据库,找到附近的咖啡店,它请求数据库返回距离你当前位置一定范围内的商店列表。政府使用空间数据来跟踪住宅和商业地块的足迹;流行病学家则用它来可视化疾病的传播。
在我们的练习中,我们将分析美国各地农贸市场的位置,以及新墨西哥州圣达菲的道路和水道。你将学习如何构建和查询空间数据类型,并结合地图投影和网格系统。你将获得从空间数据中提取信息的工具,类似于你以前分析数字和文本的方式。
我们将首先设置 PostGIS。本书练习的所有代码和数据可以通过书籍资源获取,网址为 nostarch.com/practical-sql-2nd-edition/。
启用 PostGIS 和创建空间数据库
PostGIS 是一个免费、开源的项目,由加拿大地理空间公司 Refractions Research 创建,并由国际开发团队在开放源代码地理空间基金会(OSGeo)下维护。其名称中的 GIS 部分指的是 地理信息系统,即一个允许存储、编辑、分析和显示空间数据的系统。你可以在 postgis.net/ 查找文档和更新。
如果你按照第一章中的步骤在 Windows、macOS 或 Ubuntu 版本的 Linux 上安装了 PostgreSQL,那么 PostGIS 应该已经安装在你的机器上。如果你以其他方式在 Windows 或 macOS 上安装了 PostgreSQL,或者你使用的是其他 Linux 发行版,可以按照 postgis.net/install/ 上的安装说明进行操作。
要在你的 analysis 数据库中启用 PostGIS,请打开 pgAdmin 的查询工具并运行 列表 15-1 中的语句。
CREATE EXTENSION postgis;
列表 15-1:加载 PostGIS 扩展
你将看到信息 CREATE EXTENSION,表示你的数据库已更新,包含了空间数据类型和分析功能。运行 SELECT postgis_full_version(); 以显示 PostGIS 的版本号及其已安装组件的版本。这个版本号不会与你安装的 PostgreSQL 版本匹配,但这没问题。
理解空间数据的基本构成
在学习如何查询空间数据之前,让我们先看看它在 GIS 和相关数据格式中的描述方式。这是一个重要的背景知识,但如果你想直接跳到查询部分,你可以稍后跳到本章的“理解 PostGIS 数据类型”并在之后再返回这里。
网格上的一个点是空间数据的最小构建单元。网格可能通过 x 轴和 y 轴标记,或者如果我们使用地图,则通过经纬度标记。网格可以是平面的二维网格,也可以描述三维空间,如立方体。在一些数据格式中,如基于 JavaScript 的GeoJSON,一个点除了位置外,可能还包含其他属性。我们可以通过包含经纬度的点来描述一家杂货店,此外还可以添加商店名称和营业时间等属性。
理解二维几何图形
开放地理空间联盟(OGC)和国际标准化组织(ISO)创建了一个简单特征访问模型,用于描述构建和查询二维及三维形状的标准,这些形状有时被称为几何图形。PostGIS 支持该标准。
以下是一些更常见的特征,从点开始,逐步增加复杂度:
点(Point)
- 二维或三维平面中的一个位置。在地图上,点通常是标记经纬度的一个小点。
线状对象(LineString)
- 两个或更多点,每个点通过直线连接。一个线状对象可以表示诸如道路、自行车道或溪流等特征。
多边形(Polygon)
- 一个具有三条或更多直边的二维形状,每条边由一个线状对象构成。在地图上,多边形表示像国家、州、建筑物和水体这样的对象。一个多边形可以包含一个或多个内部多边形,这些内部多边形作为大多边形内部的孔。
多点对象(MultiPoint)
- 一组点。一个单一的多点对象(MultiPoint)可以表示零售商的多个位置,每个位置的经纬度信息。
多线状对象(MultiLineString)
- 一组线状对象。一个例子是一个由多个不连续段组成的道路。
多面对象(MultiPolygon)
- 一组多边形。一个被道路分隔的土地 parcel 可以在一个多面对象(MultiPolygon)中进行分组,而不是多个单独的多边形。
图 15-1 展示了每种特征的示例。PostGIS 提供了构建、编辑和分析这些对象的功能。这些功能根据用途接受各种输入,包括经纬度、专用的文本和二进制格式以及简单特征。有些功能还接受一个可选的空间参考系统标识符(SRID),该标识符指定了放置对象的网格。

图 15-1:几何图形的可视化示例
我稍后会解释 SRID,但首先,让我们来看一下 PostGIS 函数使用的一种输入示例,称为知名文本(WKT)——这是一种基于文本的格式,用于表示几何图形。
知名文本格式
OGC 标准的 WKT 格式在一个或多个括号内指定几何类型及其坐标。坐标和括号的数量根据几何类型的不同而有所不同。表 15-1 展示了常用的几何类型及其 WKT 格式的示例。坐标以经纬度对的形式展示,但你可能会遇到使用其他度量系统的网格系统。
表 15-1: 几何形状的著名文本格式
| 几何 | 格式 | 备注 |
|---|---|---|
| 点 | POINT (-74.9 42.7) |
一个坐标对,标记一个位于 −74.9 经度和 42.7 纬度的点。 |
| 线串 | LINESTRING (-74.9 42.7, -75.1 42.7) |
由两个坐标对标记的直线,表示线段的端点。 |
| 多边形 | POLYGON ((-74.9 42.7, -75.1 42.7, -75.1 42.6, -74.9 42.7)) |
由三个不同的坐标对勾画出的三角形。虽然列出了两次,但第一对和最后一对是相同的坐标,表示我们关闭了这个形状。 |
| 多点 | MULTIPOINT (-74.9 42.7, -75.1 42.7) |
两个点,每个坐标对对应一个点。 |
| 多线串 | MULTILINESTRING ((-76.27 43.1, -76.06 43.08), (-76.2 43.3, -76.2 43.4, -76.4 43.1)) |
两条线串。第一条有两个点;第二条有三个点。 |
| 多边形 | MULTIPOLYGON (((-74.92 42.7, -75.06 42.71, -75.07 42.64, -74.92 42.7), (-75.0 42.66, -75.0 42.64, -74.98 42.64, -74.98 42.66, -75.0 42.66))) |
两个多边形。第一个是三角形,第二个是矩形。 |
这些示例创建了简单的形状,正如你将在本章后面使用 PostGIS 构建它们时所看到的那样。实际上,复杂的几何形状将包含成千上万个坐标。
投影和坐标系
在二维地图上表示地球的球面并非易事。想象一下,把地球的外层从地球仪上剥离下来,试图把它摊开在桌子上,同时保持所有大陆和海洋的连接。不可避免地,你需要拉伸地图的某些部分。这就是制图师在创建地图投影及其自己的投影坐标系统时所发生的情况。投影就是地球的一个扁平化表示,具有自己的二维坐标系统。
一些投影表示整个世界;另一些则是针对特定区域或目的。墨卡托投影具有对导航有用的特性;Google 地图和其他在线地图使用一种变体,称为Web 墨卡托投影。其转换背后的数学原理使得接近南北极的陆地面积发生了变形,导致它们看起来比实际要大得多。美国人口普查局使用阿尔伯斯投影,它最小化了失真,这是你在美国选举之夜看到的电视画面,选票在实时统计时就是使用这种投影的。
投影是从地理坐标系统派生的,地理坐标系统定义了地球上任意一点的纬度、经度和高度的网格,同时还包括地球形状等因素。每当您获取地理数据时,了解其所引用的坐标系统至关重要,这样您在编写查询时才能提供正确的信息。通常,用户文档会命名坐标系统。接下来,我们来看看如何在 PostGIS 中指定坐标系统。
空间参考系统标识符
在使用 PostGIS(以及许多 GIS 应用程序)时,您通过其唯一的 SRID 来指定坐标系统。当您在本章开始时启用了 PostGIS 扩展时,系统会创建一个表spatial_ref_sys,该表以 SRID 作为主键。该表还包含列srtext,其中包含空间参考系统的 WKT 表示及其他元数据。
In this chapter, we’ll frequently use SRID `4326`, the ID for the geographic coordinate system WGS 84\. That’s the most recent World Geodetic System (WGS) standard used by GPS, and you’ll encounter it often in spatial data. You can see the WKT representation for WGS 84 by running the code in Listing 15-2 that looks for its SRID, `4326`: ``` SELECT srtext FROM spatial_ref_sys WHERE srid = 4326; ``` Listing 15-2: Retrieving the WKT for SRID `4326` Run the query and you should get the following result, indented for readability: ``` GEOGCS["WGS 84", DATUM["WGS_1984", SPHEROID["WGS 84",6378137,298.257223563, AUTHORITY["EPSG","7030"]], AUTHORITY["EPSG","6326"]], PRIMEM["Greenwich",0, AUTHORITY["EPSG","8901"]], UNIT["degree",0.0174532925199433, AUTHORITY["EPSG","9122"]], AUTHORITY["EPSG","4326"]] ``` You don’t need to use this information for any of this chapter’s exercises, but it’s helpful to know some of the variables and how they define the projection. The `GEOGCS` keyword provides the geographic coordinate system in use. Keyword `PRIMEM` specifies the location of the *prime meridian*, or longitude 0\. To see definitions of all the variables, check the reference at [`docs.geotools.org/stable/javadocs/org/opengis/referencing/doc-files/WKT.html`](https://docs.geotools.org/stable/javadocs/org/opengis/referencing/doc-files/WKT.html). Conversely, if you ever need to find the SRID associated with a coordinate system, you can query the `srtext` column in `spatial_ref_sys` to find it. ## Understanding PostGIS Data Types Installing PostGIS adds several data types to your database. We’ll use two: `geography` and `geometry`. Both types can store spatial data, such as the points, lines, polygons, and SRIDs you just learned about, but they have important distinctions: 1. `geography`A data type based on a sphere, using the round-Earth coordinate system (longitude and latitude). All calculations occur on the globe, taking its curvature into account. This makes the math complex and limits the number of functions available to work with the `geography` type. But because Earth’s curvature is factored in, calculations for distance are more precise; you should use the `geography` data type when handling data that spans large areas. The results from calculations on the `geography` type will be expressed in meters. 2. `geometry` A data type based on a plane, using the Euclidean coordinate system. Calculations occur on straight lines as opposed to along the curvature of a sphere, making calculations for geographical distance less precise than with the `geography` data type; the results of calculations are expressed in units of whichever coordinate system you’ve designated. The PostGIS documentation at [`postgis.net/docs/using_postgis_dbmanagement.html`](https://postgis.net/docs/using_postgis_dbmanagement.html) offers guidance on when to use one or the other type. In short, if you’re working strictly with longitude/latitude data or if your data covers a large area, such as a continent or the globe, use the `geography` type, even though it limits the functions you can use. If your data covers a smaller area, the `geometry` type provides more functions and better performance. You can also convert one type to the other using `CAST`. With the background you have now, we can start working with spatial objects. ## Creating Spatial Objects with PostGIS Functions PostGIS has more than three dozen constructor functions that build spatial objects using WKT or coordinates. You can find a list at [`postgis.net/docs/reference.html#Geometry_Constructors`](https://postgis.net/docs/reference.html#Geometry_Constructors), but the following sections explain several that you’ll use in the exercises. Most PostGIS functions begin with the letters *ST*, which is an ISO naming standard that means *spatial type*. ### Creating a Geometry Type from Well-Known Text The `ST_GeomFromText(``WKT``,` `SRID``)` function creates a `geometry` data type from an input of a WKT string and an optional SRID. Listing 15-3 shows simple `SELECT` statements that generate `geometry` data types for each of the simple features described in Table 15-1. ``` SELECT ST_GeomFromText(1'POINT(-74.9233606 42.699992)', 24326); SELECT ST_GeomFromText('LINESTRING(-74.9 42.7, -75.1 42.7)', 4326); SELECT ST_GeomFromText('POLYGON((-74.9 42.7, -75.1 42.7, -75.1 42.6, -74.9 42.7))', 4326); SELECT ST_GeomFromText('MULTIPOINT (-74.9 42.7, -75.1 42.7)', 4326); SELECT ST_GeomFromText('MULTILINESTRING((-76.27 43.1, -76.06 43.08), (-76.2 43.3, -76.2 43.4, -76.4 43.1))', 4326); SELECT ST_GeomFromText('MULTIPOLYGON3(( (-74.92 42.7, -75.06 42.71, -75.07 42.64, -74.92 42.7)4, (-75.0 42.66, -75.0 42.64, -74.98 42.64, -74.98 42.66, -75.0 42.66)))', 4326); ``` Listing 15-3: Using `ST_GeomFromText()` to create spatial objects For each example, we give a WKT string as the first input and the SRID `4326` as the second. In the first example, we create a Point by inserting the WKT `POINT` string 1 as the first argument to `ST_GeomFromText()` with the SRID 2 as the optional second argument. We use the same format in the rest of the examples. Note that we don’t have to indent the coordinates. I do so here only to make the coordinate pairs more readable. Be sure to mind the number of parentheses that segregate objects, particularly in complex structures such as the MultiPolygon. For example, we need to use two opening parentheses 3 and enclose each polygon’s coordinates within another set of parentheses 4. If you run each statement separately in pgAdmin, you can view both its data output and visual representation. Upon execution, each statement should return a single column of the `geometry` data type displayed as a string of characters that looks something like this truncated example: ``` 0101000020E61000008EDA0E5718BB52C017BB7D5699594540 ... ``` The string is of the format *extended well-known binary (EWKB)*, which you typically won’t need to interpret directly. Instead, you’ll use columns of geometry (or geography) data as inputs to other functions. To see the visual representation, click the eye icon in the pgAdmin result column header. That should open a Geometry Viewer pane in pgAdmin that displays the geometry atop a map that uses OpenStreetMap as the base layer. For example, the `MULTIPOLYGON` example in Listing 15-3 should look like Figure 15-2, with a triangle and a rectangle.  Figure 15-2: Viewing geometries in pgAdmin Try viewing each example in Listing 15-3 to get to know the differences between objects. ### Creating a Geography Type from Well-Known Text To create a `geography` data type, you can use `ST_GeogFromText(``WKT``)` to convert a WKT or `ST_GeogFromText(``EWKT``)` to convert a PostGIS-specific variation called *extended WKT* that includes the SRID. Listing 15-4 shows how to pass in the SRID as part of the extended WKT string to create a MultiPoint `geography` object with three points. ``` SELECT ST_GeogFromText('SRID=4326;MULTIPOINT(-74.9 42.7, -75.1 42.7, -74.924 42.6)') ``` Listing 15-4: Using `ST_GeogFromText()` to create spatial objects Again, you can view the Points on a map by clicking the eye icon in the geography column in the pgAdmin results grid. Along with the all-purpose `ST_GeomFromText()` and `ST_GeogFromText()` functions, PostGIS includes several that are specific to creating certain spatial objects. I’ll cover those briefly next. ### Using Point Functions The `ST_PointFromText()` and `ST_MakePoint()` functions will turn a WKT `POINT` or a collection of coordinates, respectively, into a `geometry` data type. Points mark coordinates, such as longitude and latitude, which you would use to identify locations or use as building blocks of other objects, such as LineStrings. Listing 15-5 shows how these functions work. ``` SELECT 1ST_PointFromText('POINT(-74.9233606 42.699992)', 4326); SELECT 2ST_MakePoint(-74.9233606, 42.699992); SELECT 3ST_SetSRID(ST_MakePoint(-74.9233606, 42.699992), 4326); ``` Listing 15-5: Functions specific to making Points The `ST_PointFromText(``WKT``,` `SRID``)` 1 function creates a point `geometry` type from a WKT `POINT` and an optional SRID as the second input. The PostGIS docs note that the function includes validation of coordinates that makes it slower than the `ST_GeomFromText()` function. The `ST_MakePoint(``x``,` `y``,` `z``,` `m``)` 2 function creates a point `geometry` type on a two-, three-, and four-dimensional grid. The first two parameters, `x` and `y` in the example, represent longitude and latitude coordinates. You can use the optional `z` to represent altitude and `m` to represent a measure. That would allow you, for example, to mark a water fountain on a bike trail at a certain altitude and certain distance from the start of the trail. The `ST_MakePoint()` function is faster than `ST_GeomFromText()` and `ST_PointFromText()`, but if you want to specify an SRID, you’ll need to designate one by wrapping it inside the `ST_SetSRID()` 3 function. ### Using LineString Functions Now let’s examine some functions we use specifically for creating LineString `geometry` data types. Listing 15-6 shows how they work. ``` SELECT 1ST_LineFromText('LINESTRING(-105.90 35.67,-105.91 35.67)', 4326); SELECT 2ST_MakeLine(ST_MakePoint(-74.9, 42.7), ST_MakePoint(-74.1, 42.4)); ``` Listing 15-6: Functions specific to making LineStrings The `ST_LineFromText(``WKT``,` `SRID``)` 1 function creates a LineString from a WKT `LINESTRING` and an optional SRID as its second input. Like `ST_PointFromText()` earlier, this function includes validation of coordinates that makes it slower than `ST_GeomFromText()`. The `ST_MakeLine(``geom``,` `geom``)` 2 function creates a LineString from inputs that must be of the `geometry` data type. In Listing 15-6, the example uses two `ST_MakePoint()` functions as inputs to create the start and endpoint of the line. You can also pass in an `ARRAY` object with multiple points, perhaps generated by a subquery, to generate a more complex line. ### Using Polygon Functions Let’s look at three Polygon functions: `ST_PolygonFromText()`, `ST_MakePolygon()`, and `ST_MPolyFromText()`. All create `geometry` data types. Listing 15-7 shows how you can create Polygons with each. ``` SELECT 1ST_PolygonFromText('POLYGON((-74.9 42.7, -75.1 42.7, -75.1 42.6, -74.9 42.7))', 4326); SELECT 2ST_MakePolygon( ST_GeomFromText('LINESTRING(-74.92 42.7, -75.06 42.71, -75.07 42.64, -74.92 42.7)', 4326)); SELECT 3ST_MPolyFromText('MULTIPOLYGON(( (-74.92 42.7, -75.06 42.71, -75.07 42.64, -74.92 42.7), (-75.0 42.66, -75.0 42.64, -74.98 42.64, -74.98 42.66, -75.0 42.66) ))', 4326); ``` Listing 15-7: Functions specific to making Polygons The `ST_PolygonFromText(``WKT``,` `SRID``)` 1 function creates a Polygon from a WKT `POLYGON` and an optional SRID. As with the similarly named functions for creating points and lines, it includes a validation step that makes it slower than `ST_GeomFromText()`. The `ST_MakePolygon(``linestring``)` 2 function creates a Polygon from a LineString that must open and close with the same coordinates, ensuring the object is closed. This example uses `ST_GeomFromText()` to create the LineString geometry using a WKT `LINESTRING`. The `ST_MPolyFromText(``WKT``,` `SRID``)` 3 function creates a MultiPolygon from a WKT and an optional SRID. Now you have the building blocks to analyze spatial data. Next, we’ll use them to explore a set of data. ## Analyzing Farmers’ Markets Data The National Farmers’ Market Directory from the US Department of Agriculture catalogs the location and offerings of more than 8,600 “markets that feature two or more farm vendors selling agricultural products directly to customers at a common, recurrent physical location,” according to the update page linked from the main directory site at [`www.ams.usda.gov/local-food-directories/farmersmarkets/`](https://www.ams.usda.gov/local-food-directories/farmersmarkets/). Attending these markets is a fun weekend activity, so let’s use SQL spatial queries to find the closest markets. The *farmers_markets.csv* file contains a portion of the USDA data on each market, and it’s available along with the book’s resources at [`nostarch.com/practical-sql-2nd-edition/`](https://nostarch.com/practical-sql-2nd-edition/). Save the file to your computer and run the code in Listing 15-8 to create and load a `farmers_markets` table. ``` CREATE TABLE farmers_markets ( fmid bigint PRIMARY KEY, market_name text NOT NULL, street text, city text, county text, st text NOT NULL, zip text, longitude numeric(10,7), latitude numeric(10,7), organic text NOT NULL ); COPY farmers_markets FROM '`C:\YourDirectory\`farmers_markets.csv' WITH (FORMAT CSV, HEADER); ``` Listing 15-8: Creating and loading the `farmers_markets` table The table contains routine address data plus the `longitude` and `latitude` for most markets. Twenty-nine of the markets were missing those values when I downloaded the file from the USDA. An `organic` column indicates whether the market offers organic products; a hyphen (`-`) in that column indicates an unknown value. After you import the data, count the rows using `SELECT count(*) FROM farmers_markets;`. If everything imported correctly, you should have 8,681 rows. ### Creating and Filling a Geography Column To perform spatial queries on the markets’ longitude and latitude, we need to convert those coordinates into a single column with a spatial data type. Because we’re working with locations spanning the entire United States and an accurate measurement of a large spherical distance is important, we’ll use the `geography` type. After creating the column, we can update it using Points derived from the coordinates and then apply an index to speed up queries. Listing 15-9 contains the statements for doing these tasks. ``` ALTER TABLE farmers_markets ADD COLUMN geog_point geography(POINT,4326); 1 UPDATE farmers_markets SET geog_point = 2 ST_SetSRID( 3 ST_MakePoint(longitude,latitude)4::geography,4326 ); CREATE INDEX market_pts_idx ON farmers_markets USING GIST (geog_point); 5 SELECT longitude, latitude, geog_point, 6 ST_AsEWKT(geog_point) FROM farmers_markets WHERE longitude IS NOT NULL LIMIT 5; ``` Listing 15-9: Creating and indexing a `geography` column The `ALTER TABLE` statement 1 you learned in Chapter 10 with the `ADD COLUMN` option creates a column of the `geography` type called `geog_point` that will hold points and reference the WGS 84 coordinate system, which we denote using SRID `4326`. Next, we run a standard `UPDATE` statement to fill the `geog_point` column. Nested inside an `ST_SetSRID()` 2 function, the `ST_MakePoint()` 3 function takes as input the `longitude` and `latitude` columns from the table. The output, which is the `geometry` type by default, must be cast to `geography` to match the `geog_point` column type. To do this, we add the PostgreSQL-specific double-colon syntax (`::`) 4 to the output of `ST_MakePoint()`. ### Adding a Spatial Index Before you start analysis, it’s wise to add an index to the new column to speed up queries. In Chapter 8, you learned about PostgreSQL’s default index, the B-tree. A B-tree index is useful for data that you can order and search using equality and range operators, but it’s less useful for spatial objects. The reason is that you cannot easily sort GIS data along one axis. For example, the application has no way to determine which of these coordinate pairs is greatest: (0,0), (0,1), or (1,0). Instead, the makers of PostGIS include support for an index designed for spatial data called *R-tree*. In an R-tree index, each spatial item is represented in the index as a rectangle that surrounds its boundaries, and the index itself is a hierarchy of rectangles. (Find a good overview at [`postgis.net/workshops/postgis-intro/indexing.html`](https://postgis.net/workshops/postgis-intro/indexing.html).) We add a spatial index to the `geog_point` column by including the keywords `USING GIST` in the `CREATE INDEX` statement 5 in Listing 15-9. `GIST` refers to a generalized search tree (GiST), an interface to facilitate incorporating specialized indexes to the database. PostgreSQL core team member Bruce Momjian describes GiST as “a general indexing framework designed to allow indexing of complex data types.” With the index in place, we use the `SELECT` statement to view the geography data to show the newly encoded `geog_points` column. To view the extended WKT version of `geog_point`, we wrap it in a `ST_AsEWKT()` function 6 to show the extended well-known text coordinates and SRID. The results should look similar to this, with `geog_point` truncated for brevity: ``` longitude latitude geog_point st_asewkt ------------ ---------- ------------ ----------------------------------- -105.5890000 47.4154000 01010000... SRID=4326;POINT(-105.589 47.4154) -98.9530000 40.4998000 01010000... SRID=4326;POINT(-98.953 40.4998) -119.4280000 35.7610000 01010000... SRID=4326;POINT(-119.428 35.761) -92.3063000 42.1718000 01010000... SRID=4326;POINT(-92.3063 42.1718) -70.6868160 44.1129600 01010000... SRID=4326;POINT(-70.686816 44.11296)) ``` Now we’re ready to perform calculations on the points. ### Finding Geographies Within a Given Distance Several years ago, while reporting a story on farming in Iowa, I visited the massive Downtown Farmers’ Market in Des Moines. With hundreds of vendors, the market spanned several city blocks in the Iowa capital. Farming is big business there, and even though the downtown market is huge, it’s not the only one in the area. Let’s use PostGIS to find more farmers’ markets near downtown Des Moines. The PostGIS function `ST_DWithin()` returns a Boolean value of `true` if one spatial object is within a specified distance of another object. If you’re working with the `geography` data type, as we are here, you need to use meters as the distance unit. If you’re using the `geometry` type, use the distance unit specified by the SRID. Listing 15-10 uses the `ST_DWithin()` function to filter `farmers_markets` to show markets within 10 kilometers of the Downtown Farmers’ Market in Des Moines. ``` SELECT market_name, city, st, geog_point FROM farmers_markets WHERE ST_DWithin(1 geog_point, 2 ST_GeogFromText('POINT(-93.6204386 41.5853202)'), 3 10000) ORDER BY market_name; ``` Listing 15-10: Using `ST_DWithin()` to locate farmers’ markets within 10 km of a point The first input for `ST_DWithin()` is `geog_point` 1, which holds the location of each row’s market in the `geography` data type. The second input is the `ST_GeogFromText()` function 2 that returns a Point geography from WKT. The coordinates `-93.6204386` and `41.5853202` represent the longitude and latitude of the Downtown Farmers’ Market. The final input is `10000` 3, which is the number of meters in 10 kilometers. The database calculates the distance between each market in the table and the downtown market. If a market is within 10 kilometers, it is included in the results. We’re using Points here, but this function works with any geography or geometry type. If you’re working with objects such as polygons, you can use the related `ST_DFullyWithin()` function to find objects that are completely within a specified distance. Run the query; it should return nine rows (I’ve omitted the `geog_point` column for brevity): ``` market_name city st --------------------------------------- --------------- ---- Beaverdale Farmers Market Des Moines Iowa Capitol Hill Farmers Market Des Moines Iowa Downtown Farmers' Market - Des Moines Des Moines Iowa Drake Neighborhood Farmers Market Des Moines Iowa Eastside Farmers Market Des Moines Iowa Highland Park Farmers Market Des Moines Iowa Historic Valley Junction Farmers Market West Des Moines Iowa LSI Global Greens Farmers' Market Des Moines Iowa Valley Junction Farmers Market West Des Moines Iowa ``` One of these nine markets is the Downtown Farmers’ Market in Des Moines, which makes sense because its location is at the point used for comparison. The rest are other markets in Des Moines or in nearby West Des Moines. To see these points on a map, in pgAdmin’s results grid, click the eye icon in the `geog_point` column header. The geography viewer should display a map as shown in Figure 15-3.  Figure 15-3: Farmers’ markets near downtown Des Moines, Iowa This operation should be familiar: it’s a standard feature on many online maps and product apps that let you locate stores or points of interest near you. Although this list of nearby markets is helpful, it would be even better to know the exact distance of markets from downtown. We’ll use another function to report that. ### Finding the Distance Between Geographies The `ST_Distance()` function returns the minimum distance between two geometries, providing meters for geographies and SRID units for geometries. For example, Listing 15-11 finds the distance in miles from Yankee Stadium in New York City’s Bronx borough to Citi Field in Queens, home of the New York Mets. ``` SELECT ST_Distance( ST_GeogFromText('POINT(-73.9283685 40.8296466)'), ST_GeogFromText('POINT(-73.8480153 40.7570917)') ) / 1609.344 AS mets_to_yanks; ``` Listing 15-11: Using `ST_Distance()` to calculate the miles between Yankee Stadium and Citi Field To convert the distance units from meters to miles, we divide the result of `ST_Distance()` by 1609.344 (the number of meters in a mile) The result is about 6.5 miles. ``` mets_to_yanks ----------------- 6.543861827875209 ``` Let’s apply this technique to the farmers’ market data using the code in Listing 15-12. We’ll again find all farmers’ markets within 10 kilometers of the Downtown Farmers’ Market in Des Moines and show the distance in miles. ``` SELECT market_name, city, 1 round( (ST_Distance(geog_point, ST_GeogFromText('POINT(-93.6204386 41.5853202)') ) / 1609.344)2::numeric, 2 ) AS miles_from_dt FROM farmers_markets WHERE3 ST_DWithin(geog_point, ST_GeogFromText('POINT(-93.6204386 41.5853202)'), 10000) ORDER BY miles_from_dt ASC; ``` Listing 15-12: Using `ST_Distance()` for each row in `farmers_markets` The query is similar to Listing 15-10, which used `ST_DWithin()` to find markets 10 kilometers or closer to downtown, but adds the `ST_Distance()` function as a column to calculate and display the distance from downtown. I’ve wrapped the function inside `round()` 1 to trim the output. We provide `ST_Distance()` with the same two inputs we gave `ST_DWithin()` in Listing 15-10: `geog_point` and the `ST_GeogFromText()` function. The `ST_Distance()` function then calculates the distance between the points specified by both inputs, returning the result in meters. To convert to miles, we divide by `1609.344` 2, the approximate number of meters in a mile. Then, to provide the `round()` function with the correct input data type, we cast the column result to type `numeric`. The `WHERE` clause 3 uses the same `ST_DWithin()` function and inputs as in Listing 15-10. You should see the following results, ordered by distance in ascending order: ``` market_name city miles_from_dt ------------------------------------- --------------- ------------- Downtown Farmers' Market - Des Moines Des Moines 0.00 Capitol Hill Farmers Market Des Moines 1.15 Drake Neighborhood Farmers Market Des Moines 1.70 LSI Global Greens Farmers' Market Des Moines 2.30 Highland Park Farmers Market Des Moines 2.93 Eastside Farmers Market Des Moines 3.40 Beaverdale Farmers Market Des Moines 3.74 Historic Valley Junction Farmers Market West Des Moines 4.68 Valley Junction Farmers Market West Des Moines 4.70 ``` Again, you see this type of result often when you’re searching online for a store or address. You might also find the technique helpful for other analysis scenarios, such as finding all the schools within a certain distance of a known source of pollution or all the homes within five miles of an airport. ### Finding the Nearest Geographies Sometimes it’s helpful to have the database simply return the spatial objects that are in closest proximity to another object without specifying some arbitrary distance in which to search. For example, we may want to find the closest farmers’ market regardless of whether it’s 10 kilometers away or 100\. To do that, we can instruct PostGIS to implement a *K-nearest neighbors* search algorithm by using the `<->` distance operator in the `ORDER BY` clause of a query. Nearest neighbors algorithms solve a range of classification problems by identifying similar items—text recognition is an example. In this case, PostGIS will identify some number of spatial objects, represented by `K`, nearest to an object we specify. For example, let’s say we’re planning to visit the vacation spot of Bar Harbor, Maine, and want to find the three farmer’s markets closest to town. We can use the code in Listing 15-13. ``` SELECT market_name, city, st, round( (ST_Distance(geog_point, ST_GeogFromText('POINT(-68.2041607 44.3876414)') ) / 1609.344)::numeric, 2 ) AS miles_from_bh FROM farmers_markets ORDER BY geog_point <->1 ST_GeogFromText('POINT(-68.2041607 44.3876414)') LIMIT 3; ``` Listing 15-13: Using the `<->` distance operator for a nearest neighbors search The query is similar to Listing 15-12, but instead of using a `WHERE` clause with `ST_DWithin()`, we provide an `ORDER BY` clause that contains the `<->` 1 distance operator. To the left of the operator, we place the `geog_point` column; to the right we supply the WKT for the Point locating downtown Bar Harbor inside `ST_GeogFromText()`. In effect, this syntax says, “Order the results by the distance from the geography to the Point.” Adding `LIMIT 3` restricts the results to the three closest markets (the three nearest neighbors): ``` market_name city st miles_from_bh -------------------------------- ---------------- ----- ------------- Bar Harbor Eden Farmers' Market Bar Harbor Maine 0.32 Northeast Harbor Farmers' Market Northeast Harbor Maine 7.65 Southwest Harbor Farmers' Market Southwest Harbor Maine 9.56 ``` You can, of course, change the number in the `LIMIT` clause to return more or fewer results. Using `LIMIT 1`, for example, will return only the closest market. So far, you’ve learned how to work with spatial objects constructed from WKT. Next, I’ll show you a common data format used in GIS called the *shapefile* and how to bring it into PostGIS for analysis. ## Working with Census Shapefiles A *shapefile* is a GIS data file format developed by Esri, a US company known for its ArcGIS mapping visualization and analysis platform. Shapefiles are a standard file format for GIS platforms—such as ArcGIS and the open source QGIS—and are used by governments, corporations, nonprofits, and technical organizations to display, analyze, and distribute data with geographic features. Shapefiles hold information describing the shape of a feature (such as a county, a road, or a lake) plus a database with each feature’s attributes. Those attributes might include their name and other demographic descriptors. A single shapefile can contain only one type of shape, such as polygons or points, and when you load a shapefile into a GIS platform that supports visualization, you can view the shapes and query their attributes. PostgreSQL, with the PostGIS extension, lets you query the spatial data in the shapefile, which we’ll do in “Exploring the Census 2019 Counties Shapefile” and “Performing Spatial Joins” later in the chapter. First, let’s examine the structure and contents of shapefiles. ### Understanding the Contents of a Shapefile A shapefile comprises a collection of files with different extensions, each with a different purpose. Often, when you download a shapefile, it comes in a compressed archive, such as *.zip*. You’ll need to unzip it to access the individual files. Per ArcGIS documentation, these are the most common extensions you’ll encounter: 1. `.shp` Main file that stores the feature geometry. 2. `.shx` Index file that stores the index of the feature geometry. 3. `.dbf` Database table (in dBASE format) that stores the attribute information of features. 4. `.xml` XML-format file that stores metadata about the shapefile. 5. `.prj` Projection file that stores the coordinate system information. You can open this file with a text editor to view the geographic coordinate system and projection. According to the documentation, files with the first three extensions include necessary data required for working with a shapefile. The other file types are optional. You can load a shapefile into PostGIS to access its spatial objects and the attributes for each. Let’s do that next and explore some additional analysis functions. I’ve included several shapefiles with the resources for this chapter at [`nostarch.com/practical-sql-2nd-edition/`](https://nostarch.com/practical-sql-2nd-edition/). We’ll start with TIGER/Line Shapefiles from the US Census that contain the boundaries for each county or county equivalent, such as parish or borough, as of 2019\. You can learn more about this series of shapefiles at [`www.census.gov/geographies/mapping-files/time-series/geo/tiger-line-file.html`](https://www.census.gov/geographies/mapping-files/time-series/geo/tiger-line-file.html). Save *tl_2019_us_county.zip* from the book’s resources for this chapter to your computer and unzip it; the archive should contain files including those with the extensions I listed earlier. ### Loading Shapefiles If you’re using Windows, the PostGIS suite includes a Shapefile Import/Export Manager with a simple *graphical user interface (GUI)*. In recent years, builds of that GUI have become harder to find on macOS and Linux distributions, so for those operating systems we’ll instead use the command line application `shp2pgsql`. We’ll start with the Windows GUI. If you’re on macOS or Linux, skip ahead to “Importing Shapefiles Using shp2pgsql.” #### Windows Shapefile Importer/Exporter On Windows, if you followed the installation steps in Chapter 1, you should find the Shapefile Import/Export Manager by selecting **Start**▶**PostGIS Bundle** `x.y` **for PostgreSQL x64** `x.y`▶ **PostGIS Bundle** `x.y` **for PostgreSQL x64** `x.y` **Shapefile and DBF Loader Exporter**. Whatever you see in place of `x.y` should match your PostgreSQL and PostGIS versions. Click to launch the application. To establish a connection between the app and your `analysis` database, follow these steps: 1. Click **View connection details**. 2. In the dialog that opens, enter **postgres** for the username, and enter a password if you added one for the server during initial setup. 3. Ensure that Server Host has `localhost` and `5432` by default. Leave those as is unless you’re connecting to a different server or port. 4. Enter **analysis** `for the database name. Figure 15-4 shows a screenshot of what the connection should look like. Figure 15-4: Establishing the PostGIS connection in the shapefile loader` ``* Click **OK**. You should see the message `Connection Succeeded` in the log window. Now that you’ve successfully established the PostGIS connection, you can load your shapefile.* Under Options, change `DBF file character encoding` to **Latin1**—we do this because the shapefile attributes include county names with characters that require this encoding. Keep the default checked boxes, including the one to create an index on the spatial column. Click **OK**.* Click **Add File** and select *tl_2019_us_county.shp* from the location you saved it. Click **Open**. The file should appear in the Shapefile list in the loader, as shown in Figure 15-5. Figure 15-5: Specifying upload details in the shapefile loader * In the Table column, double-click to select the table name. Replace it with **us_counties_2019_shp**. Press ENTER to accept the value.* In the SRID column, double-click and enter **4269**. That’s the ID for the North American Datum 1983 coordinate system, which is often used by US federal agencies including the US Census Bureau. Again, press ENTER to accept the value.* Click **Import**.`` ````In the log window, you should see a message that ends with the following message: ``` Shapefile type: Polygon PostGIS type: MULTIPOLYGON[2] Shapefile import completed. ``` Switch to pgAdmin, and in the object browser, expand the `analysis` node and continue expanding by selecting **Schemas**▶**public**▶**Tables**. Refresh your tables by right-clicking **Tables** and selecting **Refresh** from the pop-up menu. You should see `us_counties_2019_shp` listed. Congrats! You’ve loaded your shapefile into a table. As part of the import, the shapefile loader also indexed the `geom` column. You can move ahead to the section “Exploring the Census 2019 Counties Shapefile.” #### Importing Shapefiles using shp2pgsql The Shapefile Import/Export Manager isn’t available on all PostGIS distributions for macOS and Linux. For that reason, I’ll show you how to import shapefiles using the PostGIS command line tool `shp2pgsql`, which lets you accomplish the same thing using a single text command. On macOS and Linux, you execute command line tools in the Terminal application. If you’re not familiar with working on the command line, you may want to pause here and read Chapter 18, “Using PostgreSQL from the Command Line,” to get set up. Otherwise, on macOS launch Terminal from your Applications folder (under Utilities); on Linux, open your distribution’s terminal. At the command line, you use the following syntax to import a shapefile into a new table; the italicized code here are placeholders: ``` shp2pgsql -I -s `SRID` -W `encoding shapefile_name` `table_name` | psql -d `database` -U `user` ``` A lot’s happening here. Let’s look at each argument following the command: 1. `-I` adds an index on the new table’s geometry column using GiST. 2. `-s` lets you specify an SRID for the geometric data. 3. `-W` lets you specify file encoding, if necessary. 4. `shapefile_name` is the name (including full path) of the file ending with the *.shp* extension. 5. `table_name` indicates the new table you want the shapefile imported to. Following these arguments, you place a pipe symbol (`|`) to direct the output of `shp2pgsql` to `psql`, the PostgreSQL command line utility. That’s followed by arguments for naming the database and user. For example, to load the *tl_2019_us_county.shp* shapefile from the book’s resources into a `us_counties_2019_shp` table in the `analysis` database, in your terminal you would move to the directory containing the shapefile and run the following command (all on one line): ``` shp2pgsql -I -s 4269 -W LATIN1 tl_2019_us_county.shp us_counties_2019_shp | psql -d analysis -U postgres ``` The server should respond with a number of SQL `INSERT` statements before creating the index and returning you to the command line. It might take some time to construct the entire set of arguments the first time around. But after you’ve done one, subsequent imports should take less time because you can simply substitute file and table names into the syntax you already wrote. Load your shapefile, and then you’ll be ready to explore the data with queries. ### Exploring the Census 2019 Counties Shapefile Your new `us_counties_2019_shp` table contains columns including each county’s name as well as the *Federal Information Processing Standards (FIPS)* codes uniquely assigned to each state and county. The `geom` column contains the spatial data for each county’s boundary. To start, let’s check what kind of spatial object `geom` contains using the `ST_AsText()` function. Use the code in Listing 15-14 to show the WKT representation of the first `geom` value in the table. ``` SELECT ST_AsText(geom) FROM us_counties_2019_shp ORDER BY gid LIMIT 1; ``` Listing 15-14: Checking the `geom` column’s WKT representation The result is a MultiPolygon with hundreds of coordinate pairs. Here’s a portion of the output: ``` MULTIPOLYGON(((-97.019516 42.004097,-97.019519 42.004933,-97.019527 42.007501,-97.019529 42.009755,-97.019529 42.009776,-97.019529 42.009939,-97.019529 42.010163,-97.019538 42.013931,-97.01955 42.014546,-97.01955 42.014565,-97.019551 42.014608,-97.019551 42.014632,-97.01958 42.016158,-97.019622 42.018384,-97.019629 42.018545,-97.01963 42.019475,-97.01963 42.019553,-97.019644 42.020927, `--snip--` ))) ``` Each coordinate pair marks a Point on the boundary of the county, and remember that a `MULTIPOLYGON` object can contain a set of polygons. In the case of US counties, that will enable storage of counties whose boundaries contain more than one distinct, separated area. Now, you’re ready to analyze the data. #### Finding the Largest Counties in Square Miles Which county can claim the title of largest in area? To find the answer, Listing 15-15 uses the `ST_Area()` function, which returns the area of a Polygon or MultiPolygon object. If you’re working with a `geography` data type, `ST_Area()` returns the result in square meters. With a `geometry` data type—as used with this shapefile—the function returns the area in SRID units. Typically, those units are not useful for practical analysis, so we’ll cast the `geometry` type to `geography` to obtain square meters. It’s an intensive calculation, so expect extra time for this query to complete. ``` SELECT name, statefp AS st, round( ( ST_Area(1geom::geography) / 22589988.110336 )::numeric, 2 ) AS 3square_miles FROM us_counties_2019_shp ORDER BY square_miles 4DESC LIMIT 5; ``` Listing 15-15: Finding the largest counties by area using `ST_Area()` The `geom` column is data type `geometry`, so to find the area in square meters, we cast the `geom` column to a `geography` data type using the double-colon syntax 1. Then, to get square miles, we divide the area by 2589988.110336, which is the number of square meters in a square mile 2. To make the result easier to read, I’ve wrapped it in a `round()` function and named the resulting column `square_miles` 3. Finally, we list the results in descending order from the largest area to the smallest 4 and use `LIMIT 5` to show the first five results, which should look like this: ``` name st square_miles ---------------- -- ------------ Yukon-Koyukuk 02 147871.00 North Slope 02 94827.92 Bethel 02 45559.08 Northwest Arctic 02 40619.78 Valdez-Cordova 02 40305.54 ``` Congratulations to Alaska, where the boroughs (the name for county equivalents up there) are big. The five largest are all in Alaska, denoted by the state FIPS code `02`. Yukon-Koyukuk, located in the heart of Alaska, is more than 147,800 square miles. (Keep that information in mind for the “Try It Yourself” exercise at the end of the chapter.) Note that the shapefile doesn’t include a state name, just its FIPS code. Because the spatial data resides in a table, in the next section we’ll join to another census table to obtain the state name. #### Finding a County by Longitude and Latitude If you’ve ever wondered how spammy online ads seem to know where you live (“This one trick helped a Boston man fix his old shoes!”), it’s thanks to *geolocation services* that use various means, such as your phone’s GPS, to find your longitude and latitude. Given your coordinates, a spatial query can then determine which geography (a city or town, for example) that point falls into. You can replicate this technique using your census shapefile and the `ST_Within()` function, which returns `true` if one geometry is inside another on the coordinate grid. Listing 15-16 shows an example using the longitude and latitude of downtown Hollywood, California. ``` SELECT sh.name, c.state_name FROM us_counties_2019_shp sh JOIN us_counties_pop_est_2019 c ON sh.statefp = c.state_fips AND sh.countyfp = c.county_fips WHERE 1ST_Within( 'SRID=4269;POINT(-118.3419063 34.0977076)'::geometry, geom ); ``` Listing 15-16: Using `ST_Within()` to find the county belonging to a pair of coordinates The `ST_Within()` function 1 inside the `WHERE` clause requires two `geometry` inputs and evaluates whether the first is inside the second. For the function to work properly, both `geometry` inputs must have the same SRID. In this example, the first input is an extended WKT representation of a Point that includes the SRID `4269` (same as the census data), which is cast as a `geometry` type. The `ST_Within()` function doesn’t accept a separate SRID input, so to set it for the supplied WKT, you must prefix it to the string like this: `'SRID=4269;POINT(-118.3419063 34.0977076)'`. The second input is the `geom` column from the table. Run the query; you should see the following result: ``` name state_name ----------- ----------- Los Angeles California ``` It shows that the Point you supplied is within Los Angeles county in California. We also see how this technique can gain value (or raise privacy concerns) by relating a Point to data about its surrounding area—as we did here by joining to county population estimates. Suddenly, we can tell a lot about someone based on data describing where they spend time. Try supplying other longitude and latitude pairs to see which US county they fall in. If you provide coordinates outside the United States, the query should return no results because the shapefile contains only US areas. ### Examining Demographics Within a Distance A fundamental metric for planners trying to locate a new school, business, or other community amenity is the number of people who live within a certain distance of it. Will there be enough people nearby to make construction worthwhile? To find the answer, we can use spatial and demographics data to estimate the population contained in the geographies within a certain distance of the planned location. Say we’re considering building a restaurant in downtown Lincoln, Nebraska, and we want to understand how many people live within 50 miles of the potential location. The code in Listing 15-17 uses the `ST_DWithin()` function to find counties that have any portion of their boundary within 50 miles of downtown Lincoln and sum their estimated 2019 population. ``` SELECT sum(c.pop_est_2019) AS pop_est_2019 FROM us_counties_2019_shp sh JOIN us_counties_pop_est_2019 c ON sh.statefp = c.state_fips AND sh.countyfp = c.county_fips WHERE ST_DWithin(sh.geom::geography, ST_GeogFromText('SRID=4269;POINT(-96.699656 40.811567)'), 80467); ``` Listing 15-17: Using `ST_DWithin()` to count people near Lincoln, Nebraska In Listing 15-10, we used `ST_DWithin()` to find farmers’ markets close to Des Moines, Iowa. Here, we apply the same technique. We pass three arguments to `ST_DWithin()`: the census shapefile’s `geom` column cast to the `geography` type; a point representing downtown Lincoln; and the distance of 50 miles using its equivalent in meters, 80,467. The query should return a sum of 1,470,295, using the data from the joined census estimates table’s `pop_est_2019` column. Say we want to list the county names and visualize their borders in pgAdmin; we can modify our query, as in Listing 15-18. ``` SELECT sh.name, c.state_name, c.pop_est_2019, 1 ST_Transform(sh.geom, 4326) AS geom FROM us_counties_2019_shp sh JOIN us_counties_pop_est_2019 c ON sh.statefp = c.state_fips AND sh.countyfp = c.county_fips WHERE ST_DWithin(geom::geography, ST_GeogFromText('SRID=4269;POINT(-96.699656 40.811567)'), 80467); ``` Listing 15-18: Displaying counties near Lincoln, Nebraska This query should return 25 rows with the county name and its population. If you click the eye icon in the header of the `geom` column, you should see the counties displayed on a map in pgAdmin’s Geometry Viewer, as in Figure 15-6.  Figure 15-6: Counties that have a portion of their boundaries within 50 miles of Lincoln These queries show counties that have any portion of their boundaries within 50 miles of Lincoln. Because counties tend to be large in area, they’re a bit crude for determining an exact number of people within the distance of the point. For a more precise count, we could use smaller census geographies such as tracts or block groups, both of which are subcomponents of counties. Finally, note that the pgAdmin Geometry Viewer’s base map is the free OpenStreetMap, which uses the WGS 84 coordinate system. Our census shapefiles use a different coordinate system: North American Datum 83\. For our data to display properly against the base map, we must use the `ST_Transform()` function 1 to convert the census geometry to the SRID of 4326\. If we omit that function, the geographies will display on a blank canvas in the viewer because the coordinate systems don’t match. ## Performing Spatial Joins Joining tables with spatial data opens up interesting opportunities for analysis. For example, you could join a table of coffee shops (which includes their longitude and latitude) to the counties table to find out how many shops exist in each county based on their location. In this section, we’ll explore spatial joins with a detailed look at roads and waterways using census data. ### Exploring Roads and Waterways Data Much of the year, the Santa Fe River, which cuts through the New Mexico state capital, is a dry riverbed better described as an *intermittent stream*. According to the Santa Fe city website, the river is susceptible to flash flooding and was named the nation’s most endangered river in 2007\. If you were an urban planner, it would help to know where the river intersects roadways so you could plan for emergency response when it floods. You can find these locations using another set of US Census TIGER/Line shapefiles that has details on roads and waterways in Santa Fe County. These shapefiles are also included with the book’s resources. Download and unzip *tl_2019_35049_linearwater.zip* and *tl_2019_35049_roads.zip*, and then import both using the same steps from earlier in the chapter. Name the water table `santafe_linearwater_2019` and the roads table `santafe_roads_2019`. Next, refresh your database and run a quick `SELECT * FROM` query on both tables to view the data. You should have 11,655 rows in the roads table and 1,148 in the linear water table. As with the counties shapefile, both tables have an indexed `geom` column of type `geometry`. It’s helpful to check the type of spatial object in the column so you know the type of spatial feature you’re querying. You can do that using the `ST_AsText()` function or `ST_GeometryType()`, as shown in Listing 15-19. ``` SELECT ST_GeometryType(geom) FROM santafe_linearwater_2019 LIMIT 1; SELECT ST_GeometryType(geom) FROM santafe_roads_2019 LIMIT 1; ``` Listing 15-19: Using `ST_GeometryType()` to determine geometry Both queries should return one row with the same value: `ST_MultiLineString`. That tell us that waterways and roads are stored as MultiLineString objects, a set of LineStrings that can be noncontinuous. ### Joining the Census Roads and Water Tables To find all the roads in Santa Fe that intersect the Santa Fe River, we’ll join the roads and waterway tables with a query that tells us where the objects touch. We’ll do this using the `ST_Intersects()` function, which returns a Boolean `true` if two spatial objects contact each other. Inputs can be either `geometry` or `geography` types. Listing 15-20 joins the tables. ``` SELECT water.fullname AS waterway, 1 roads.rttyp, roads.fullname AS road FROM santafe_linearwater_2019 water JOIN santafe_roads_2019 roads 2 3 ON ST_Intersects(water.geom, roads.geom) WHERE water.fullname = 4'Santa Fe Riv' AND roads.fullname IS NOT NULL ORDER BY roads.fullname; ``` Listing 15-20: Spatial join with `ST_Intersects()` to find roads crossing the Santa Fe River The `SELECT` column list 1 includes the `fullname` column from the `santafe_linearwater_2019` table, which gets `water` as its alias in the `FROM` 2 clause. The column list includes the `rttyp` code, which represents the route type, and `fullname` columns from the `santafe_roads_2019` table, aliased as `roads`. In the `ON` portion 3 of the `JOIN` construct, we use the `ST_Intersects()` function with the `geom` columns from both tables as inputs. Here, the expression evaluates as `true` if the geometries intersect. We use `fullname` to filter the results to show only those that have the full string `'Santa Fe Riv'` 4, which is how the Santa Fe River is listed in the water table. We also eliminate instances where road names are `NULL`. The query should return 37 rows; here are the first five: ``` waterway rttyp road ------------ ----- ---------------- Santa Fe Riv M Baca Ranch Ln Santa Fe Riv M Baca Ranch Ln Santa Fe Riv M Caja del Oro Grant Rd Santa Fe Riv M Caja del Oro Grant Rd Santa Fe Riv M Cam Carlos Rael `--snip--` ``` Each road in the results intersects with a portion of the Santa Fe River. The route type code for each of the first results is `M`, which indicates that the road name shown is its *common* name as opposed to a county or state recognized name, for example. Other road names in the complete results carry route types of `C`, `S`, or `U` (for unknown). The full route type code list is available at [`www.census.gov/library/reference/code-lists/route-type-codes.html`](https://www.census.gov/library/reference/code-lists/route-type-codes.html). ### Finding the Location Where Objects Intersect We successfully identified roads that intersect the Santa Fe River. That’s good, but it would really help to know the precise location of each intersection. We can modify the query to include the `ST_Intersection()` function, which returns the location of the place where objects touch. I’ve added it as a column in Listing 15-21. ``` SELECT water.fullname AS waterway, roads.rttyp, roads.fullname AS road, 1 ST_AsText(ST_Intersection(2water.geom, roads.geom)) FROM santafe_linearwater_2019 water JOIN santafe_roads_2019 roads ON ST_Intersects(water.geom, roads.geom) WHERE water.fullname = 'Santa Fe Riv' AND roads.fullname IS NOT NULL ORDER BY roads.fullname; ``` Listing 15-21: Using `ST_Intersection()` to show where roads cross the river The function returns a geometry object, so to view its WKT representation, we must wrap it in `ST_AsText()` 1. The `ST_Intersection()` function takes two inputs: the `geom` columns 2 from both the `water` and `roads` tables. Run the query, and the results should now include the exact coordinate location, or locations, where the river crosses the roads (I’ve rounded the Point coordinates for brevity). ``` waterway rttyp road st_astext ------------ ----- ---------------- ---------------------------- Santa Fe Riv M Baca Ranch Ln POINT(-106.049802 35.642638) Santa Fe Riv M Baca Ranch Ln POINT(-106.049743 35.643126) Santa Fe Riv M Caja del Oro Grant Rd POINT(-106.024674 35.657624) Santa Fe Riv M Caja del Oro Grant Rd POINT(-106.024692 35.657644) Santa Fe Riv M Cam Carlos Rael POINT(-105.986934 35.672342) `--snip--` ``` Much better than poring over a map with a pencil, and this might prompt more ideas for analyzing spatial data. For example, if you have a shapefile of building footprints, you could find buildings near the river and in danger of flooding during heavy rains. Governments and private organizations regularly use these techniques as part of their planning process. ## Wrapping Up Mapping is a powerful analysis tool, and the techniques you learned in this chapter give you a strong start toward exploring more with PostGIS. You may indeed want to visualize this data, and that’s entirely possible with a GIS application such as Esri’s ArcGIS ([`www.esri.com/`](https://www.esri.com/)) or the free open source QGIS ([`qgis.org/`](https://qgis.org/)). Both can use a PostGIS-enabled PostgreSQL database as a data source, allowing you to visualize shapefile data in your tables or the results of queries. You’ve now added working with geographic data to your analysis skills. Next, we’ll explore another widely used data type called JavaScript Object Notation (JSON) and how PostgreSQL enables storing and querying it.````
第十六章:操作 JSON 数据

JavaScript 对象表示法 (JSON) 是一种广泛使用的文本格式,用于以平台无关的方式存储数据,以便在计算机系统之间共享。在本章中,你将学习 JSON 的结构,以及如何在 PostgreSQL 中存储和查询 JSON 数据类型。在我们探讨 PostgreSQL 的 JSON 查询操作符后,我们将分析一个月的地震数据。
美国国家标准协会(ANSI)SQL 标准在 2016 年增加了 JSON 的语法定义,并指定了用于创建和访问 JSON 对象的函数。近年来,主要的数据库系统也增加了对 JSON 的支持,尽管实现方式有所不同。例如,PostgreSQL 支持部分 ANSI 标准,同时实现了一些非标准操作符。在我们进行练习时,我会指出 PostgreSQL 对 JSON 的支持哪些是标准 SQL 的一部分。
理解 JSON 结构
JSON 数据主要由两种结构组成:对象,它是一个无序的键值对集合;和数组,它是一个有序的值集合。如果你使用过 JavaScript、Python 或 C# 等编程语言,这些 JSON 的特性应该很熟悉。
在一个对象内部,我们使用键值对作为存储和引用单个数据项的结构。整个对象被花括号包围,每个名称(通常称为键)都用双引号括起来,后跟冒号及其对应的值。该对象可以包含多个键值对,键值对之间用逗号分隔。以下是一个使用电影信息的示例:
{"title": "The Incredibles", "year": 2004}
键是 title 和 year,它们的值分别是 "The Incredibles" 和 2004。如果值是字符串,它放在双引号中。如果是数字、布尔值或 null,我们则省略引号。如果你熟悉 Python 语言,你会把这种结构认作是字典。
数组是一个有序的值列表,用方括号括起来。我们用逗号分隔数组中的每个值。例如,我们可能会这样列出电影类型:
["animation", "action"]
数组在编程语言中是常见的,我们已经在 SQL 查询中使用过它们。在 Python 中,这种结构被称为列表。
我们可以创建这些结构的多种排列方式,包括将对象和数组相互嵌套。例如,我们可以创建一个对象数组,或使用数组作为键的值。我们可以添加或省略键值对,或创建额外的对象数组,而不会违反预设的模式。这种灵活性——与 SQL 表的严格定义相比——是使用 JSON 作为数据存储的吸引力之一,也是处理 JSON 数据的最大难点之一。
作为示例,示例 16-1 展示了以 JSON 存储的我最喜欢的两部电影的信息。最外层结构是一个包含两个元素的数组——每个电影一个对象。我们知道最外层结构是一个数组,因为整个 JSON 以方括号开头和结尾。
[{1
"title": "The Incredibles",
"year": 2004,
2"rating": {
"MPAA": "PG"
},
3"characters": [{
"name": "Mr. Incredible",
"actor": "Craig T. Nelson"
}, {
"name": "Elastigirl",
"actor": "Holly Hunter"
}, {
"name": "Frozone",
"actor": "Samuel L. Jackson"
}],
4"genre": ["animation", "action", "sci-fi"]
}, {
"title": "Cinema Paradiso",
"year": 1988,
"characters": [{
"name": "Salvatore",
"actor": "Salvatore Cascio"
}, {
"name": "Alfredo",
"actor": "Philippe Noiret"
}],
"genre": ["romance", "drama"]
}]
示例 16-1:包含两部电影信息的 JSON
在最外层数组内,每个电影对象都被花括号包围。位于 1 的打开花括号开始了第一部电影超人总动员的对象。对于两部电影,我们将title和year作为键/值对存储,它们分别具有字符串和整数值。第三个键rating 2,其值是一个 JSON 对象。该对象包含一个键/值对,显示了该电影来自美国电影协会的评分。
在这里,我们可以看到 JSON 作为存储介质所赋予我们的灵活性。首先,如果以后想为电影添加另一个国家的评分,我们可以轻松地向rating值对象中添加第二个键/值对。其次,我们并不要求在每个电影对象中都包括rating—或任何键/值对。事实上,我在天堂电影院中省略了rating。如果某个特定的数据不可用(例如评分),一些生成 JSON 的系统可能会简单地省略该对。其他系统可能会包括rating,但其值为null。这两种做法都是有效的,而这种灵活性正是 JSON 的优势之一:它的数据定义或模式可以根据需要进行调整。
最后的两个键/值对展示了 JSON 的其他结构方式。对于characters 3,值是一个对象数组,每个对象都被花括号包围,并且用逗号分隔。genre 4 的值是一个字符串数组。
考虑何时在 SQL 中使用 JSON
使用NoSQL或文档数据库(将数据存储在 JSON 或其他基于文本的数据格式中)相较于使用 SQL 的关系表有其优势。文档数据库在数据定义方面具有灵活性。如果需要,您可以随时重新定义数据结构。文档数据库通常也用于高流量应用,因为可以通过增加服务器来扩展它们。但另一方面,您可能会放弃 SQL 的某些优势,例如能够轻松添加约束以确保数据完整性以及对事务的支持。
SQL 对 JSON 的支持使得通过在关系表中添加 JSON 数据作为列,享受两者的优势成为可能。决定使用 SQL 还是 NoSQL 数据库应该是多方面的。相较于 NoSQL,PostgreSQL 在速度上表现优异,但我们还必须考虑存储的数据种类和数量、所服务的应用程序等因素。
也就是说,一些可能希望在 SQL 中利用 JSON 的情况包括以下几种:
-
当用户或应用程序需要任意创建键/值对时。例如,在标记一组医学研究论文时,一个用户可能想添加一个用于跟踪化学名称的键,而另一个用户可能想要一个用于跟踪食物名称的键。
-
当将相关数据存储在 JSON 列中而不是单独的表中时。例如,员工表可能包含通常的姓名和联系信息列,再加上一列 JSON 数据,存储灵活的键/值对集合,其中可能包含不适用于每个员工的额外属性,如公司奖励或绩效指标。
-
当通过分析从其他系统获取的 JSON 数据来节省时间,而无需首先将其解析为一组表格时。
请记住,在 PostgreSQL 或其他 SQL 数据库中使用 JSON 也可能带来一些挑战。对于常规 SQL 表格中容易设置的约束,JSON 数据中的约束可能更难设置和强制执行。随着键名在文本中反复出现,以及定义结构的引号、逗号和大括号,JSON 数据可能会占用更多的空间。最后,JSON 的灵活性可能会对与其交互的代码(无论是 SQL 还是其他语言)造成问题,如果键名意外消失或某个值的数据类型发生变化。
记住这些内容后,让我们来回顾一下 PostgreSQL 的两种 JSON 数据类型,并将一些 JSON 数据加载到表中。
使用 json 和 jsonb 数据类型
PostgreSQL 提供了两种数据类型来存储 JSON。这两种类型只允许插入有效的 JSON——即符合 JSON 规范的文本,包括环绕对象的大括号、分隔对象的逗号以及键的正确引用。如果你尝试插入无效的 JSON,数据库将生成错误。
两者之间的主要区别在于,一种将 JSON 存储为文本,另一种将其存储为二进制数据。二进制实现是 PostgreSQL 新增的功能,通常更受偏好,因为它在查询时更快并且支持索引功能。
这两种类型如下:
json
- 以文本形式存储 JSON,保留空白并保持键的顺序。如果一个 JSON 对象包含某个特定的键多次(这是有效的),
json类型将保留每个重复的键/值对。最后,每次数据库函数处理存储在json中的文本时,都必须解析该对象以解释其结构。这可能使得从数据库读取数据的速度比jsonb类型慢。索引不受支持。通常,当应用程序具有重复键或需要保留键的顺序时,json类型更为有用。
jsonb
- 以二进制格式存储 JSON,去除空白并不保留键的顺序。如果一个 JSON 对象包含某个特定的键多次,
jsonb类型将只保留最后一个键/值对。二进制格式会增加写入数据到表格的开销,但处理速度更快。支持索引。
json和jsonb都不是 ANSI SQL 标准的一部分,ANSI SQL 标准并未指定 JSON 数据类型,如何实现支持则留给数据库厂商决定。PostgreSQL 文档中www.postgresql.org/docs/current/datatype-json.html推荐使用jsonb,除非有必要保持键/值对的顺序。
在本章余下部分,我们将专门使用jsonb,不仅因为它的速度考虑,还因为 PostgreSQL 的许多 JSON 函数在json和jsonb上都能以相同的方式工作——并且jsonb有更多可用的函数。接下来,我们将把 Listing 16-1 中的电影 JSON 数据添加到表中,并探索 JSON 查询语法。
导入并为 JSON 数据建立索引
书籍资源中第十六章文件夹里的films.json文件位于nostarch.com/practical-sql-2nd-edition/,包含了 Listing 16-1 中 JSON 的修改版。用文本编辑器查看该文件,你会看到每个电影的 JSON 对象被放置在一行上,元素之间没有换行。我还去除了最外层的方括号以及分隔两个电影对象的逗号。每个对象仍然是有效的 JSON 对象:
{"title": "The Incredibles", "year": 2004, `--snip--` }
{"title": "Cinema Paradiso", "year": 1988, `--snip--` }
我这样设置文件是为了让 PostgreSQL 的COPY命令在导入时将每个电影的 JSON 对象解释为一行,就像导入 CSV 文件时那样。Listing 16-2 中的代码创建了一个简单的films表,具有替代主键和名为film的jsonb列。
CREATE TABLE films (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
film jsonb NOT NULL
);
COPY films (film)
1 FROM `C:\YourDirectory`\films.json';
2 CREATE INDEX idx_film ON films USING GIN (film);
Listing 16-2:创建用于存储 JSON 数据的表并添加索引
请注意,COPY语句以FROM子句 1 结尾,而不是像之前的示例那样继续包含WITH语句。我们不再需要WITH语句(之前我们用它来指定文件头和 CSV 格式选项),因为这个文件没有头部,并且没有分隔符。我们只希望数据库读取每一行并处理它。
导入后,我们使用 GIN 索引类型为jsonb列添加索引 2。我们在第十四章中讨论了用于全文搜索的广义倒排索引(GIN)。GIN 在文本中索引单词或键值的位置的实现,特别适用于 JSON 数据。请注意,因为索引条目指向表中的行,所以jsonb列的索引在每行包含相对较小的 JSON 片段时效果最佳——而不是一个表中有一行包含一个巨大 JSON 值且重复的键。
执行命令来创建并填充表格,添加索引。运行SELECT * FROM films;,你应该能看到两行,分别包含自动生成的id和 JSON 对象文本。现在你准备好使用 PostgreSQL 的 JSON 操作符来查询数据了。
使用 json 和 jsonb 提取操作符
为了从存储的 JSON 中提取值,我们可以使用 PostgreSQL 特定的提取运算符,这些运算符返回一个 JSON 对象、数组的元素,或我们指定路径中存在的元素。表 16-1 展示了这些运算符及其功能,具体功能会根据输入的数据类型有所不同。每个运算符都可以与json和jsonb数据类型一起使用。
表 16-1:json和jsonb提取运算符
| 运算符,语法 | 功能 | 返回类型 |
|---|---|---|
json -> text jsonb -> text |
提取键值,作为文本指定 | json或jsonb(与输入类型匹配) |
json ->> text jsonb ->> text |
提取键值,作为文本指定 | text |
json -> integer jsonb -> integer |
提取数组元素,指定一个整数表示其在数组中的位置 | json或jsonb(与输入类型匹配) |
json ->> integer jsonb ->> integer |
提取数组元素,指定一个整数表示其在数组中的位置 | text |
json #> text array jsonb #> text array |
提取指定路径处的 JSON 对象 | json或jsonb(与输入类型匹配) |
json #>> text array jsonb #>> text array |
提取指定路径处的 JSON 对象 | text |
让我们尝试使用这些运算符与我们的电影 JSON,进一步了解它们在功能上的差异。
键值提取
在列表 16-3 中,我们使用->和->>运算符,后面跟上文本来指定要提取的键值。在这种情况下,这些运算符被称为字段提取运算符,因为它们从 JSON 中提取一个字段或键值。两者的区别在于,->将键值作为 JSON 以与存储时相同的类型返回,而->>将键值作为文本返回。
SELECT id, film ->1 'title' AS title
FROM films
ORDER BY id;
SELECT id, film ->>2 'title' AS title
FROM films
ORDER BY id;
SELECT id, film ->3 'genre' AS genre
FROM films
ORDER BY id;
列表 16-3:使用字段提取运算符提取 JSON 键值
在SELECT列表中,我们指定我们的 JSON 列名,后面跟上运算符和用单引号括起来的键名。在第一个示例中,语法-> 'title' 1 返回title键的值,数据类型与存储时相同,都是jsonb。运行第一个查询,你应该看到如下输出:
id title
-- -----------------
1 "The Incredibles"
2 "Cinema Paradiso"
在 pgAdmin 中,title列头中列出的数据类型应指示为jsonb,并且电影标题保持用引号括起来,就像它们在 JSON 对象中一样。
将字段提取运算符更改为->> 2 时,将电影标题作为文本返回:
id title
-- ---------------
1 The Incredibles
2 Cinema Paradiso
最后,我们将返回一个数组。在我们的电影 JSON 中,genre键的值是一个值的数组。使用字段提取运算符-> 3 可以将数组作为jsonb返回:
id genre
-- ---------------------------------
1 ["animation", "action", "sci-fi"]
2 ["romance", "drama"]
如果我们在这里使用->>,我们将返回数组的文本表示。接下来,我们来看一下如何从数组中提取元素。
数组元素提取
要从数组中检索特定值,我们使用->和->>操作符,后跟指定值在数组中的位置或索引。我们称这些为元素提取操作符,因为它们从 JSON 数组中提取元素。与字段提取一样,->返回与存储类型相同的 JSON 值,而->>则返回文本格式的值。
Listing 16-4 显示了四个使用"genre"数组值的示例。
SELECT id, film -> 'genre' -> 01 AS genres
FROM films
ORDER BY id;
SELECT id, film -> 'genre' -> -12 AS genres
FROM films
ORDER BY id;
SELECT id, film -> 'genre' -> 23 AS genres
FROM films
ORDER BY id;
SELECT id, film -> 'genre' ->> 04 AS genres
FROM films
ORDER BY id;
Listing 16-4: 使用元素提取操作符获取 JSON 数组值
我们必须先从键中提取数组值作为 JSON,然后从数组中提取所需的元素。在第一个示例中,我们指定 JSON 列film,接着使用字段提取操作符->和单引号括起来的genre键名。这将返回genre值,类型为jsonb。然后我们跟随键名使用->和整数0 1 来获取第一个元素。
为什么不使用1来表示数组中的第一个值呢?在许多语言中,包括 Python 和 JavaScript,索引值从零开始,在使用 SQL 访问 JSON 数组时也遵循这一规则。
运行第一个查询时,结果应该像这样,显示每部电影的genre数组中的第一个元素,返回类型为jsonb:
id genres
-- -----------
1 "animation"
2 "romance"
我们还可以访问数组的最后一个元素,即使我们不确定它的索引,因为每部电影的类型数量可能会不同。我们使用负数索引从列表的末尾倒数。提供-1 2 会告诉->从列表末尾获取第一个元素:
id genres
-- --------
1 "sci-fi"
2 "drama"
如果我们需要,还可以继续往回数——-2的索引将获取倒数第二个元素。
请注意,如果在指定的索引位置没有元素,PostgreSQL 不会返回错误,它只会返回该行的NULL。例如,如果我们为索引提供2 3,则会看到一部电影的结果,另一部电影则显示NULL:
id genres
-- --------
1 "sci-fi"
2
对于Cinema Paradiso,我们得到NULL,因为它的genre值数组只有两个元素,索引2(从零开始计数)表示第三个元素。稍后我们将在本章中学习如何计算数组长度。
最后,将元素提取操作符改为->> 4 可以返回所需的元素,数据类型为text,而非 JSON:
id genres
-- ---------
1 animation
2 romance
这与我们在提取键值时看到的模式相同:->返回 JSON 数据类型,->>返回文本。
路径提取
#>和#>>都是路径提取操作符,用于返回位于 JSON 路径中的对象。路径是由一系列键或数组索引组成,用于定位值的位置。在我们的示例 JSON 中,如果我们想要电影的名称,路径可能仅是title键。或者,路径可能更复杂,例如characters键,后跟索引值1,再后面是actor键;这样可以提供指向索引1处演员姓名的路径。#>路径提取操作符返回与存储数据匹配的 JSON 数据类型,而#>>返回文本。
考虑电影 超人总动员 的 MPAA 评级,在我们的 JSON 中显示如下:
"rating": {
"MPAA": "PG"
}
该结构是一个名为 rating 的键,其值是一个对象;在该对象内有一个键/值对,MPAA 为键名。因此,电影的 MPAA 评级的路径从 rating 键开始,到 MPAA 键结束。为了表示路径的元素,我们使用 PostgreSQL 数组的字符串语法,创建一个以逗号分隔的列表,放在花括号和单引号中。然后,我们将该字符串传递给路径提取操作符。列表 16-5 展示了设置路径的三个示例。
SELECT id, film #> '{rating, MPAA}'1 AS mpaa_rating
FROM films
ORDER BY id;
SELECT id, film #> '{characters, 0, name}'2 AS name
FROM films
ORDER BY id;
SELECT id, film #>> '{characters, 0, name}'3 AS name
FROM films
ORDER BY id;
列表 16-5: 使用路径提取操作符获取 JSON 键值
为了获取每部电影的 MPAA 评级,我们在数组中指定路径:{rating, MPAA} 1,每个项由逗号分隔。运行查询后,你应该看到以下结果:
id mpaa_rating
-- -----------
1 "PG"
2
查询返回 超人总动员 的 PG 评级和 天堂电影院 的 NULL,因为在我们的数据中,后者没有 MPAA 评级。
第二个示例适用于 characters 数组,在我们的 JSON 中看起来是这样的:
"characters": [{
"name": "Salvatore",
"actor": "Salvatore Cascio"
}, {
"name": "Alfredo",
"actor": "Philippe Noiret"
}]
显示的 characters 数组是第二部电影的内容,但两部电影的结构相似。数组对象分别表示一个角色,以及扮演该角色的演员的名字。为了查找数组中第一个角色的名字,我们指定一个路径 2,从 characters 键开始,使用索引 0 访问数组的第一个元素,并以 name 键结束。查询结果应该像这样:
id name
-- ----------------
1 "Mr. Incredible"
2 "Salvatore"
#> 操作符将结果返回为 JSON 数据类型,在我们的例子中是 jsonb。如果我们希望以文本格式返回结果,我们使用 #>> 3,路径相同。
包含性和存在性
我们将探讨的最后一组操作符执行两种类型的评估。第一种涉及 包含性,检查指定的 JSON 值是否包含第二个指定的 JSON 值。第二种检查 存在性:测试 JSON 对象中的文本字符串是否作为顶级键(或作为嵌套在更深层对象中的数组元素)存在。这两种操作符都返回布尔值,这意味着我们可以在 WHERE 子句中使用它们来筛选查询结果。
这组操作符仅适用于 jsonb 数据类型——这也是我们偏好 jsonb 而非 json 的另一个好理由——并且可以利用我们的 GIN 索引进行高效搜索。表 16-2 列出了这些操作符的语法和功能。
表 16-2: jsonb 包含和存在操作符
| 操作符,语法 | 功能 | 返回值 |
|---|---|---|
jsonb @> jsonb |
测试第一个 JSON 值是否包含第二个 JSON 值 | boolean |
jsonb <@ jsonb |
测试第二个 JSON 值是否包含第一个 JSON 值 | boolean |
jsonb ? text |
测试文本是否作为顶级(非嵌套)键或数组值存在 | boolean |
jsonb ?| text array |
测试数组中的任何文本元素是否存在为顶层(非嵌套)键或数组值 | boolean |
jsonb ?& text array |
测试数组中的所有文本元素是否存在为顶层(非嵌套)键或数组值 | boolean |
使用包含操作符
在列表 16-6 中,我们使用 @> 来评估一个 JSON 值是否包含第二个 JSON 值。
SELECT id, film ->> 'title' AS title,
film @>1 '{"title": "The Incredibles"}'::jsonb AS is_incredible
FROM films
ORDER BY id;
列表 16-6:演示 @> 包含操作符
在我们的 SELECT 列表中,我们检查每一行中存储在 film 列中的 JSON 是否包含 超人总动员 的键值对。我们在表达式中使用了 @> 包含操作符 1,如果 film 包含 "title": "The Incredibles",则生成一个布尔结果 true 的列。我们给出了我们的 JSON 列 film,然后是 @> 操作符,再是一个字符串(强制转换为 jsonb),指定键值对。在我们的 SELECT 列表中,我们还将电影标题的文本作为一列返回。运行查询应该得到如下结果:
id title is_incredible
-- --------------- -------------
1 The Incredibles true
2 Cinema Paradiso false
正如预期的那样,表达式对 超人总动员 评估为 true,对 天堂电影院 评估为 false。
因为该表达式评估为布尔结果,所以我们可以将其用于查询的 WHERE 2 子句,如列表 16-7 所示。
SELECT film ->> 'title' AS title,
film ->> 'year' AS year
FROM films
2 WHERE film @> '{"title": "The Incredibles"}'::jsonb;
列表 16-7:在 WHERE 子句中使用包含操作符
这里我们再次检查 film 列中的 JSON 是否包含 超人总动员 的标题键值对。通过将评估放在 WHERE 子句中,查询应该只返回表达式返回 true 的那一行:
title year
--------------- ----
The Incredibles 2004
最后,在列表 16-8 中,我们翻转了评估顺序,以检查指定的键值对是否包含在 film 列中。
SELECT film ->> 'title' AS title,
film ->> 'year' AS year
FROM films
WHERE '{"title": "The Incredibles"}'::jsonb <@3 film;
列表 16-8:演示 <@ 包含操作符
在这里,我们使用 <@ 操作符 3 替代 @> 来翻转评估顺序。这个表达式也会评估为 true,返回与之前查询相同的结果。
使用存在性操作符
接下来,在列表 16-9 中,我们探讨了三个存在性操作符。它们检查我们提供的文本是否存在为顶层键或数组元素。所有操作符都返回布尔值。
SELECT film ->> 'title' AS title
FROM films
WHERE film ?1 'rating';
SELECT film ->> 'title' AS title,
film ->> 'rating' AS rating,
film ->> 'genre' AS genre
FROM films
WHERE film ?|2 '{rating, genre}';
SELECT film ->> 'title' AS title,
film ->> 'rating' AS rating,
film ->> 'genre' AS genre
FROM films
WHERE film ?&3 '{rating, genre}';
列表 16-9:演示存在性操作符
? 操作符检查单个键或数组元素的存在。在第一个查询的 WHERE 子句中,我们给出了 film 列、? 操作符 1,然后是字符串 rating。这个语法表示:“在每一行中,rating 是否存在为 film 列中的 JSON 键?”当我们运行查询时,结果显示了唯一拥有 rating 键的电影 超人总动员。
?| 和 ?& 操作符分别作为 or 和 and 使用。例如,使用 ?| 2 会测试是否存在rating或genre作为顶级键。运行第二个查询返回两个影片,因为它们至少有其中一个键。然而,使用 ?& 3 会测试是否同时存在rating和genre键,这对超人总动员来说是成立的。
所有这些操作符提供了细致调整 JSON 数据探索的选项。现在,让我们在一个更大的数据集上使用其中的一些操作符。
分析地震数据
在本节中,我们将分析由美国地质调查局(US Geological Survey)编制的地震 JSON 数据。美国地质调查局是美国内政部的一个机构,监测包括火山、滑坡和水质在内的自然现象。美国地质调查局利用一网络的地震仪来记录地球的震动,编制每个地震事件的地点和强度数据。小型地震在全球每天发生多次;而大型地震则较为少见,但可能会造成灾难性后果。
对于我们的练习,我从美国地质调查局(USGS)的应用程序编程接口(API)中提取了一个月的 JSON 格式地震数据。API是计算机之间传输数据和命令的资源,而 JSON 常用于 API 中。你可以在本书资源的本章文件夹中找到名为earthquakes.json的文件。
探索并加载地震数据
清单 16-10 显示了文件中每个地震记录的数据结构,以及它的部分键值对(你的Chapter_16.sql文件有未截断的版本)。
{
"type": "Feature", 1
"properties":2 {
"mag": 1.44,
"place": "134 km W of Adak, Alaska",
"time": 1612051063470,
"updated": 1612139465880,
"tz": null,
`--snip--`
"felt": null,
"cdi": null,
"mmi": null,
"alert": null,
"status": "reviewed",
"tsunami": 0,
"sig": 32,
"net": "av",
"code": "91018173",
"ids": ",av91018173,",
"sources": ",av,",
"types": ",origin,phase-data,",
"nst": 10,
"dmin": null,
"rms": 0.15,
"gap": 174,
"magType": "ml",
"type": "earthquake",
"title": "M 1.4 - 134 km W of Adak, Alaska"
},
"geometry":3 {
"type": "Point",
"coordinates": [-178.581, 51.8418333333333, 22.48]
},
"id": "av91018173"
}
清单 16-10:包含一个地震数据的 JSON
这些数据采用GeoJSON格式,这是一个基于 JSON 的空间数据规范。GeoJSON 将包含一个或多个Feature对象,通过包含键值对"type": "Feature"来表示 1。每个Feature描述一个空间对象,并在properties下包含描述性属性(例如事件时间或相关代码)2,以及包含空间对象坐标的geometry 3 键。在我们的数据中,每个geometry都是一个点(Point),这是一个简单的特征,包含一个地震的经度、纬度和深度(单位:千米)坐标。我们在第十五章使用 PostGIS 时讨论了点和简单特征;GeoJSON 将其与其他空间简单特征结合使用。你可以在geojson.org/阅读更多关于 GeoJSON 规范的内容,并在 USGS 文档中查看键的定义,网址为earthquake.usgs.gov/data/comcat/data-eventterms.php/。
让我们使用清单 16-11 中的代码,将数据加载到名为earthquakes的表中。
CREATE TABLE earthquakes (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
earthquake jsonb1 NOT NULL
);
COPY earthquakes (earthquake)
FROM `C:\YourDirectory`\earthquakes.json';
2 CREATE INDEX idx_earthquakes ON earthquakes USING GIN (earthquake);
清单 16-11:创建并加载地震数据表
与我们的films表类似,我们使用COPY将数据复制到一个单独的jsonb列中,并添加一个 GIN 索引。运行SELECT * FROM earthquakes;应返回 12,899 行数据。现在让我们看看我们可以从数据中学到什么。
处理地震时间
time键/值对表示地震发生的时刻。在列表 16-12 中,我们使用路径提取运算符检索time的值。
SELECT id, earthquake #>> '{properties, time}'1 AS time
FROM earthquakes
ORDER BY id LIMIT 5;
列表 16-12:检索地震时间
在SELECT列表中,我们给出earthquake列,后跟#>>路径提取运算符和路径 1,用来获取数组中表示时间值的#>>运算符将我们的值作为文本返回。运行查询应返回五行:
id time
-- -------------
1 1612137592990
2 1612137479762
3 1612136740672
4 1612136207600
5 1612135893550
如果这些值看起来不像时间,这并不奇怪。默认情况下,USGS 将时间表示为从 1970 年 1 月 1 日 00:00 UTC 起的毫秒数。这是我们在第十二章介绍的标准时代的一个变体,该时代以秒计量。我们可以使用to_timestamp()和一点数学来将 USGS 的time值转换为可理解的形式,如列表 16-13 所示。
SELECT id, earthquake #>> '{properties, time}' as time,
1 to_timestamp(
(earthquake #>> '{properties, time}')::bigint / 10002
) AS time_formatted
FROM earthquakes
ORDER BY id LIMIT 5;
列表 16-13:将time值转换为时间戳
在to_timestamp()函数的括号内,我们重复代码以提取time值。to_timestamp()函数需要表示秒数的数字,但提取的值是文本且以毫秒为单位,因此我们还将提取的文本转换为bigint并除以 1000 以将其转换为秒。
在我的机器上,查询生成以下结果,显示提取的time值及其转换后的时间戳(根据您的 PostgreSQL 服务器时区设置不同,您的值将有所不同,因此time_formatted将显示地震发生时的服务器时区时间)。
id time time_formatted
-- ------------- ----------------------
1 1612137592990 2021-01-31 18:59:52-05
2 1612137479762 2021-01-31 18:57:59-05
3 1612136740672 2021-01-31 18:45:40-05
4 1612136207600 2021-01-31 18:36:47-05
5 1612135893550 2021-01-31 18:31:33-05
现在我们有了一个可理解的时间戳,让我们使用min()和max()聚合函数在列表 16-14 中找到最旧和最新的地震时间。
SELECT min1(to_timestamp(
(earthquake #>> '{properties, time}')::bigint / 1000
)) AT TIME ZONE 'UTC'2 AS min_timestamp,
max3(to_timestamp(
(earthquake #>> '{properties, time}')::bigint / 1000
)) AT TIME ZONE 'UTC' AS max_timestamp
FROM earthquakes;
列表 16-14:查找最小和最大的地震时间
我们在SELECT列表中的min()和max()函数内部放置to_timestamp()和我们的毫秒转秒转换。这一次,在两个函数之后添加关键字AT TIME ZONE 'UTC',无论我们的服务器时区设置如何,结果都将以 UTC 显示,正如 USGS 记录的那样。您的结果应该如下所示:
min_timestamp max_timestamp
------------------- -------------------
2021-01-01 00:01:39 2021-01-31 23:59:52
这组地震跨越了一个月——从 2021 年 1 月 1 日凌晨到 1 月 31 日结束。这是我们继续挖掘可用信息时的有用背景。
查找最大和最多报告的地震
接下来,我们将看两个数据点,即地震的大小和市民报告感觉程度,并应用 JSON 提取技术对结果进行简单排序。
按震级提取
美国地质调查局(USGS)报告每个地震的震级,位于mag键下,位于properties之下。根据 USGS 的定义,震级是表示地震源大小的一个数字。它的尺度是对数的:一个震级为 4 的地震,其地震波的振幅约是震级为 3 的地震的 10 倍。了解了这一背景后,我们来使用清单 16-15 中的代码,查找我们数据中五个最大的地震。
SELECT earthquake #>> '{properties, place}'1 AS place,
to_timestamp((earthquake #>> '{properties, time}')::bigint / 1000)
AT TIME ZONE 'UTC' AS time,
(earthquake #>> '{properties, mag}')::numeric AS magnitude
FROM earthquakes
2 ORDER BY (earthquake #>> '{properties, mag}')::numeric3 DESC NULLS LAST
LIMIT 5;
清单 16-15:查找五个震级最大的地震
我们再次使用路径提取操作符来获取我们所需的元素,包括place 1 和mag的值。为了在结果中显示最大的五个,我们在ORDER BY子句 2 中添加了mag。我们在此处和SELECT中将值强制转换为数值类型 3,因为我们希望将其作为数字而不是文本进行显示和排序。我们还添加了DESC NULLS LAST关键字,这样可以将结果按降序排列,并将NULL值(有两个)放在最后。你的结果应该如下所示:
place time magnitude
------------------------------------- ------------------- ---------
211 km SE of Pondaguitan, Philippines 2021-01-21 12:23:04 7
South Shetland Islands 2021-01-23 23:36:50 6.9
30 km SSW of Turt, Mongolia 2021-01-11 21:32:59 6.7
28 km SW of Pocito, Argentina 2021-01-19 02:46:21 6.4
Kermadec Islands, New Zealand 2021-01-08 00:28:50 6.3
最大的一次震级为 7,发生在菲律宾小城市 Pondaguitan 东南的海洋下。第二次地震发生在南极的南设得兰群岛附近。
基于市民报告的提取
美国地质调查局(USGS)运营着一个“你感觉到地震了吗?”网站,地址是earthquake.usgs.gov/data/dyfi/,人们可以在此报告他们的地震体验。我们的 JSON 数据中包含每个地震的报告数量,这些数据位于felt键下,位于properties之下。我们将使用清单 16-16 中的代码来查看我们的数据中哪些地震产生了最多的报告。
SELECT earthquake #>> '{properties, place}' AS place,
to_timestamp((earthquake #>> '{properties, time}')::bigint / 1000)
AT TIME ZONE 'UTC' AS time,
(earthquake #>> '{properties, mag}')::numeric AS magnitude,
(earthquake #>> '{properties, felt}')::integer1 AS felt
FROM earthquakes
ORDER BY (earthquake #>> '{properties, felt}')::integer2 DESC NULLS LAST
LIMIT 5;
清单 16-16:查找拥有最多“你感觉到地震了吗?”报告的地震
结构上,这个查询类似于清单 16-15,用于查找最大规模的地震。我们为felt键添加了路径提取操作符,将返回的文本值强制转换为integer类型。我们将其强制转换为integer,是因为提取的文本将作为数字进行排序和显示。最后,我们将提取代码放在ORDER BY 2 中,使用NULLS LAST,因为有许多地震没有报告,我们希望这些地震出现在列表的最后。你应该看到以下结果:
place time magnitude felt
------------------------- ------------------- --------- -----
4km SE of Aromas, CA 2021-01-17 04:01:27 4.2 19907
2km W of Concord, CA 2021-01-14 19:18:10 3.63 5101
10km NW of Pinnacles, CA 2021-01-02 14:42:23 4.32 3076
2km W of Willowbrook, CA 2021-01-20 16:31:58 3.52 2086
3km NNW of Santa Rosa, CA 2021-01-19 04:22:20 2.68 1765
排名前五的地震都发生在加利福尼亚,这很有道理。“你感觉到地震了吗?”是美国政府运营的系统,因此我们会预期更多的美国报告,尤其是在易发生地震的加利福尼亚。此外,我们数据中一些最大的地震发生在海洋下或偏远地区。那次拥有超过 19,900 份报告的地震震级适中,但由于它靠近城市,人们更容易注意到它。
将地震 JSON 转换为空间数据
我们的 JSON 数据包含了每次地震的经度和纬度值,这意味着我们可以使用第十五章讨论的 GIS 技术进行空间分析。例如,我们将使用 PostGIS 距离函数来定位发生在距离某城市 50 英里以内的地震。但是,首先我们必须将存储在 JSON 中的坐标转换为 PostGIS 数据类型。
经度和纬度值位于 geometry 下的 coordinates 键的数组中。以下是一个例子:
"geometry": {
"type": "Point",
"coordinates": [-178.581, 51.8418333333333, 22.48]
}
数组中的第一个坐标(位置 0)表示经度;第二个坐标(位置 1)表示纬度。第三个值表示深度(单位为千米),我们暂时不使用。要将这些元素提取为文本,我们使用 #>> 路径操作符,如 列表 16-17 所示。
SELECT id,
earthquake #>> '{geometry, coordinates}' AS coordinates,
earthquake #>> '{geometry, coordinates, 0}' AS longitude,
earthquake #>> '{geometry, coordinates, 1}' AS latitude
FROM earthquakes
ORDER BY id
LIMIT 5;
列表 16-17:提取地震的位置信息
查询应返回五行数据:
id coordinates longitude latitude
-- -------------------------------- ------------ ----------
1 [-122.852, 38.8228333, 2.48] -122.852 38.8228333
2 [-148.3859, 64.2762, 16.2] -148.3859 64.2762
3 [-152.489, 59.0143, 73] -152.489 59.0143
4 [-115.82, 32.7493333, 9.85] -115.82 32.7493333
5 [-115.6446667, 33.1711667, 5.89] -115.6446667 33.1711667
通过快速对比我们的结果与 JSON 中的 longitude 和 latitude 值,我们可以确认提取的值是正确的。接下来,我们将使用 PostGIS 函数将这些值转换为 geography 数据类型中的点。
列表 16-18 为每次地震生成一个 geography 类型的点,我们可以将其用作 PostGIS 空间函数的输入。
SELECT ST_SetSRID(
ST_MakePoint1(
(earthquake #>> '{geometry, coordinates, 0}')::numeric,
(earthquake #>> '{geometry, coordinates, 1}')::numeric
),
43262)::geography AS earthquake_point
FROM earthquakes
ORDER BY id;
列表 16-18:将 JSON 位置信息转换为 PostGIS 地理数据
在 ST_MakePoint() 内,我们放入提取经度和纬度的代码,将这两个值强制转换为 numeric 类型,以符合该函数的要求。我们将该函数嵌套在 ST_SetSRID() 中,以设置结果点的空间参考系统标识符(SRID)。在第十五章中,你学到 SRID 用于指定绘制空间对象的坐标网格。SRID 值 4326 表示常用的 WGS 84 坐标系统。最后,我们将整个输出转换为 geography 类型。前几行数据应该如下所示:
earthquake_point
--------------------------------------------------
0101000020E61000004A0C022B87B65EC0A6C7009A52694340
0101000020E6100000D8F0F44A598C62C0EFC9C342AD115040
0101000020E6100000CFF753E3A50F63C0992A1895D4814D40
`--snip--`
我们无法直接解释那些由数字和字母组成的字符串,但可以使用 pgAdmin 的几何图形查看器来查看地图上的地震点。在 pgAdmin 数据输出窗格中显示查询结果后,点击 earthquake_point 结果头部的眼睛图标。你应该能看到在地图上绘制的地震位置,地图使用 OpenStreetMap 作为底图,如 图 16-1 所示。
即使只有一个月的数据,也很容易看出地震集中在太平洋边缘的环太平洋地震带(即“火环”)区域,这里是构造板块碰撞和火山活动较为活跃的地方。

图 16-1:在 pgAdmin 中查看地震位置
查找指定距离内的地震
接下来,让我们将研究范围缩小到发生在俄克拉荷马州塔尔萨附近的地震——根据美国地质调查局(USGS)的数据,这个地区自 2009 年以来,由于石油和天然气加工活动,地震活动有所增加。
为了执行像这样的复杂 GIS 任务,如果我们将 JSON 坐标永久转换为earthquakes表中的geography类型列,事情会更容易。这样可以避免在每个查询中添加转换代码的混乱。
示例 16-19 向earthquakes表中添加了一个名为earthquake_point的列,并将 JSON 坐标转换为geography类型填充到新列中。
1 ALTER TABLE earthquakes ADD COLUMN earthquake_point geography(POINT, 4326);
2 UPDATE earthquakes
SET earthquake_point =
ST_SetSRID(
ST_MakePoint(
(earthquake #>> '{geometry, coordinates, 0}')::numeric,
(earthquake #>> '{geometry, coordinates, 1}')::numeric
),
4326)::geography;
3 CREATE INDEX quake_pt_idx ON earthquakes USING GIST (earthquake_point);
示例 16-19:将 JSON 坐标转换为 PostGIS 几何列
我们使用ALTER TABLE 1 来添加一个类型为geography的列earthquake_point,并指定该列将包含具有 SRID 4326的点。接下来,我们使用 2 中的UPDATE语句更新表格,使用与示例 16-18 中相同的语法设置earthquake_point列,并对新列添加空间索引,使用 GIST 3。
完成后,我们可以使用示例 16-20 来查找位于塔尔萨 50 英里范围内的地震。
SELECT earthquake #>> '{properties, place}' AS place,
to_timestamp((earthquake -> 'properties' ->> 'time')::bigint / 1000)
AT TIME ZONE 'UTC' AS time,
(earthquake #>> '{properties, mag}')::numeric AS magnitude,
earthquake_point
FROM earthquakes
1 WHERE ST_DWithin(earthquake_point,
2 ST_GeogFromText('POINT(-95.989505 36.155007)'),
80468)
ORDER BY time;
示例 16-20:查找位于塔尔萨市中心 50 英里范围内的地震
在WHERE子句 1 中,我们使用了ST_DWithin()函数,该函数如果一个空间对象在另一个对象的指定距离内,则返回布尔值true。在这里,我们想要评估每个地震点,以检查它是否位于塔尔萨市中心 50 英里范围内。我们在ST_GeogFromText() 2 中指定了该城市的坐标,并使用其米制等价值80468作为输入值,因为米是必需的输入单位。查询应该返回 19 行(我省略了earthquake_point列并截断了结果以便简洁):
place time magnitude
------------------------------ ------------------- ---------
4 km SE of Owasso, Oklahoma 2021-01-04 19:46:58 1.53
6 km SSE of Cushing, Oklahoma 2021-01-05 08:04:42 0.91
2 km SW of Hulbert, Oklahoma 2021-01-05 21:08:28 1.95
`--snip--`
在 pgAdmin 中点击earthquake_point列上方的眼睛图标来查看地震位置。你应该会看到塔尔萨市周围的 19 个点,如图 16-2 所示(你还可以通过点击右上角的图层图标来调整底图样式)。
要实现这些结果,需要进行一些编码技巧,如果数据是以 Shapefile 或典型的 SQL 表格形式到达,原本是无需做这些的。然而,借助 PostgreSQL 对 JSON 格式的支持,依然可以从 JSON 数据中提取有意义的见解。在本章的最后部分,我们将介绍一些有用的 PostgreSQL 函数,用于生成和操作 JSON。

图 16-2:在 pgAdmin 中查看塔尔萨附近的地震
生成和操作 JSON
我们可以使用 PostgreSQL 函数从 SQL 表中的现有行创建 JSON,或修改存储在表中的 JSON,以添加、删除或更改键和值。PostgreSQL 文档中列出了几打与 JSON 相关的函数,网址为www.postgresql.org/docs/current/functions-json.html—我们将学习其中一些你可能会用到的函数。
将查询结果转换为 JSON
由于 JSON 主要是一种用于共享数据的格式,因此能够快速将 SQL 查询的结果转换为 JSON,以便传递给其他计算机系统是非常有用的。清单 16-21 使用 PostgreSQL 特定的 to_json() 函数,将你在第七章中创建的 employees 表的行转换为 JSON 格式。
1 SELECT to_json(employees) AS json_rows
FROM employees;
清单 16-21:使用 to_json() 将查询结果转换为 JSON
to_json() 函数执行它所描述的操作:将提供的 SQL 值转换为 JSON。为了将 employees 表中每一行的所有值转换为 JSON,我们在 SELECT 语句中使用 to_json() 并将表名作为该函数的参数;这样会将每一行作为一个 JSON 对象返回,列名作为键:
json_rows
--------------------------------------------------------------------------------------
{"emp_id":1,"first_name":"Julia","last_name":"Reyes","salary":115300.00,"dept_id":1}
{"emp_id":2,"first_name":"Janet","last_name":"King","salary":98000.00,"dept_id":1}
{"emp_id":3,"first_name":"Arthur","last_name":"Pappas","salary":72700.00,"dept_id":2}
{"emp_id":4,"first_name":"Michael","last_name":"Taylor","salary":89500.00,"dept_id":2}
我们可以通过几种方式修改查询,以限制要包含在结果中的列。在清单 16-22 中,我们使用 row() 构造函数作为 to_json() 的参数。
SELECT to_json(row(emp_id, last_name))1 AS json_rows
FROM employees;
清单 16-22:指定要转换为 JSON 的列
row() 构造函数(符合 ANSI SQL 标准)根据传递给它的参数构建一个行值。在此例中,我们提供了列名 emp_id 和 last_name 1,并将 row() 放在 to_json() 内部。此语法将只返回 JSON 结果中的这些列:
json_rows
----------------------
{"f1":1,"f2":"Reyes"}
{"f1":2,"f2":"King"}
{"f1":3,"f2":"Pappas"}
{"f1":4,"f2":"Taylor"}
然而,请注意,键名是 f1 和 f2,而不是它们的源列名。这是 row() 的副作用,row() 在构建行记录时不会保留列名。我们可以设置键名,通常这样做是为了简化名称并减少 JSON 文件的大小,从而提高传输速度。清单 16-23 展示了通过子查询来实现这一点。
SELECT to_json(employees) AS json_rows
FROM (
1 SELECT emp_id, last_name AS ln2 FROM employees
) AS employees;
清单 16-23:通过子查询生成键名
我们编写一个子查询 1 来获取我们需要的列,并将结果别名为 employees。在此过程中,我们将列名 2 别名为一个简短的名称,以便在 JSON 中作为键使用。
结果应如下所示:
json_rows
--------------------------
{"emp_id":1,"ln":"Reyes"}
{"emp_id":2,"ln":"King"}
{"emp_id":3,"ln":"Pappas"}
{"emp_id":4,"ln":"Taylor"}
最后,清单 16-24 展示了如何将所有 JSON 行编译成一个包含对象的数组。如果你需要将这些数据提供给其他应用程序,供其迭代处理对象数组以执行任务(如计算)或在设备上呈现数据时,可能会需要这样做。
1 SELECT json_agg(to_json(employees)) AS json
FROM (
SELECT emp_id, last_name AS ln FROM employees
) AS employees;
清单 16-24:聚合行并转换为 JSON
我们将 to_json() 包裹在 PostgreSQL 特定的 json_agg() 1 函数中,该函数将包括 NULL 在内的值聚合成一个 JSON 数组。其输出应如下所示:
json
--------------------------------------------------------------------------------------------
[{"emp_id":1,"ln":"Reyes"}, {"emp_id":2,"ln":"King"}, {"emp_id":3,"ln":"Pappas"}, `--snip--` ]
这些是简单的示例,但你可以使用子查询构建更复杂的 JSON 结构,从而生成嵌套对象。我们将在本章结尾的“动手实践”练习中考虑一种实现方法。
添加、更新和删除键值对
我们可以通过连接和 PostgreSQL 特定的函数来添加、更新和删除 JSON 中的内容。让我们通过一些示例来进行操作。
添加或更新顶级键值对
在清单 16-25 中,我们回到films表,并使用两种不同的技术为电影The Incredibles添加顶级键/值对"studio": "Pixar":
UPDATE films
SET film = film ||1 '{"studio": "Pixar"}'::jsonb
WHERE film @> '{"title": "The Incredibles"}'::jsonb;
UPDATE films
SET film = film || jsonb_build_object('studio', 'Pixar')2
WHERE film @> '{"title": "The Incredibles"}'::jsonb;
清单 16-25:通过拼接添加顶级键/值对
两个示例都使用UPDATE语句来为jsonb列film设置新值。在第一个示例中,我们使用 PostgreSQL 的拼接运算符|| 1 将现有的电影 JSON 与我们转换为jsonb的新键值对拼接在一起。在第二个示例中,我们再次使用拼接,但这次使用jsonb_build_object()。这个函数接受一系列的键和值作为参数,并返回一个jsonb对象,这样我们如果需要的话,可以一次性拼接多个键/值对。
如果键在正在拼接的 JSON 中不存在,两条语句都会插入新的键/值对;如果键已经存在,它会覆盖该键。两者之间没有功能上的差异,所以你可以根据个人喜好选择使用其中任何一种。请注意,这种行为是jsonb特有的,它不允许重复的键名。
如果你执行SELECT * FROM films;并双击film列中的更新数据,你应该能看到新的键/值对:
`--snip--`
"rating": {
"MPAA": "PG"
},
"studio": "Pixar",
"characters": [
`--snip--`
在路径上更新值
目前,Cinema Paradiso的genre键有两个条目:
"genre": ["romance", "drama"]
要向数组中添加第三个条目,我们使用jsonb_set()函数,该函数允许我们在特定的 JSON 路径上指定要更新的值。在清单 16-26 中,我们使用UPDATE语句和jsonb_set()来添加World War II这一类别。
UPDATE films
SET film = jsonb_set(film, 1
'{genre}', 2
film #> '{genre}' || '["World War II"]', 3
true4)
WHERE film @> '{"title": "Cinema Paradiso"}'::jsonb;
清单 16-26:使用jsonb_set()在路径上添加数组值
在UPDATE中,我们将film的值设置为jsonb_set()的结果,并使用WHERE限制更新只作用于Cinema Paradiso这一行。该函数的第一个参数 1 是我们想要修改的目标 JSON,这里是film。第二个参数是指向数组值的路径 2——genre键。第三个参数是我们为genre指定的新值,这个新值是当前的genre值与一个包含单一值"World War II"的数组 3 拼接起来的。这个拼接会生成一个包含三个元素的数组。最后一个参数是一个可选的布尔值 4,决定jsonb_set()在值不存在时是否创建该值。由于genre已经存在,所以这里是多余的;我展示它是为了参考。
执行查询后,再快速执行一次SELECT以检查更新后的 JSON。你应该能看到genre数组包含三个值:["romance", "drama", "World War II"]。
删除值
我们可以通过配对两个运算符来从 JSON 对象中删除键和值。清单 16-27 展示了两个UPDATE示例。
UPDATE films
SET film = film -1 'studio'
WHERE film @> '{"title": "The Incredibles"}'::jsonb;
UPDATE films
SET film = film #-2 '{genre, 2}'
WHERE film @> '{"title": "Cinema Paradiso"}'::jsonb;
清单 16-27:从 JSON 中删除值
减号 1 作为删除操作符,删除了我们之前为《超人总动员》添加的studio键及其值。在减号后提供一个文本字符串表示我们要删除一个键及其值;提供一个整数则表示删除该索引处的元素。
#- 2 符号是一个路径删除操作符,它会删除我们指定路径上的 JSON 元素。语法与路径提取操作符#>和#>>相似。在这里,我们使用{genre, 2}来表示 genre 数组中的第三个元素(记住,JSON 数组的索引从零开始)。这将删除我们之前添加到《天堂电影院》中的World War II。
运行这两个语句后,使用SELECT查看修改后的电影 JSON。你应该看到这两个元素已被删除。
使用 JSON 处理函数
为了完成我们的 JSON 学习,我们将回顾一些专门用于处理 JSON 数据的 PostgreSQL 函数,包括将数组值展开为表格行和格式化输出。你可以在 PostgreSQL 文档中找到完整的函数列表,网址是www.postgresql.org/docs/current/functions-json.html。
查找数组的长度
计算数组中元素的数量是一个常见的编程和分析任务。例如,我们可能想知道每部电影的演员在 JSON 中存储了多少。为此,我们可以使用jsonb_array_length()函数,在清单 16-28 中查看。
SELECT id,
film ->> 'title' AS title,
1 jsonb_array_length(film -> 'characters') AS num_characters
FROM films
ORDER BY id;
清单 16-28:查找数组的长度
作为唯一参数,函数 1 接受一个表达式,该表达式提取film中character键的值。运行查询应生成以下结果:
id title num_characters
-- --------------- --------------
1 The Incredibles 3
2 Cinema Paradiso 2
输出正确显示了《超人总动员》有三个角色,《天堂电影院》有两个角色。注意,对于 json 类型有一个类似的json_array_length()函数。
返回数组元素作为行
jsonb_array_elements()和jsonb_array_elements_text()函数将数组元素转换为行,每个元素一行。这是数据处理的一个有用工具。例如,要将 JSON 转换为结构化的 SQL 数据,我们可以使用该函数生成可以插入表格的行,或者生成可以通过分组和计数来聚合的行。
清单 16-29 使用这两个函数将genre键的数组值转换为行。每个函数都接受一个jsonb数组作为参数。它们之间的区别在于,jsonb_array_elements()将数组元素作为jsonb值的行返回,而jsonb_array_elements_text()则将元素返回为text。
SELECT id,
jsonb_array_elements(film -> 'genre') AS genre_jsonb,
jsonb_array_elements_text(film -> 'genre') AS genre_text
FROM films
ORDER BY id;
清单 16-29:返回数组元素作为行
运行代码应生成以下结果:
id genre_jsonb genre_text
-- ----------- ----------
1 "animation" animation
1 "action" action
1 "sci-fi" sci-fi
2 "romance" romance
2 "drama" drama
对于一个包含简单值列表的数组,处理起来非常简单,但如果数组包含包含自己键值对的 JSON 对象集合,比如我们 film JSON 中的 character,我们就需要额外的处理步骤,先将值解包。列表 16-30 详细说明了这一过程。
SELECT id,
jsonb_array_elements(film -> 'characters') 1
FROM films
ORDER BY id;
2 WITH characters (id, json) AS (
SELECT id,
jsonb_array_elements(film -> 'characters')
FROM films
)
3 SELECT id,
json ->> 'name' AS name,
json ->> 'actor' AS actor
FROM characters
ORDER BY id;
列表 16-30:从数组中的每个项返回键值
我们使用 jsonb_array_elements() 来返回 characters 1 数组的元素,这应该会将数组中的每个 JSON 对象作为一行返回:
id jsonb_array_elements
-- ------------------------------------------------------
1 {"name": "Mr. Incredible", "actor": "Craig T. Nelson"}
1 {"name": "Elastigirl", "actor": "Holly Hunter"}
1 {"name": "Frozone", "actor": "Samuel L. Jackson"}
2 {"name": "Salvatore", "actor": "Salvatore Cascio"}
2 {"name": "Alfredo", "actor": "Philippe Noiret"}
为了将 name 和 actor 的值转化为列,我们使用了一个常见的表表达式(CTE),如第十三章所述。我们的 CTE 2 使用 jsonb_array_elements() 来生成一个临时的 characters 表,包含两列:电影的 id 和解包后的数组值,存储在名为 json 的列中。接下来是一个 SELECT 语句 3,它查询该临时表,从 json 列中提取 name 和 actor 的值:
id name actor
-- -------------- -----------------
1 Mr. Incredible Craig T. Nelson
1 Elastigirl Holly Hunter
1 Frozone Samuel L. Jackson
2 Salvatore Cascio
2 Alfredo Philippe Noiret
这些值被整齐地解析为标准 SQL 结构,适合进一步使用标准 SQL 进行分析。
总结
JSON 是一种非常普及的格式,分析数据的过程中你很可能会经常遇到它。你已经了解到 PostgreSQL 能轻松处理 JSON 的加载、索引和解析,但 JSON 有时需要额外的处理步骤,这些步骤在使用标准 SQL 约定处理数据时并不需要。和许多编码领域一样,是否使用 JSON 取决于你的具体情况。现在,你已经掌握了理解上下文所需的知识。
JSON 本身是一个标准,但本章中的数据类型以及大部分函数和语法是 PostgreSQL 特有的。这是因为 ANSI SQL 标准将大多数 JSON 支持的实现留给了数据库供应商。如果你的工作涉及使用 Microsoft SQL Server、MySQL、SQLite 或其他系统,请查阅它们的文档。即使函数名称不同,你会发现它们在能力上有很多相似之处。
第十七章:通过视图、函数和触发器节省时间

使用编程语言的一个优势是我们可以自动化重复的、枯燥的任务。这就是本章的内容:将你可能一遍又一遍执行的查询或步骤转化为可重用的数据库对象,你只需编写一次代码,之后可以调用它们让数据库完成工作。程序员称之为 DRY 原则:不要重复自己。
你将首先学习如何将查询存储为可重用的数据库视图。接下来,你将探索如何创建数据库函数,像使用round()和upper()这样的内置函数一样操作数据。然后,你将设置触发器,当表上发生特定事件时,自动运行这些函数。所有这些技巧不仅有助于减少重复工作,还能确保数据完整性。
我们将在前面章节中的示例上练习这些技巧。本章的所有代码都可以通过nostarch.com/practical-sql-2nd-edition/与本书的资源一起下载。
使用视图简化查询
视图本质上是一个存储的查询,带有一个名称,你可以像操作表一样使用它。例如,一个视图可能存储一个计算每个州总人口的查询。与表一样,你可以查询这个视图,将视图与表(或其他视图)连接,并使用视图更新或插入数据到它所依赖的表,尽管有一些限制。视图中的存储查询可以很简单,只引用一个表,或者很复杂,涉及多个表的连接。
视图在以下情况下尤其有用:
-
避免重复劳动: 它们让你能够只写一次复杂的查询,并在需要时访问结果。
-
减少杂乱: 通过只显示与你需求相关的列,它们可以减少你需要浏览的信息量。
-
提供安全性: 视图可以限制对表中某些列的访问。
在本节中,我们将介绍两种类型的视图。第一种——标准视图——包含与 ANSI SQL 标准大致一致的 PostgreSQL 语法。每次访问标准视图时,存储的查询都会运行并生成一组临时结果。第二种是物化视图,这是 PostgreSQL、Oracle 以及少数其他数据库系统特有的。当你创建物化视图时,它的查询返回的数据会像表一样永久存储在数据库中;如果需要,你可以刷新视图以更新存储的数据。
视图易于创建和维护。让我们通过几个示例来看看它们是如何工作的。
创建和查询视图
在本节中,我们将返回到你在第五章导入的人口普查估计表us_counties_pop_est_2019。清单 17-1 创建了一个标准视图,仅返回内华达州县的总人口。原始表有 16 列;该视图将只返回其中的 4 列。当我们经常引用数据或在应用程序中使用这些数据时,这样做会方便我们快速访问内华达州的人口普查数据的子集。
1 CREATE OR REPLACE VIEW nevada_counties_pop_2019 AS
2 SELECT county_name,
state_fips,
county_fips,
pop_est_2019
FROM us_counties_pop_est_2019
WHERE state_name = 'Nevada';
清单 17-1:创建一个显示内华达州 2019 年县数据的视图
我们使用关键字CREATE OR REPLACE VIEW1 定义视图,后面是视图的名称nevada_counties_pop_2019,然后是AS。(我们可以根据需要命名视图;我更倾向于给视图起一个描述性名称。)接下来,我们使用标准 SQL 的SELECT2 查询us_counties_pop_est_2019表中每个内华达州县的 2019 年人口估计(pop_est_2019列)。
注意CREATE后面的OR REPLACE关键字。这些是可选的,表示如果已经存在同名的视图,则用新定义替换它。如果你在迭代创建视图并希望完善查询时,加入这些关键字会很有帮助。有一个警告:如果你要替换现有视图,新查询 2 必须生成相同的列名,且数据类型和顺序必须与要替换的视图一致。你可以添加列,但它们必须放在列列表的末尾。如果尝试做其他操作,数据库会返回错误消息。
使用 pgAdmin 运行清单 17-1 中的代码。数据库应返回CREATE VIEW消息。要找到新视图,在 pgAdmin 的对象浏览器中,右键点击analysis数据库并点击刷新。选择Schemas▶public▶Views来查看所有视图。当你右键点击新视图并点击属性时,应该能在弹出的对话框中的“代码”标签页看到查询的详细版本(表名会添加到每个列名之前)。这是检查数据库中可能存在的视图的一种方便方式。
这种类型的视图——一个非物化的视图——此时不包含任何数据;相反,它包含的SELECT查询将在你从另一个查询访问该视图时执行。例如,清单 17-2 中的代码返回视图中的所有列。与典型的SELECT查询一样,我们可以使用ORDER BY对结果进行排序,这次使用的是县的联邦信息处理标准(FIPS)代码——美国人口普查局和其他联邦机构用来指定每个县和州的标准标识符。我们还添加了一个LIMIT子句,只显示五行。
SELECT *
FROM nevada_counties_pop_2019
ORDER BY county_fips
LIMIT 5;
清单 17-2:查询nevada_counties_pop_2010视图
除了五行的限制外,结果应该与运行清单 17-1 中用于创建视图的SELECT查询相同:
geo_name | state_fips | county_fips | pop_2010
------------------+------------+-------------+----------
Churchill County | 32 | 001 | 24909
Clark County | 32 | 003 | 2266715
Douglas County | 32 | 005 | 48905
Elko County | 32 | 007 | 52778
Esmeralda County | 32 | 009 | 873
这个简单的示例除非你需要频繁列出内华达州的县人口,否则没有什么实际用处。那么,让我们想象一个政治研究组织中的数据分析师可能经常会问的问题:2010 年到 2019 年期间,每个内华达州(或其他州)县的人口百分比变化是多少?
我们在第七章写过一个查询来回答这个问题,虽然创建这个查询并不繁琐,但它确实需要在两列上进行表连接,并使用包含四舍五入和类型转换的百分比变化公式。为了避免重复这项工作,我们可以创建一个视图,将类似于第七章中的查询作为视图存储,如清单 17-3 所示。
1 CREATE OR REPLACE VIEW county_pop_change_2019_2010 AS
2 SELECT c2019.county_name,
c2019.state_name,
c2019.state_fips,
c2019.county_fips,
c2019.pop_est_2019 AS pop_2019,
c2010.estimates_base_2010 AS pop_2010,
3 round( (c2019.pop_est_2019::numeric - c2010.estimates_base_2010)
/ c2010.estimates_base_2010 * 100, 1 ) AS pct_change_2019_2010
4 FROM us_counties_pop_est_2019 AS c2019
JOIN us_counties_pop_est_2010 AS c2010
ON c2019.state_fips = c2010.state_fips
AND c2019.county_fips = c2010.county_fips;
清单 17-3:创建一个显示美国县人口变化的视图
我们从CREATE OR REPLACE VIEW 1 开始定义视图,接着是视图的名称和AS。SELECT 查询 2 从人口普查表中选择列,并包括一个百分比变化计算的列定义 3,这是你在第六章学习过的内容。然后,我们使用州和县的 FIPS 代码连接 2019 年和 2010 年的人口普查表 4。运行代码后,数据库应该再次返回CREATE VIEW。
现在我们已经创建了视图,可以使用清单 17-4 中的代码,通过新的视图运行一个简单查询,检索内华达州县的数据。
SELECT county_name,
state_name,
pop_2019,
1 pct_change_2019_2010
FROM county_pop_change_2019_2010
2 WHERE state_name = 'Nevada'
ORDER BY county_fips
LIMIT 5;
清单 17-4:从county_pop_change_2019_2010视图中选择列
在清单 17-2 中,引用我们nevada_counties_pop_2019视图的查询中,我们通过在SELECT后使用星号通配符,检索了视图中的所有列。清单 17-4 显示了与查询表格一样,我们在查询视图时可以指定具体的列。这里,我们指定了county_pop_change_2019_2010视图的七列中的四列。其中一列是pct_change_2019_2010 1,它返回我们需要的百分比变化计算结果。如你所见,像这样写列名比写整个公式要简单得多。我们还通过WHERE子句 2 对结果进行了过滤,这与我们过滤任何查询的方式类似。
查询视图中的四列后,结果应该是这样的:
county_name state_name pop_2019 pct_change_2019_2010
---------------- ---------- -------- --------------------
Churchill County Nevada 24909 0.1
Clark County Nevada 2266715 16.2
Douglas County Nevada 48905 4.1
Elko County Nevada 52778 7.8
Esmeralda County Nevada 873 11.4
现在我们可以根据需要随时重新查看这个视图,以便提取数据进行演示,或回答关于 2010 年到 2019 年间任何美国县人口百分比变化的问题。
仅看这五行数据,你可以看到几个有趣的故事:克拉克县的持续快速增长,这里包括了拉斯维加斯市;以及埃斯梅拉达县的强劲百分比增长,埃斯梅拉达县是美国最小的县之一,还是几个鬼镇的所在地。
创建和刷新物化视图
物化视图与标准视图的不同之处在于,在创建时,物化视图的存储查询会被执行,并且它生成的结果会保存在数据库中。实际上,这相当于创建了一个新表。视图保留其存储的查询,因此你可以通过发出刷新视图的命令来更新存储的数据。物化视图的一个好用途是预处理需要较长时间运行的复杂查询,并使这些结果可供更快查询。
让我们删除nevada_counties_pop_2019视图,并使用清单 17-5 中的代码重新创建它作为物化视图。
1 DROP VIEW nevada_counties_pop_2019;
2 CREATE MATERIALIZED VIEW nevada_counties_pop_2019 AS
SELECT county_name,
state_fips,
county_fips,
pop_est_2019
FROM us_counties_pop_est_2019
WHERE state_name = 'Nevada';
清单 17-5:创建物化视图
首先,我们使用DROP VIEW语句删除数据库中的nevada_counties_pop_2019视图。然后,我们运行CREATE MATERIALIZED VIEW语句来创建视图。请注意,语法与创建标准视图相同,只是在添加了MATERIALIZED关键字,并且省略了OR REPLACE,因为物化视图语法中不支持该选项。运行该语句后,数据库应该会响应SELECT 17消息,告诉你视图的查询生成了 17 行数据,将被存储在视图中。现在,我们可以像使用标准视图一样查询这些数据。
假设存储在us_counties_pop_est_2019中的人口估算值已经被修订。要更新存储在物化视图中的数据,我们可以使用REFRESH关键字,如清单 17-6 所示。
REFRESH MATERIALIZED VIEW nevada_counties_pop_2019;
清单 17-6:刷新物化视图
执行该语句会重新运行存储在nevada_counties_pop_2019视图中的查询;服务器将响应REFRESH MATERIALIZED VIEW消息。该视图将反映视图查询引用的任何数据更新。当你有一个需要一定时间来运行的查询时,可以通过将其结果存储在定期刷新的物化视图中节省时间,从而让用户快速访问存储的数据,而不是运行一个冗长的查询。
要删除一个物化视图,我们使用DROP MATERIALIZED VIEW语句。另外,请注意,物化视图会出现在 pgAdmin 对象浏览器的不同部分,位于Schemas▶public▶Materialized Views下。
使用视图插入、更新和删除数据
对于非物化视图,只要视图满足某些条件,你可以更新或插入被查询的底层表中的数据。一个要求是,视图必须引用单个表或可更新的视图。如果视图的查询连接了多个表,如我们在上一节中构建的人口变化视图,则不能直接对原始表执行插入或更新操作。另外,视图的查询不能包含DISTINCT、WITH、GROUP BY或其他子句。(有关限制的完整列表,请参见www.postgresql.org/docs/current/sql-createview.html。)
你已经知道如何直接在表中插入和更新数据,那么为什么要通过视图来操作呢?其中一个原因是,视图是控制用户可以更新哪些数据的一种方式。让我们通过一个例子来看看如何操作。
创建员工视图
在第七章的联接课程中,我们创建并填充了departments和employees表,包含了关于员工和他们工作地点的四行数据(如果你跳过了那部分内容,可以回顾 Listing 7-1)。运行一个快速的SELECT * FROM employees ORDER BY emp_id;查询,可以查看表的内容,如下所示:
emp_id first_name last_name salary dept_id
------ ---------- --------- --------- -------
1 Julia Reyes 115300.00 1
2 Janet King 98000.00 1
3 Arthur Pappas 72700.00 2
4 Michael Taylor 89500.00 2
假设我们希望通过视图使税务部门的用户(其dept_id为1)能够添加、删除或更新他们员工的姓名,但不允许他们更改薪资信息或其他部门员工的数据。为此,我们可以使用 Listing 17-7 中的视图定义来实现。
CREATE OR REPLACE VIEW employees_tax_dept WITH (security_barrier)1 AS
SELECT emp_id,
first_name,
last_name,
dept_id
FROM employees
2 WHERE dept_id = 1
3 WITH LOCAL CHECK OPTION;
Listing 17-7: 在employees表上创建视图
这个视图与我们之前创建的其他视图类似,但有一些额外的功能。首先,在CREATE OR REPLACE VIEW语句中,我们添加了关键字WITH (security_barrier) 1。这为数据库增加了一层安全性,防止恶意用户绕过视图对行和列的限制。(有关如何防止用户绕过视图安全性的详细信息,请参见www.postgresql.org/docs/current/rules-privileges.html。)
在视图的SELECT查询中,我们从employees表中选择要显示的列,并使用WHERE条件过滤出dept_id = 1的数据,只列出税务部门的员工。视图本身会限制对符合WHERE条件的行进行更新或删除。添加关键字WITH LOCAL CHECK OPTION 3 也会限制插入操作,只允许用户添加新的税务部门员工(如果视图定义中没有这些关键字,用户还可以插入dept_id为3的行)。LOCAL CHECK OPTION还会防止用户将员工的dept_id更改为1以外的值。
通过运行 Listing 17-7 中的代码来创建employees_tax_dept视图。然后运行SELECT * FROM employees_tax_dept ORDER BY emp_id;,应该会返回以下两行数据:
emp_id first_name last_name dept_id
------ ---------- --------- -------
1 Julia Reyes 1
2 Janet King 1
查询结果显示了在税务部门工作的员工;他们是整个employees表中四行数据中的两行。
现在,让我们来看一下通过这个视图如何进行插入和更新操作。
使用employees_tax_dept视图插入行
我们可以使用视图来插入或更新数据,但在INSERT或UPDATE语句中,我们不使用表名,而是使用视图名作为替代。在我们通过视图添加或更改数据后,变化会应用到底层表中,这里是employees。视图然后通过它所运行的查询来反映这些变化。
列表 17-8 展示了两个通过employees_tax_dept视图尝试添加新员工记录的例子。第一个成功,第二个失败。
1 INSERT INTO employees_tax_dept (emp_id, first_name, last_name, dept_id)
VALUES (5, 'Suzanne', 'Legere', 1);
2 INSERT INTO employees_tax_dept (emp_id, first_name, last_name, dept_id)
VALUES (6, 'Jamil', 'White', 2);
3 SELECT * FROM employees_tax_dept ORDER BY emp_id;
4 SELECT * FROM employees ORDER BY emp_id;
列表 17-8:通过employees_tax_dept视图成功和拒绝的插入
在第一个INSERT 1 中,我们使用了在第二章中学到的插入语法,提供了 Suzanne Legere 的名字和姓氏,以及她的emp_id和dept_id。由于新行符合视图中的LOCAL CHECK—它包含相同的列且dept_id为1—因此插入在执行时成功。
但是,当我们运行第二个INSERT 2,尝试使用dept_id为2的 Jamil White 来添加员工时,操作失败,出现错误信息new row violates check option for view "employees_tax_dept"。原因是当我们创建视图时,使用了WHERE子句来仅返回dept_id = 1的行。dept_id为2的行没有通过LOCAL CHECK,因此被阻止插入。
运行SELECT语句 3,查看 Suzanne Legere 是否成功添加:
emp_id first_name last_name dept_id
------ ---------- --------- -------
1 Julia Reyes 1
2 Janet King 1
5 Suzanne Legere 1
我们还查询了employees表 4,确实发现 Suzanne Legere 已经被添加到完整的表中。每次访问视图时,它都会查询employees表。
emp_id first_name last_name salary dept_id
------ ---------- --------- --------- -------
1 Julia Reyes 115300.00 1
2 Janet King 98000.00 1
3 Arthur Pappas 72700.00 2
4 Michael Taylor 89500.00 2
5 Suzanne Legere 1
如你从 Suzanne Legere 的添加中看到的那样,我们通过视图添加的数据也会添加到底层表中。然而,由于视图不包含salary列,所以她行中的值为NULL。如果你尝试通过该视图插入薪资值,将会收到错误信息column "salary" of relation "employees_tax_dept" does not exist。原因是即使salary列在底层的employees表中存在,它在视图中并未被引用。同样,这是限制对敏感数据访问的一种方式。如果你打算承担数据库管理员的责任,可以查看我在“使用视图简化查询”一节中的笔记中提供的链接,了解更多关于授权用户和添加WITH (security_barrier)的内容。
使用employees_tax_dept视图更新行
在使用employees_tax_dept视图更新数据时,访问底层表数据的相同限制适用。列表 17-9 展示了一个标准查询,通过UPDATE更正 Suzanne 姓氏的拼写(作为一个姓氏中有多个大写字母的人,我可以确认这种更正并不罕见)。
UPDATE employees_tax_dept
SET last_name = 'Le Gere'
WHERE emp_id = 5;
SELECT * FROM employees_tax_dept ORDER BY emp_id;
列表 17-9:通过employees_tax_dept视图更新行
运行代码后,SELECT查询的结果应显示更新后的姓氏,这会反映在底层的employees表中:
emp_id first_name last_name dept_id
------ ---------- --------- -------
1 Julia Reyes 1
2 Janet King 1
5 Suzanne Le Gere 1
Suzanne 的姓氏现在正确拼写为 Le Gere,而不是 Legere。
然而,如果我们尝试更新一位不在税务部门的员工的姓名,查询会像在示例 17-8 中尝试插入 Jamil White 时一样失败。即使是在税务部门的员工,尝试通过此视图更新薪水也会失败。如果视图没有引用基础表中的某个列,你就无法通过视图访问该列。同样,视图上的更新受到这种限制,提供了保护和隐藏某些数据的方式。
使用 employees_tax_dept 视图删除行
现在,让我们探讨如何使用视图删除行。这里也会有对哪些数据可以影响的限制。例如,如果 Suzanne Le Gere 从另一家公司获得了更好的报价并决定离开,你可以通过 employees_tax_dept 视图将她从 employees 中移除。示例 17-10 显示了标准 DELETE 语法中的查询。
DELETE FROM employees_tax_dept
WHERE emp_id = 5;
示例 17-10:通过 employees_tax_dept 视图删除一行
运行查询时,PostgreSQL 应该会返回 DELETE 1。但是,当你尝试删除一个不在税务部门的员工所在行时,PostgreSQL 会拒绝操作,并返回 DELETE 0。
总结来说,视图不仅可以让你控制数据的访问,还能为你提供处理数据的快捷方式。接下来,让我们探讨如何使用函数来节省击键次数和时间。
创建你自己的函数和过程
在本书中,你已经使用过函数,例如使用 upper() 转换字母为大写,或使用 sum() 计算总和。这些函数背后有大量(有时复杂的)编程代码,它执行一系列操作,可能根据函数的功能返回响应。我们在这里避免使用复杂的代码,但会构建一些基本函数,作为你自己想法的跳板。即使是简单的函数也能帮助你避免重复代码。
本节中的大部分语法是 PostgreSQL 特有的,它支持用户自定义的函数和过程(两者之间的区别微妙,我会给出两者的例子)。你可以使用普通 SQL 来定义函数和过程,但你也可以选择其他选项。一个选项是 PostgreSQL 特有的过程语言 PL/pgSQL,它增加了一些标准 SQL 中没有的特性,例如逻辑控制结构(IF ... THEN ... ELSE)。其他选项包括 PL/Python 和 PL/R,分别用于 Python 和 R 编程语言。
请注意,主要的数据库系统(包括 Microsoft SQL Server、Oracle 和 MySQL)都实现了自己变种的函数和过程。如果你使用的是其他数据库管理系统,本节将帮助你理解与函数相关的概念,但你需要查看数据库文档以了解其对函数的具体实现。
创建 percent_change() 函数
函数处理数据并返回一个值。作为例子,让我们编写一个函数,简化数据分析中的常见任务:计算两个值之间的百分比变化。在第六章中,你学习了我们如何表达百分比变化公式:
percent change = (New Number – Old Number) / Old Number
我们不必每次都写这个公式,可以创建一个名为 percent_change() 的函数,接受新的和旧的数字作为输入,并返回结果,四舍五入到用户指定的小数位数。让我们通过 Listing 17-11 中的代码来了解如何声明一个使用 SQL 的简单函数。
1 CREATE OR REPLACE FUNCTION
2 percent_change(new_value numeric,
old_value numeric,
decimal_places integer 3DEFAULT 1)
4 RETURNS numeric AS
5 'SELECT round(
((new_value - old_value) / old_value) * 100, decimal_places
);'
6 LANGUAGE SQL
7 IMMUTABLE
8 RETURNS NULL ON NULL INPUT;
Listing 17-11: 创建 percent_change() 函数
这段代码发生了很多事情,但它并不像看起来那么复杂。我们从命令 CREATE OR REPLACE FUNCTION 1 开始。与创建视图的语法一样,OR REPLACE 关键字是可选的。接着,我们给出函数的名称 2,并在括号中列出确定函数输入的 参数。每个参数都作为函数的输入,具有名称和数据类型。例如,new_value 和 old_value 是 numeric 类型,要求函数用户提供匹配该类型的输入值,而 decimal_places(指定四舍五入结果的小数位数)是 integer 类型。对于 decimal_places,我们指定 1 作为 DEFAULT 3 值——这使得该参数是可选的,如果用户省略了该参数,默认值将设置为 1。
然后,我们使用关键字 RETURNS numeric AS 4 来告诉函数将其计算结果作为 numeric 类型返回。如果这是一个用于连接字符串的函数,我们可能会返回 text 类型。
接下来,我们编写函数的核心部分,执行计算。在单引号内,我们放入一个 SELECT 查询 5,其中包含嵌套在 round() 函数中的百分比变化计算。在公式中,我们使用函数的参数名而不是数字。
然后,我们提供一系列定义函数属性和行为的关键字。LANGUAGE 6 关键字指定我们使用的是普通 SQL,而不是 PostgreSQL 支持的其他编程语言来创建函数。接下来,IMMUTABLE 关键字 7 表示该函数不能修改数据库,并且对于给定的一组参数,它始终返回相同的结果。RETURNS NULL ON NULL INPUT 8 这一行保证了如果任何默认未提供的输入为 NULL,函数将返回 NULL。
使用 pgAdmin 运行代码以创建 percent_change() 函数。服务器应返回 CREATE FUNCTION 消息。
使用 percent_change() 函数
为了测试新的 percent_change() 函数,可以像 Listing 17-12 中所示,单独运行它,使用 SELECT。
SELECT percent_change(110, 108, 2);
Listing 17-12: 测试 percent_change() 函数
这个例子使用 110 作为新值,108 作为旧值,2 作为四舍五入结果的小数位数。
运行代码;结果应如下所示:
percent_change
----------------
1.85
结果告诉我们,108 和 110 之间的百分比增加为 1.85%。你可以尝试使用其他数字,看看结果如何变化。还可以尝试将decimal_places参数更改为包括0在内的值,或者省略它,看看这如何影响输出。你应该看到结果的小数点后面有更多或更少的数字,具体取决于你的输入。
我们创建了这个函数,以避免在查询中编写完整的百分比变化公式。让我们使用它来计算百分比变化,使用我们在第七章编写的普查估计人口变化查询的版本,如清单 17-13 所示。
SELECT c2019.county_name,
c2019.state_name,
c2019.pop_est_2019 AS pop_2019,
1 percent_change(c2019.pop_est_2019,
c2010.estimates_base_2010) AS pct_chg_func,
2 round( (c2019.pop_est_2019::numeric - c2010.estimates_base_2010)
/ c2010.estimates_base_2010 * 100, 1 ) AS pct_change_formula
FROM us_counties_pop_est_2019 AS c2019
JOIN us_counties_pop_est_2010 AS c2010
ON c2019.state_fips = c2010.state_fips
AND c2019.county_fips = c2010.county_fips
ORDER BY pct_chg_func DESC
LIMIT 5;
清单 17-13:在普查数据上测试percent_change()函数
清单 17-13 修改了第七章中的原始查询,在SELECT中添加了percent_change()函数 1 作为一列。我们还包括了明确的百分比变化公式 2,以便我们可以比较结果。作为输入,我们使用 2019 年人口估算列(c2019.pop_est_2019)作为新数字,使用 2010 年估算基数作为旧数字(c2010.estimates_base_2010)。
查询结果应显示人口变化百分比最大的五个县,并且函数的结果应与直接输入查询中的公式结果相匹配。请注意,pct_chg_func列中的每个值都有一个小数位,这是函数的默认值,因为我们没有提供可选的第三个参数。以下是同时使用函数和公式的结果:
county_name state_name pop_2019 pct_chg_func pct_chg_formula
--------------- ------------ -------- ------------ ---------------
McKenzie County North Dakota 15024 136.3 136.3
Loving County Texas 169 106.1 106.1
Williams County North Dakota 37589 67.8 67.8
Hays County Texas 230191 46.5 46.5
Wasatch County Utah 34091 44.9 44.9
现在我们知道函数按预期工作,我们可以在任何需要解决该计算时使用percent_change()——这比编写公式要快得多!
使用过程更新数据
在 PostgreSQL 中实现的过程与函数非常相似,尽管有一些显著的不同之处。过程和函数都可以执行不返回值的数据操作,例如更新。然而,过程没有返回值的子句,而函数有。此外,过程可以包含我们在第十章中讲解的事务命令,例如COMMIT和ROLLBACK,而函数不能。许多数据库管理系统实现了过程,通常称为存储过程。PostgreSQL 从版本 11 开始添加了过程,它们是 SQL 标准的一部分,但 PostgreSQL 的语法并不完全兼容。
我们可以使用过程简化常规的数据更新。在本节中,我们将编写一个过程,根据教师的雇佣日期以来的时间,更新教师的个人休假天数(除了假期天数)。
在本练习中,我们将返回到第二章第一节的teachers表。如果你跳过了该章节中的“创建表格”部分,现在使用清单 2-2 和 2-3 中的示例代码创建teachers表并插入数据。
让我们向teachers表添加一列,用于存储教师的个人假期,使用列表 17-14 中的代码。新列在我们稍后使用过程填充之前将为空。
ALTER TABLE teachers ADD COLUMN personal_days integer;
SELECT first_name,
last_name,
hire_date,
personal_days
FROM teachers;
列表 17-14:向teachers表添加一列并查看数据
列表 17-14 使用ALTER更新教师表,并通过ADD COLUMN关键字添加personal_days列。然后,我们运行SELECT语句查看数据,同时也包括每个教师的姓名和聘用日期。当两个查询完成后,你应该看到以下六行:
first_name last_name hire_date personal_days
---------- --------- ---------- -------------
Janet Smith 2011-10-30
Lee Reynolds 1993-05-22
Samuel Cole 2005-08-01
Samantha Bush 2011-10-30
Betty Diaz 2005-08-30
Kathleen Roush 2010-10-22
personal_days列目前只包含NULL值,因为我们尚未插入任何内容。
现在,让我们创建一个名为update_personal_days()的过程,该过程将根据获得的个人假期(除了假期天数)填充personal_days列。我们将使用以下标准:
-
自聘用之日起不到 10 年:3 天个人假期
-
自聘用之日起 10 年至 15 年:4 天个人假期
-
自聘用之日起 15 年至 20 年:5 天个人假期
-
自聘用之日起 20 年至 25 年:6 天个人假期
-
自聘用之日起 25 年或以上:7 天个人假期
列表 17-15 中的代码创建了一个过程。这一次,我们不仅使用纯 SQL,还结合了 PL/pgSQL 过程语言的元素,这是 PostgreSQL 支持的一种额外语言,用于编写函数。让我们来看看一些不同之处。
CREATE OR REPLACE PROCEDURE update_personal_days()
AS 1$$
2 BEGIN
UPDATE teachers
SET personal_days =
3 CASE WHEN (now() - hire_date) >= '10 years'::interval
AND (now() - hire_date) < '15 years'::interval THEN 4
WHEN (now() - hire_date) >= '15 years'::interval
AND (now() - hire_date) < '20 years'::interval THEN 5
WHEN (now() - hire_date) >= '20 years'::interval
AND (now() - hire_date) < '25 years'::interval THEN 6
WHEN (now() - hire_date) >= '25 years'::interval THEN 7
ELSE 3
END;
4 RAISE NOTICE 'personal_days updated!';
END;
5 $$
6 LANGUAGE plpgsql;
列表 17-15:创建update_personal_days()函数
我们以CREATE OR REPLACE PROCEDURE开始,并为过程指定一个名称。这一次,我们没有提供参数,因为不需要用户输入——该过程操作的是预定的列,并具有用于计算间隔的固定值。
在编写基于 PL/pgSQL 的函数时,PostgreSQL 的约定通常是使用非 ANSI SQL 标准的美元引号($$)来标记包含所有函数命令的字符串的开始和结束(就像之前的percent_change() SQL 函数一样,你可以使用单引号来包围字符串,但那样字符串中的任何单引号就需要加倍,这不仅看起来凌乱,还可能引起混淆)。所以,$$之间的所有内容就是执行工作的代码。你也可以在美元符号之间添加一些文本,比如$namestring$,以创建一对独特的起始和结束引号。这在某些情况下很有用,比如你需要在函数内部引用一个查询时。
紧接着第一个 $$ 后,我们开始一个 BEGIN ... END; 2 块。这是 PL/pgSQL 的约定,用于标识函数或过程中的代码段的开始和结束;与美元符号引号一样,可以将一个 BEGIN ... END; 块嵌套在另一个块内,以便逻辑分组代码。在这个块内,我们放置了一个 UPDATE 语句,该语句使用 CASE 语句 3 来确定每位教师的个人假期天数。我们通过 now() 函数从服务器获取当前日期,并用其减去 hire_date。根据 now() - hire_date 所在的时间范围,CASE 语句返回对应的个人假期天数。我们使用 PL/pgSQL 关键字 RAISE NOTICE 4 来显示过程完成的消息。最后,我们使用 LANGUAGE 关键字 6 使数据库知道我们编写的代码需要按照 PL/pgSQL 特定的语法进行解释。
运行 示例 17-15 中的代码来创建 update_personal_days() 过程。要调用该过程,我们使用 CALL 命令,这是 ANSI SQL 标准的一部分:
CALL update_personal_days();
当过程运行时,服务器会响应并显示它所引发的通知,内容为 personal_days updated!。
当您重新运行 示例 17-14 中的 SELECT 语句时,您应该会看到 personal_days 列的每一行都填充了相应的值。请注意,结果会有所不同,具体取决于您运行此函数的时间,因为使用 now() 的计算会随时间变化而变化。
first_name last_name hire_date personal_days
---------- --------- ---------- -------------
Janet Smith 2011-10-30 3
Lee Reynolds 1993-05-22 7
Samuel Cole 2005-08-01 5
Samantha Bush 2011-10-30 3
Betty Diaz 2005-08-30 5
Kathleen Roush 2010-10-22 4
您可以在执行某些任务后手动使用 update_personal_days() 函数定期更新数据,或者可以使用任务调度程序(如 pgAgent,一个独立的开源工具)自动运行它。您可以在附录的《PostgreSQL 实用工具、工具和扩展》中了解有关 pgAgent 和其他工具的更多信息。
在函数中使用 Python 语言
之前,我提到过 PL/pgSQL 是 PostgreSQL 中的默认过程语言,但数据库还支持使用开源语言(如 Python 和 R)创建函数。这种支持使您能够在创建的函数中利用这些语言的特性和模块。例如,使用 Python,您可以使用 pandas 库进行分析。有关 PostgreSQL 中包含的语言的详细信息,请参考 www.postgresql.org/docs/current/server-programming.html 上的文档,但在这里我将展示一个使用 Python 的简单函数。
要启用 PL/Python,您必须使用 示例 17-16 中的代码创建扩展。
CREATE EXTENSION plpython3u;
示例 17-16:启用 PL/Python 过程语言
如果你收到错误信息,如image not found,那意味着系统上未安装 PL/Python 扩展。根据操作系统的不同,PL/Python 的安装通常需要安装 Python 并进行一些基本 PostgreSQL 安装之外的额外配置。有关详细信息,请参考第一章中针对你操作系统的安装说明。
启用扩展后,我们可以使用类似你之前尝试过的语法创建一个函数,但在函数体中使用 Python。Listing 17-17 展示了如何使用 PL/Python 创建一个名为trim_county()的函数,该函数移除字符串末尾的County。我们将使用这个函数来清理人口普查数据中的县名。
CREATE OR REPLACE FUNCTION trim_county(input_string text)
1 RETURNS text AS $$
import re2
3 cleaned = re.sub(r' County', '', input_string)
return cleaned
$$
4 LANGUAGE plpython3u;
Listing 17-17: 使用 PL/Python 创建trim_county()函数
结构看起来应该很熟悉。为函数命名并定义其文本输入后,我们使用RETURNS关键字 1 来指定该函数将返回文本。在开头的$$符号之后,我们直接写入 Python 代码,从导入 Python 正则表达式模块re 2 开始。即使你不太了解 Python,你也许可以推测接下来的两行代码 3 是设置一个变量cleaned,它保存了 Python 正则表达式函数sub()的结果。该函数在input_string中查找一个空格后跟单词County的模式,并将其替换为空字符串(由两个撇号表示)。然后该函数返回cleaned变量的内容。最后,我们指定LANGUAGE plpython3u 4 来标明我们使用 PL/Python 编写函数。
运行代码以创建函数,然后执行 Listing 17-18 中的SELECT语句,查看函数执行效果。
SELECT county_name,
trim_county(county_name)
FROM us_counties_pop_est_2019
ORDER BY state_fips, county_fips
LIMIT 5;
Listing 17-18: 测试trim_county()函数
我们使用county_name列作为输入,传递给us_counties_pop_est_2019表中的trim_county()函数。那应该返回以下结果:
county_name trim_county
---------------- -------------
Autauga County Autauga
Baldwin County Baldwin
Barbour County Barbour
Bibb County Bibb
Blount County Blount
如你所见,trim_county()函数检查了county_name列中的每个值,并在存在时删除了空格和单词County。虽然这是一个简单的例子,但它展示了如何轻松地在函数中使用 Python(或其他支持的过程语言)。
接下来,你将学习如何使用触发器来自动化数据库操作。
使用触发器自动化数据库操作
数据库中的触发器在每次发生指定事件(例如INSERT、UPDATE或DELETE)时都会执行一个函数。你可以设置触发器在事件发生前、后,或代替事件触发,并且可以设置它仅对每一行受到事件影响的记录触发一次,或者每次操作只触发一次。例如,假设你从一个表中删除了 20 行数据。你可以设置触发器,在每删除一行时都触发一次,或者只触发一次。
我们将通过两个例子进行演示。第一个例子记录学校成绩的变化日志。第二个例子每次收集温度读数时,自动对温度进行分类。
将成绩更新记录到表格中
假设我们想要自动跟踪我们学校数据库中学生 grades 表的变化。每次更新一行数据时,我们想记录旧成绩、新成绩以及更改发生的时间(在线搜索 David Lightman and grades,你会理解为什么这值得跟踪)。为了自动处理这个任务,我们需要三项内容:
-
一个
grades_history表,用来记录grades表中成绩的变化 -
一个触发器,每次
grades表发生变化时都会运行一个函数,我们将其命名为grades_update -
触发器将执行的函数,我们称之为
record_if_grade_changed()
创建表格以跟踪成绩和更新
我们从创建所需的表格开始。清单 17-19 包含了首先创建并填充 grades 表,然后创建 grades_history 表的代码。
1 CREATE TABLE grades (
student_id bigint,
course_id bigint,
course text NOT NULL,
grade text NOT NULL,
PRIMARY KEY (student_id, course_id)
);
2 INSERT INTO grades
VALUES
(1, 1, 'Biology 2', 'F'),
(1, 2, 'English 11B', 'D'),
(1, 3, 'World History 11B', 'C'),
(1, 4, 'Trig 2', 'B');
3 CREATE TABLE grades_history (
student_id bigint NOT NULL,
course_id bigint NOT NULL,
change_time timestamp with time zone NOT NULL,
course text NOT NULL,
old_grade text NOT NULL,
new_grade text NOT NULL,
PRIMARY KEY (student_id, course_id, change_time)
);
清单 17-19:创建 grades 和 grades_history 表
这些命令很简单。我们使用 CREATE 创建一个 grades 表 1,并使用 INSERT 2 添加四行数据,每一行表示一名学生在某门课程中的成绩。接着,我们使用 CREATE TABLE 创建 grades_history 表 3,用于记录每次现有成绩被更改时的日志。grades_history 表有新成绩、旧成绩和更改时间的列。运行代码以创建这些表格并填充 grades 表。在这里我们不往 grades_history 表插入任何数据,因为触发器会处理这项任务。
创建函数和触发器
接下来,我们编写触发器将执行的 record_if_grade_changed() 函数(注意,PostgreSQL 文档中将此类函数称为 触发器过程)。我们必须在触发器中引用该函数之前先编写它。让我们查看 清单 17-20 中的代码。
CREATE OR REPLACE FUNCTION record_if_grade_changed()
1 RETURNS trigger AS
$$
BEGIN
2 IF NEW.grade <> OLD.grade THEN
INSERT INTO grades_history (
student_id,
course_id,
change_time,
course,
old_grade,
new_grade)
VALUES
(OLD.student_id,
OLD.course_id,
now(),
OLD.course,
3 OLD.grade,
4 NEW.grade);
END IF;
5 RETURN NULL;
END;
$$ LANGUAGE plpgsql;
清单 17-20:创建 record_if_grade_changed() 函数
record_if_grade_changed() 函数遵循早期示例的模式,但在与触发器配合使用时有一些特定的区别。首先,我们指定 RETURNS trigger 1,而不是数据类型。我们使用美元引号来分隔函数的代码部分,并且由于 record_if_grade_changed() 是一个 PL/pgSQL 函数,我们还将执行代码放在 BEGIN ... END; 块中。接下来,我们使用 IF ... THEN 语句 2 启动过程,这是 PL/pgSQL 提供的控制结构之一。我们在这里使用它来仅在更新的成绩与旧成绩不同的情况下运行 INSERT 语句,我们通过 <> 运算符来进行检查。
当 grades 表发生变化时,将执行我们接下来要创建的触发器。对于每一行的更改,触发器将把两个数据集传递到 record_if_grade_changed() 中。第一个是更改之前的行值,以 OLD 前缀标记。第二个是更改之后的行值,以 NEW 前缀标记。函数可以访问原始行值和更新后的行值,用于比较。如果 IF ... THEN 语句评估为 true,表明旧的和新的 grade 值不同,我们使用 INSERT 将包含 OLD.grade 3 和 NEW.grade 4 的行添加到 grades_history。最后,我们包含一个带有 NULL 值的 RETURN 语句 5;触发器过程执行数据库 INSERT,因此我们不需要返回值。
运行 列表 17-20 中的代码来创建函数。然后,使用 列表 17-21 将 grades_update 触发器添加到 grades 表中。
1 CREATE TRIGGER grades_update
2 AFTER UPDATE
ON grades
3 FOR EACH ROW
4 EXECUTE PROCEDURE record_if_grade_changed();
列表 17-21:创建grades_update触发器
在 PostgreSQL 中,创建触发器的语法遵循 ANSI SQL 标准(尽管文档中并不支持标准的所有方面,详情请见 www.postgresql.org/docs/current/sql-createtrigger.html)。代码以 CREATE TRIGGER 1 语句开头,接着是控制触发器何时运行以及如何运行的子句。我们使用 AFTER UPDATE 2 来指定我们希望触发器在 grades 行更新后执行。根据需要,我们还可以使用 BEFORE 或 INSTEAD OF 关键字。
我们使用 FOR EACH ROW 3 来告诉触发器在更新表中的每一行时执行该过程。例如,如果某人运行的更新影响了三行,该过程将运行三次。另一种(也是默认的)是 FOR EACH STATEMENT,它只运行一次过程。如果我们不关心捕获每一行的更改,只想记录在特定时间内更改了成绩,我们可以使用该选项。最后,我们使用 EXECUTE PROCEDURE 4 来指定触发器应运行的函数为 record_if_grade_changed()。
在 pgAdmin 中运行 列表 17-21 中的代码来创建触发器。数据库应该会响应 CREATE TRIGGER 消息。
测试触发器
现在我们已经创建了触发器和函数,当grades表中的数据发生变化时,它应该会运行;让我们看看这个过程是如何运行的。首先,让我们检查我们数据的当前状态。当你运行 SELECT * FROM grades_history; 时,你会看到表是空的,因为我们还没有对 grades 表进行任何更改,没有需要跟踪的内容。接下来,当你运行 SELECT * FROM grades ORDER BY student_id, course_id; 时,你应该会看到你在 列表 17-19 中插入的成绩数据,如下所示:
student_id course_id course grade
---------- --------- ----------------- -----
1 1 Biology 2 F
1 2 English 11B D
1 3 World History 11B C
1 4 Trig 2 B
那个生物学 2 等级看起来不怎么好。让我们使用 列表 17-22 中的代码进行更新。
UPDATE grades
SET grade = 'C'
WHERE student_id = 1 AND course_id = 1;
列表 17-22: 测试 grades_update 触发器
当您运行 UPDATE 后,pgAdmin 不会显示任何内容来告知您后台执行了触发器。它只会报告 UPDATE 1,表示已更新一行。但我们的触发器确实运行了,我们可以通过检查使用此 SELECT 查询查看 grades_history 表中的列来确认:
SELECT student_id,
change_time,
course,
old_grade,
new_grade
FROM grades_history;
当您运行此查询时,您应该看到 grades_history 表中包含了一行变更:
student_id change_time course old_grade new_grade
---------- ----------------------------- --------- --------- ---------
1 2023-09-01 15:50:43.291164-04 Biology 2 F C
此行显示了旧的生物学 2 等级 F,新值 C,以及 change_time,显示了更新的时间(你的结果应反映出你的日期和时间)。请注意,将此行添加到 grades_history 是在没有更新者知情的情况下背景进行的。但是表格上的 UPDATE 事件触发了触发器执行了 record_if_grade_changed() 函数。
如果您曾经使用过内容管理系统,例如 WordPress 或 Drupal,这种修订跟踪可能会很熟悉。它提供了一个有用的记录内容变更的方式,用于参考、审计,以及偶尔的责备。无论如何,自动触发数据库操作的能力让您对数据有了更多的控制。
自动分类温度
在第十三章中,我们使用 SQL 的 CASE 语句将温度读数重新分类为描述性类别。CASE 语句也是 PL/pgSQL 过程化语言的一部分,我们可以利用它的能力为变量赋值,以便每次添加温度读数时自动将这些类别名称存储在表格中。如果我们经常收集温度读数,使用这种技术自动化分类可以避免手动处理任务。
我们将按照记录成绩变化的相同步骤进行操作:首先创建一个函数来分类温度,然后创建一个触发器,在每次更新表格时运行该函数。使用 列表 17-23 来创建一个名为 temperature_test 的表格以供练习使用。
CREATE TABLE temperature_test (
station_name text,
observation_date date,
max_temp integer,
min_temp integer,
max_temp_group text,
PRIMARY KEY (station_name, observation_date)
);
列表 17-23: 创建一个名为 temperature_test 的表格
temperature_test 表包含用于保存站点名称和温度观测日期的列。假设我们有某个过程,每天插入一行数据,提供该位置的最高和最低温度,并且我们需要填写 max_temp_group 列以提供天气预报的描述性分类的文本。
为此,我们首先创建一个名为 classify_max_temp() 的函数,如 列表 17-24 所示。
CREATE OR REPLACE FUNCTION classify_max_temp()
RETURNS trigger AS
$$
BEGIN
1 CASE
WHEN NEW.max_temp >= 90 THEN
NEW.max_temp_group := 'Hot';
WHEN NEW.max_temp >= 70 AND NEW.max_temp < 90 THEN
NEW.max_temp_group := 'Warm';
WHEN NEW.max_temp >= 50 AND NEW.max_temp < 70 THEN
NEW.max_temp_group := 'Pleasant';
WHEN NEW.max_temp >= 33 AND NEW.max_temp < 50 THEN
NEW.max_temp_group := 'Cold';
WHEN NEW.max_temp >= 20 AND NEW.max_temp < 33 THEN
NEW.max_temp_group := 'Frigid';
WHEN NEW.max_temp < 20 THEN
NEW.max_temp_group := 'Inhumane';
ELSE NEW.max_temp_group := 'No reading';
END CASE;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
列表 17-24: 创建 classify_max_temp() 函数
到现在为止,这些函数应该看起来很熟悉。这里的新内容是CASE语法的 PL/pgSQL 版本 1,它与 SQL 语法稍有不同。PL/pgSQL 语法在每个WHEN ... THEN子句后面都包括一个分号。另一个新内容是赋值运算符 :=,我们用它根据CASE函数的结果为NEW.max_temp_group列赋予描述性名称。例如,语句NEW.max_temp_group := 'Cold'会在温度值大于或等于 33 度但小于 50 度华氏度时,将字符串'Cold'赋给NEW.max_temp_group。当函数将NEW行返回以插入表中时,NEW.max_temp_group列会包含字符串值Cold。运行代码以创建该函数。
接下来,使用清单 17-25 中的代码,创建一个触发器,每次向temperature_test添加一行数据时执行该函数。
CREATE TRIGGER temperature_insert
1 BEFORE INSERT
ON temperature_test
2 FOR EACH ROW
3 EXECUTE PROCEDURE classify_max_temp();
清单 17-25:创建temperature_insert触发器
在这个例子中,我们在将行插入表之前对max_temp进行分类,并为max_temp_group创建一个值。这样做比在插入后执行单独的更新操作更高效。为了指定这种行为,我们将temperature_insert触发器设置为在BEFORE INSERT 1 时触发。
我们还希望触发器以FOR EACH ROW 2 触发,因为我们希望表中记录的每个max_temp都能获得一个描述性的分类。最终的EXECUTE PROCEDURE语句指定了我们刚创建的classify_max_temp()函数 3。运行CREATE TRIGGER语句在 pgAdmin 中创建触发器,然后使用清单 17-26 测试设置。
INSERT INTO temperature_test
VALUES
('North Station', '1/19/2023', 10, -3),
('North Station', '3/20/2023', 28, 19),
('North Station', '5/2/2023', 65, 42),
('North Station', '8/9/2023', 93, 74),
('North Station', '12/14/2023', NULL, NULL);
SELECT * FROM temperature_test ORDER BY observation_date;
清单 17-26:插入行以测试temperature_insert触发器
在这里,我们向temperature_test插入了五行数据,并且我们期望每行都会触发temperature_insert触发器——而它确实做到了!清单中的SELECT语句应显示这些结果:
station_name observation_date max_temp min_temp max_temp_group
------------- ---------------- -------- -------- --------------
North Station 2023-01-19 10 -3 Inhumane
North Station 2023-03-20 28 19 Frigid
North Station 2023-05-02 65 42 Pleasant
North Station 2023-08-09 93 74 Hot
North Station 2023-12-14 No reading
感谢触发器和函数的帮助,每个插入的max_temp都会自动在max_temp_group列中获得适当的分类——即使该值没有读取。请注意,触发器对该列的更新将在插入过程中覆盖任何用户提供的值。
这个温度示例和之前的成绩变更审计示例虽然比较基础,但它们让你初步了解了触发器和函数在简化数据维护方面的强大作用。
总结
尽管你在本章中学到的技术开始与数据库管理员的技能重合,但你可以应用这些概念来减少重复某些任务所花费的时间。我希望这些方法能帮助你腾出更多时间,从数据中发现有趣的故事。
本章总结了我们对分析技术和 SQL 语言的讨论。接下来的两章将提供一些工作流程技巧,帮助你提升对 PostgreSQL 的掌握。内容包括如何从计算机的命令行连接数据库并运行查询,以及如何维护你的数据库。
第十八章:从命令行使用 PostgreSQL

在本章中,您将学习如何从命令行操作 PostgreSQL,这是一个文本界面,您可以输入程序名称或其他命令来执行任务,例如编辑文件或列出文件目录的内容。
命令行——也称为命令行界面、控制台、shell或终端——在计算机拥有图形用户界面(GUI)之前就已存在,GUI 包含菜单、图标和按钮,供用户点击导航。在大学时代,要编辑文件,我必须在连接到 IBM 大型机的终端中输入命令。那种工作方式让人感觉神秘,仿佛获得了新的力量——事实上也确实如此!今天,即使在 GUI 世界中,熟悉命令行对于那些希望成为专家级技能的程序员至关重要。也许这就是为什么电影中想要表达角色真正了解如何操作计算机时,会展示他们输入神秘的、纯文本的命令。
当我们学习这个纯文本世界时,请注意掌握命令行工作而不是 GUI(如 pgAdmin)的这些优点:
-
您可以通过输入短命令而不是点击多层菜单项来提高工作效率。
-
您可以获得只有命令行才能提供的功能。
-
如果您只能使用命令行访问(例如连接到远程计算机时),您仍然可以完成工作。
我们将使用 psql,这是随 PostgreSQL 一起提供的命令行工具,允许您运行查询、管理数据库对象,并通过文本命令与计算机操作系统进行交互。您将学习如何设置和访问计算机的命令行,然后启动 psql。在此过程中,我们将涵盖一般命令行语法以及用于数据库任务的额外命令。耐心十分重要:即使是有经验的专家也经常需要查阅文档以回忆可用的命令行选项。
设置 psql 命令行
要开始,我们将访问操作系统上的命令行,并在需要时设置一个名为 PATH 的环境变量,该变量告诉系统在哪里找到 psql。环境变量保存指定系统或应用程序配置的参数,例如临时文件存储位置;它们还允许您启用或禁用选项。PATH 环境变量存储一个或多个包含可执行程序的目录名称,在这种情况下,它告诉命令行界面 psql 的位置,避免每次启动时输入其完整目录路径的麻烦。
Windows psql 设置
在 Windows 上,您将在命令提示符中运行 psql,这是该系统提供的命令行界面应用程序。在执行此操作之前,我们需要告诉命令提示符在哪里找到psql.exe——Windows 上 psql 应用程序的完整名称。
将 psql 和实用程序添加到 Windows PATH
以下步骤假设你按照第一章中的“Windows 安装”部分描述的说明安装了 PostgreSQL。(如果你是通过其他方式安装 PostgreSQL,可以使用 Windows 文件资源管理器搜索 C:驱动器,找到包含psql.exe的目录,并将以下步骤中的C:\Program Files\PostgreSQL*x\bin*替换为你自己的路径。)
-
通过点击 Windows 任务栏上的搜索图标,输入控制面板,然后点击控制面板图标,打开 Windows 控制面板。
-
在控制面板应用中,在搜索框中输入环境。在显示的搜索结果列表中,点击编辑系统环境变量。系统属性对话框应该会出现。
-
在系统属性对话框中,点击“高级”标签,然后点击环境变量。打开的对话框应该有两个部分:用户变量和系统变量。如果在用户变量部分没有看到
PATH变量,请继续执行步骤 a 以创建一个新的。如果看到了现有的PATH变量,请继续执行步骤 b 以修改它。-
如果在用户变量部分没有看到
PATH,点击新建以打开一个新的用户变量对话框,如图 18-1 所示。![f18001]()
图 18-1:在 Windows 10 中创建新的
PATH环境变量在变量名框中,输入PATH。在变量值框中,输入C:\Program Files\PostgreSQL*****x\bin,其中x是你使用的 PostgreSQL 版本。(你可以点击浏览目录,在“浏览文件夹”对话框中导航到该目录,而不是手动输入。)输入路径后,或者浏览到路径后,点击所有对话框中的确定*以关闭它们。
-
如果在用户变量部分看到了现有的
PATH变量,选中它并点击编辑。在显示的变量列表中,点击新建并输入C:\Program Files\PostgreSQL*****x\bin,其中x是你使用的 PostgreSQL 版本。(你可以点击浏览目录,在“浏览文件夹”对话框中导航到该目录,而不是手动输入。)结果应类似于图 18-2 中的高亮部分。完成后,点击所有对话框中的确定*以关闭它们。
-
现在,当你启动命令提示符时,PATH应该包含该目录。请注意,每次你修改PATH时,必须关闭并重新打开命令提示符,以使更改生效。接下来,让我们设置命令提示符。

图 18-2:在 Windows 10 中编辑现有的PATH环境变量
启动并配置 Windows 命令提示符
命令提示符是一个名为cmd.exe的可执行文件。要启动它,请选择开始▶Windows 系统▶命令提示符或在搜索栏中输入cmd。当应用程序打开时,您应该看到一个具有黑色背景的窗口,显示版本和版权信息,以及显示您当前目录的提示符。在我的 Windows 10 系统中,命令提示符打开到默认用户目录,并显示C:\Users\adeba>,如图图 18-3 所示。
这一行被称为提示符,它显示当前的工作目录。对我来说,这是我的 C:驱动器,通常是 Windows 系统上的主硬盘,以及该驱动器上的\Users\adeba目录。大于号>表示您输入命令的区域。

图 18-3:我的 Windows 10 命令提示符
您可以通过点击命令提示符窗口栏左侧的图标并从菜单中选择属性来自定义字体和颜色以及访问其他设置。为了使命令提示符更适合查询输出,我建议将窗口大小(在布局选项卡上)设置为至少宽度为 80,高度为 25。对于字体,官方的 PostgreSQL 文档建议使用 Lucida Console 以正确显示所有字符。
在 Windows 命令提示符上输入指令
现在您可以在命令提示符中输入指令了。在提示符处输入help,然后按键盘上的回车键,以查看可用的 Windows 系统命令列表。您可以通过在help后面包含其名称来查看有关命令的信息。例如,输入help time以显示关于使用time命令设置或查看系统时间的信息。
探索命令提示符的全部工作是一个超出本书范围的巨大主题;但是,我鼓励您尝试一些在表 18-1 中包含的有用且经常使用的命令,尽管这些命令实际上对本章的练习并不是必需的。
表 18-1:有用的 Windows 命令
| 命令 | 功能 | 示例 | 操作 |
|---|---|---|---|
cd |
更改目录 | cd C:\my-stuff |
切换到 C:驱动器上的my-stuff目录 |
copy |
复制文件 | copy C:\my-stuff\song.mp3 C:\Music\song_favorite.mp3 |
将song.mp3文件从my-stuff复制到Music目录中并命名为song_favorite.mp3 |
del |
删除 | del *.jpg |
删除当前目录中所有扩展名为.jpg的文件(星号通配符) |
dir |
列出目录内容 | dir /p |
按屏幕一次显示目录内容(使用/p选项) |
findstr |
在文本文件中查找匹配正则表达式的字符串 | findstr "peach" *.txt |
在当前目录中所有.txt文件中搜索文本peach |
mkdir |
创建新目录 | makedir C:\my-stuff\Salad |
在my-stuff目录中创建一个Salad目录 |
move |
移动文件 | move C:\my-stuff\song.mp3 C:\Music\ |
将文件 song.mp3 移动到 C:\Music 目录 |
在打开并配置好命令提示符后,你就可以开始操作了。可以跳到“使用 psql”这一部分。
macOS psql 配置
在 macOS 上,你将在终端中运行 psql,终端是一个通过几种 shell 程序(如 bash 或 zsh)访问系统命令行的应用程序。Unix 或 Linux 系统上的 shell 程序,包括 macOS,不仅提供了用户输入指令的命令提示符,还提供了自己的编程语言用于自动化任务。例如,你可以使用 bash 命令编写程序,登录到远程计算机、传输文件并登出。
如果你按照第一章中的 Postgres.app macOS 安装说明进行设置——包括在终端中运行命令——你不需要额外的配置来使用 psql 及相关命令。接下来,我们将继续启动终端。
启动和配置 macOS 终端
通过导航至 应用程序▶实用工具▶终端来启动终端。打开后,你应该能看到一个窗口,显示上次登录的日期和时间,随后是一个提示符,包含你的计算机名、当前工作目录和用户名。在我的 Mac 上,它设置为 bash shell,提示符显示为 ad:~ anthony$,并以美元符号($)结束,如图 18-4 所示。
波浪号(~)代表系统的主目录,即 /Users/anthony。终端不会显示完整的目录路径,但你可以随时通过输入 pwd 命令(代表 print working directory)并按下回车键来查看这些信息。美元符号后面的区域就是你输入命令的地方。
如果你的 Mac 设置了其他的 shell,比如 zsh,你的提示符可能看起来有所不同。例如,使用 zsh 时,提示符以百分号结束。你使用的具体 shell 对于这些练习来说没有影响。

图 18-4:macOS 终端命令行
如果你从未使用过终端,它默认的黑白色调可能看起来有些单调。你可以通过选择终端▶偏好设置来更改字体、颜色和其他设置。为了让终端界面更大,更适合显示查询输出,我建议将窗口大小(在配置文件下的窗口标签页中)设置为至少 80 列宽和 25 行高。我的首选字体(在文本标签页中)是 Monaco 14,但可以尝试不同的字体,找到你喜欢的。
探索终端及相关命令的完整功能是一个庞大的话题,超出了本书的范围,但还是建议你花些时间尝试几个命令。表 18-2 列出了一些常用且实用的命令,这些命令实际上并不是本章练习所必需的。输入man(即manual的缩写)后跟命令名称,即可获得该命令的帮助。例如,使用man ls可以了解如何使用ls命令列出目录内容。
表 18-2:实用终端命令
| 命令 | 功能 | 示例 | 操作 |
|---|---|---|---|
cd |
切换目录 | cd /Users/pparker/my-stuff/ |
切换到my-stuff目录 |
cp |
复制文件 | cp song.mp3 song_backup.mp3 |
将文件song.mp3复制为song_backup.mp3,并保存在当前目录 |
grep |
查找匹配正则表达式的文本字符串 | grep 'us_counties_2010' *.sql |
查找所有扩展名为.sql的文件中包含us_counties_2010文本的行 |
ls |
列出目录内容 | ls -al |
以“长格式”列出所有文件和目录(包括隐藏文件) |
mkdir |
创建一个新目录 | mkdir resumes |
在当前工作目录下创建一个名为resumes的目录 |
mv |
移动文件 | mv song.mp3 /Users/pparker/songs |
将文件song.mp3从当前目录移动到用户目录下的/songs目录 |
rm |
删除文件 | rm *.jpg |
删除当前目录下所有扩展名为.jpg的文件(使用星号通配符) |
打开并配置好终端后,你已经准备好开始了。直接跳到“使用 psql”部分。
Linux psql 设置
回顾第一章中的“Linux 安装”部分,安装 PostgreSQL 的方法因 Linux 发行版不同而有所差异。尽管如此,psql是标准 PostgreSQL 安装的一部分,你可能已经通过你的发行版命令行终端应用程序在安装过程中运行过psql命令。即使没有,标准的 Linux PostgreSQL 安装会自动将psql添加到PATH中,因此你应该能够访问它。
启动终端应用程序并继续下一部分,“使用 psql”。在某些发行版(如 Ubuntu)中,你可以通过按 ctrl-alt-T 打开终端。另外,请注意,表 18-2 中的 Mac 终端命令同样适用于 Linux,并可能对你有所帮助。
使用 psql
现在,你已经识别出命令行界面并设置好路径来识别psql,接下来我们启动psql并连接到本地安装的 PostgreSQL 数据库。然后,我们将探索执行查询和用于检索数据库信息的特殊命令。
启动 psql 并连接到数据库
无论您使用的操作系统是什么,启动 psql 的方式都是相同的。打开您的命令行界面(在 Windows 上是命令提示符,在 macOS 或 Linux 上是终端)。要启动 psql 并连接到数据库,您可以在命令提示符中使用以下模式:
psql -d `database_name` -U `user_name`
在 psql 应用程序名称后,我们通过 -d 数据库参数指定数据库名称,通过 -U 用户名参数指定用户名。
对于数据库名称,我们将使用 analysis,这是我们为本书练习创建表格和其他对象的地方。对于用户名,我们将使用 postgres,这是安装过程中创建的默认用户。因此,要连接到本地服务器上的 analysis 数据库,在命令行中输入以下内容:
psql -d analysis -U postgres
请注意,您可以通过指定 –h 参数后跟主机名来连接到远程服务器上的数据库。例如,如果您要连接到名为 analysis 的数据库,且该数据库位于名为 example.com 的服务器上,可以使用以下命令:
psql -d analysis -U postgres -h example.com
无论如何,如果您在安装过程中设置了密码,在启动 psql 时应该会看到密码提示。如果是这样,请输入您的密码。连接到数据库后,您应该会看到类似下面的提示符:
psql (13.3)
Type "help" for help.
analysis=#
在这里,第一行列出了 psql 的版本号以及您连接的服务器。您的版本会根据您安装 PostgreSQL 的时间有所不同。您将输入命令的提示符是 analysis=#,它表示数据库的名称,后面跟着等号(=)和井号(#)。井号表示您以 超级用户 权限登录,超级用户权限允许您无限制地访问和创建对象,并设置账户和安全性。如果您以非超级用户身份登录,提示符的最后一个字符将是大于号(>)。如您所见,您登录的用户账户(postgres)是超级用户。
最后,在 Windows 系统上,启动 psql 后,可能会看到一条警告消息,提示控制台代码页与 Windows 代码页不同。这与命令提示符与 Windows 系统其余部分之间的字符集不匹配有关。对于本书中的练习,您可以安全地忽略该警告。如果您愿意,您可以在每次会话启动前通过在 Windows 命令提示符中输入命令 cmd.exe /c chcp 1252 来消除该警告。
获取帮助或退出
在 psql 提示符下,您可以通过一组 元命令 获取 psql 和通用 SQL 的帮助,详细内容见 表 18-3。元命令以反斜杠(\)开头,除了提供帮助之外,还可以提供有关数据库的信息、调整设置或处理数据。
表 18-3:psql 内的帮助命令
| 命令 | 显示内容 |
|---|---|
\? |
psql 中可用的命令,例如 \dt 用于列出表格。 |
\? options |
psql 命令的选项,例如 -U 用于指定用户名。 |
\? variables |
用于 psql 的变量,例如当前 psql 版本的 VERSION。 |
\h |
SQL 命令列表。添加命令名称以查看详细帮助(例如, \h INSERT)。 |
即使是经验丰富的用户,通常也需要刷新命令和选项,因此在 psql 应用程序中查看详细信息非常方便。要退出 psql,请使用元命令 \q(用于 quit)。
更改数据库连接
在使用 SQL 时,通常需要处理多个数据库,因此您需要一种在数据库之间切换的方法。您可以在 psql 提示符下使用 \c 元命令轻松完成此操作。
例如,在连接到您的 analysis 数据库时,在 psql 提示符下输入以下命令以创建名为 test 的数据库:
analysis=# **CREATE DATABASE test;**
然后,要连接到您刚刚创建的 test 数据库,在 psql 提示符下输入 \c,后跟数据库名称(如果提示,提供您的 PostgreSQL 密码):
analysis=# **\c test**
应用程序应以以下消息作出响应:
You are now connected to database "test" as user "postgres".
test=#
提示符将显示您当前连接的数据库。要以不同的用户身份登录——例如,使用 macOS 安装创建的用户名——您可以在数据库名称后添加该用户名。在我的 Mac 上,语法如下所示:
analysis-# **\c test anthony**
响应将如下所示:
You are now connected to database "test" as user "anthony".
test=#
为了减少杂乱,您可以删除您创建的 test 数据库。首先,使用 \c 断开与 test 的连接,并连接到 analysis 数据库(只有在没有人连接到它时,我们才能删除数据库)。一旦连接到 analysis,在 psql 提示符下输入 DROP DATABASE test;。
设置密码文件
如果您不希望在启动 psql 时看到密码提示,您可以设置一个文件来存储包含服务器名称、用户名和密码的数据库连接信息。启动时,psql 将读取该文件,并在文件中包含与数据库连接和用户名匹配的条目时跳过密码提示。
在 Windows 10 上,文件必须命名为 pgpass.conf,并且必须位于以下目录中:C:\Users\YourUsername*\AppData\Roaming\postgresql*。您可能需要创建 postgresql 目录。在 macOS 和 Linux 上,文件必须命名为 .pgpass,并且必须位于您的用户主目录中。文档 www.postgresql.org/docs/current/libpq-pgpass.html 中指出,在 macOS 和 Linux 上,创建文件后,您可能需要通过在命令行运行 chmod 0600 ~/.pgpass 来设置文件权限。
使用文本编辑器创建文件,并保存为适合您系统的正确名称和位置。在文件中,您需要为每个数据库连接添加一行,格式如下:
`hostname`:`port`:`database`:`username`:`password`
例如,要为 analysis 数据库和 postgres 用户名设置连接,请输入此行,并替换您的密码:
localhost:5432:analysis:postgres:`password`
你可以在前四个参数中的任何一个位置用星号替代,作为通配符。例如,为了为任何本地数据库提供密码,使用 postgres 用户名时,可以用星号代替数据库名称:
localhost:5432:*:postgres:`password`
保存你的密码可以减少一些输入,但要注意遵循安全最佳实践。始终使用强密码和/或物理安全密钥保护你的电脑,并且不要在任何公共或共享的系统上创建密码文件。
在 psql 中运行 SQL 查询
我们已经配置了 psql 并连接到了数据库,现在让我们运行一些 SQL 查询。我们将从单行查询开始,然后运行多行查询。
你可以直接在 psql 提示符下输入 SQL。例如,为了查看我们在整本书中使用的 2019 年人口普查表的几行数据,可以在提示符下输入查询,如列表 18-1 所示。
analysis=# **SELECT county_name FROM us_counties_pop_est_2019 ORDER BY county_name LIMIT 3;**
列表 18-1:在 psql 中输入单行查询
按下回车键执行查询,psql 应该会在终端中显示包括返回行数在内的以下结果:
county_name
------------------
Abbeville County
Acadia Parish
Accomack County
(3 rows)
analysis=#
在结果下方,你可以看到 analysis=# 提示符再次出现,准备接受进一步的输入。你可以使用键盘上的上下箭头来滚动查看最近的查询,并按回车键重新执行它们,这样就无需重新输入。
输入多行查询
你并不局限于单行查询。如果你的查询跨越多行,你可以一次输入一行,每输入一行按下回车键,psql 会知道直到你提供分号后才执行查询。让我们在列表 18-1 中的查询基础上重新输入,分成多行展示在列表 18-2 中。
analysis=# SELECT county_name
analysis-# FROM us_counties_pop_est_2019
analysis-# ORDER BY county_name
analysis-# LIMIT 3;
列表 18-2:在 psql 中输入多行查询
注意,当你的查询超过一行时,数据库名称和井号之间的符号会从等号变为连字符。这个多行查询只有在你按下最后一行并以分号结尾后才会执行。
在 psql 提示符中检查是否有开放的括号
psql 的另一个有用功能是,当你没有关闭一对括号时,它会提醒你。列表 18-3 展示了这一功能。
analysis=# CREATE TABLE wineries (
analysis(# id bigint,
analysis(# winery_name text
analysis(# );
CREATE TABLE
列表 18-3:在 psql 提示符中显示开放的括号
在这里,你创建了一个简单的表 wineries,它有两列。在输入 CREATE TABLE 语句的第一行和左括号(()后,提示符会从 analysis=# 变为 analysis(#,以包括一个左括号。这提醒你左括号需要配对关闭。提示符会保持这种配置,直到你添加右括号为止。
编辑查询
要修改你在 psql 中执行的最新查询,可以使用 \e 或 \edit 元命令进行编辑。输入 \e 打开最后执行的查询,在文本编辑器中进行修改。psql 默认使用的编辑器取决于你的操作系统。
在 Windows 上,psql将打开记事本,这是一个简单的图形界面文本编辑器。编辑查询后,通过选择文件▶保存保存文件,然后使用文件▶退出退出记事本。当记事本关闭时,psql应该会执行你修改过的查询。
在 macOS 和 Linux 上,psql使用一个名为vim的命令行应用程序,这是程序员们最喜欢的工具之一,但对于初学者来说可能显得难以理解。可以查看一个有用的vim备忘单,地址是vim.rtorr.com/。目前,你可以按照以下步骤进行简单的编辑:
-
当
vim在终端中打开查询时,按 I 激活插入模式。 -
对查询进行编辑。
-
按 esc 键,然后按 SHIFT-:,在
vim屏幕左下角显示冒号命令提示符,这是你输入命令以控制vim的地方。 -
输入
wq(表示写入,退出),然后按回车键保存更改。
现在,当vim退出时,psql提示符应该会执行你修改过的查询。按上箭头键可以查看修改后的文本。
导航和格式化结果
你在列表 18-1 和 18-2 中运行的查询只返回了一列和几行,因此它的输出很容易适应命令行界面。但对于返回更多列或更多行的查询,输出可能会填满多个屏幕,使得浏览变得困难。幸运的是,你可以使用\pset元命令通过几种方式自定义输出的显示样式。
设置结果的分页
调整输出格式的一种方法是指定psql如何处理长查询结果的滚动,这被称为分页。默认情况下,如果查询结果的行数超过屏幕所能显示的行数,psql会显示第一屏的行,然后让你滚动查看剩余的内容。例如,列表 18-4 显示了当我们从列表 18-1 中的查询中移除LIMIT子句时,在psql提示符下发生的情况。
analysis=# **SELECT county_name FROM us_counties_pop_est_2019 ORDER BY county_name;**
county_name
-----------------------------------
Abbeville County
Acadia Parish
Accomack County
Ada County
Adair County
Adair County
Adair County
Adair County
Adams County
Adams County
Adams County
Adams County
-- More --
列表 18-4:带有滚动结果的查询
请记住,这张表有 3,142 行。列表 18-4 仅显示当前屏幕上的前 12 行(可见行数取决于你的终端配置)。在 Windows 上,指示符-- More --表示有更多结果,按回车键可以滚动查看它们。在 macOS 和 Linux 上,指示符将是一个冒号。滚动浏览几千行会需要一些时间。按 q 退出结果并返回到psql提示符。
要绕过手动滚动并立即显示所有结果,你可以通过\pset pager元命令更改pager设置。在psql提示符下运行该命令,它应该返回消息Pager usage is off。现在,当你在列表 18-3 中重新运行查询并关闭pager设置时,你应该能看到类似这样的结果:
`--snip--`
York County
York County
York County
York County
Young County
Yuba County
Yukon-Koyukuk Census Area
Yuma County
Yuma County
Zapata County
Zavala County
Ziebach County
(3142 rows)
analysis=#
你会立即跳到结果的末尾,而无需滚动。要重新开启分页,再次运行\pset pager命令。
格式化结果网格
你还可以使用\pset结合以下选项来格式化结果:
边框 int
- 使用此选项指定结果网格是否没有边框(
0)、是否有内部行分隔列(1)或是否在所有单元格周围有线条(2)。例如,\pset border 2会在所有单元格周围设置线条。
格式未对齐
- 使用选项
\pset format unaligned可以让结果按分隔符分隔的行来显示,而不是按列显示,这类似于 CSV 文件的显示方式。分隔符默认为管道符号(|)。你可以使用fieldsep命令设置不同的分隔符。例如,要设置逗号作为分隔符,可以运行\pset fieldsep ','。要恢复为列视图,可以运行\pset format aligned。你可以使用psql元命令\a在对齐和非对齐视图之间切换。
页脚
- 使用此选项切换结果页脚的显示与隐藏,页脚会显示结果的行数。
null
- 使用此选项设置
psql如何显示NULL值。默认情况下,它们会显示为空白。你可以运行\pset null '(null)',当列值为NULL时,空白将被替换为(null)。
你可以在 PostgreSQL 文档中探索更多选项,网址为www.postgresql.org/docs/current/app-psql.html。此外,你可以在 macOS 或 Linux 上设置.psqlrc文件,或在 Windows 上设置psqlrc.conf文件,来保存你的配置偏好,并在每次psql启动时加载它们。一个很好的示例可以参考www.citusdata.com/blog/2017/07/16/customizing-my-postgres-shell-using-psqlrc/。
查看扩展结果
有时候,将结果以垂直列表的方式显示比传统的行列式表格显示更为有用,尤其当列的数量太多,无法在屏幕上以常规的水平网格显示时,或者你需要逐行扫描列中的值时。在psql中,你可以使用\x(表示扩展)元命令切换到垂直列表视图。理解正常显示和扩展视图之间的差异的最好方法是查看一个示例。Listing 18-5 显示的是你在第十七章中查询grades表时看到的正常显示。
analysis=# SELECT * FROM grades ORDER BY student_id, course_id;
student_id | course_id | course | grade
------------+-----------+-------------------+-------
1 | 1 | Biology 2 | C
1 | 2 | English 11B | D
1 | 3 | World History 11B | C
1 | 4 | Trig 2 | B
(4 rows)
Listing 18-5: grades表查询的正常显示
要切换到扩展视图,在psql提示符下输入\x,此时应显示消息Expanded display is on。然后,当你再次运行相同的查询时,你应该能看到扩展后的结果,如 Listing 18-6 所示。
analysis=# SELECT * FROM grades ORDER BY student_id, course_id;
-[ RECORD 1 ]-----------------
student_id | 1
course_id | 1
course | Biology 2
grade | C
-[ RECORD 2 ]-----------------
student_id | 1
course_id | 2
course | English 11B
grade | D
-[ RECORD 3 ]-----------------
student_id | 1
course_id | 3
course | World History 11B
grade | C
-[ RECORD 4 ]-----------------
student_id | 1
course_id | 4
course | Trig 2
grade | B
Listing 18-6: grades表查询的扩展显示
结果以垂直块的形式显示,每个块之间用记录号分隔。根据你的需求和处理的数据类型,这种格式可能更容易阅读。你可以通过在psql提示符下再次输入\x来恢复列显示。另外,设置\x auto会根据输出的大小自动让 PostgreSQL 选择以表格或扩展视图显示结果。
接下来,让我们探讨如何使用psql来挖掘数据库信息。
数据库信息的元命令
你可以通过一组特定的元命令,让psql显示有关数据库、表格和其他对象的详细信息。为了演示这些命令的使用,我们将探索一个显示数据库中表格的元命令,包括如何在命令后添加加号(+)来扩展输出,以及如何添加一个可选的模式来过滤输出。
要查看表格列表,你可以在psql提示符下输入\dt。以下是我系统上输出的一个片段:
``` List of relations Schema | Name | Type | Owner --------+----------------------------------------+-------+----------- public | acs_2014_2018_stats | table | anthony public | cbp_naics_72_establishments | table | anthony public | char_data_types | table | anthony public | check_constraint_example | table | anthony public | crime_reports | table | anthony `--snip--` ``` This result lists all tables in the current database alphabetically. You can filter the output by adding a pattern the database object name must match. For example, use `\dt us*` to show only tables whose names begin with `us` (the asterisk acts as a wildcard). The results should look like this: ``` List of relations Schema | Name | Type | Owner --------+--------------------------+-------+----------- public | us_counties_2019_shp | table | anthony public | us_counties_2019_top10 | table | anthony public | us_counties_pop_est_2010 | table | anthony public | us_counties_pop_est_2019 | table | anthony public | us_exports | table | anthony ``` Table 18-4 shows several additional commands you might find helpful, including `\l`, which lists the databases on your server. Adding a plus sign to each command, as in `\dt+`, adds more information to the output, including object sizes. Table 18-4: Example of `psql` `\d` Commands | **Command** | **Displays** | | --- | --- | | `\d [pattern]` | Columns, data types, plus other information on objects | | `\di [pattern]` | Indexes and their associated tables | | `\dt [pattern]` | Tables and the account that owns them | | `\du [pattern]` | User accounts and their attributes | | `\dv [pattern]` | Views and the account that owns them | | `\dx [pattern]` | Installed extensions | | `\l [pattern]` | Databases | The entire list of commands is available in the PostgreSQL documentation at [`www.postgresql.org/docs/current/app-psql.html`](https://www.postgresql.org/docs/current/app-psql.html), or you can see details by using the `\?` command noted earlier. ### Importing, Exporting, and Using Files In this section, we’ll explore how to use `psql` to import and export data from the command line, which can be necessary when you’re connected to remote servers, such as Amazon Web Services instances of PostgreSQL. We’ll also use `psql` to read and execute SQL commands stored in a file and learn the syntax for sending `psql` output to a file. #### Using \copy for Import and Export In Chapter 5, you learned how to use the PostgreSQL `COPY` command to import and export data. It’s a straightforward process, but it has one significant limitation: the file you’re importing or exporting must be on the same machine as the PostgreSQL server. That’s fine if you’re working on your local machine, as you’ve been doing with these exercises. But if you’re connecting to a database on a remote computer, you might not have access to its file system. You can get around this restriction by using the `\copy` meta-command in `psql`. The `\copy` meta-command works just like the PostgreSQL `COPY`, except when you execute it at the `psql` prompt, it will route data from your machine to the server you’re connected to, whether local or remote. We won’t actually connect to a remote server to try this since it’s rare to find a public remote server we could connect to, but you can still learn the syntax by using the commands on our local `analysis` database. In Listing 18-7, at the `psql` prompt we use a `DELETE` statement to remove all the rows from the small `state_regions` table you created in Chapter 10 and then import data using `\copy`. You’ll need to change the file path to match the location of the file on your computer. ``` analysis=# DELETE FROM state_regions; DELETE 56 analysis=# \copy state_regions FROM '`C:\YourDirectory\`state_regions.csv' WITH (FORMAT CSV, HEADER); COPY 56 ``` Listing 18-7: Importing data using `\copy` Next, to import the data, we use `\copy` with the same syntax used with PostgreSQL `COPY`, including a `FROM` clause with the file path on your machine, and a `WITH` clause that specifies the file is a CSV and has a header row. When you execute the statement, the server should respond with `COPY 56`, letting you know the rows have been successfully imported. If you’re connected to a remote server via `psql`, you would use the same `\copy` syntax, and the command would route your local file to the remote server for importing. In this example, we used `\copy FROM` to import a file. We could also use `\copy TO` for exporting. Let’s look at an alternate way to import or export data (or run other SQL commands) via `psql`. #### Passing SQL Commands to psql By placing a command in quotes after the `-c` argument, we can send it to our connected server, local or remote. The command can be a single SQL statement, multiple SQL statements separated by semicolons, or a meta-command. This can allow us to run `psql`, connect to a server, and execute a command in a single command line statement—handy if we want to incorporate `psql` statements into shell scripts to automate tasks. For example, we can import data to the `state_regions` table with the statement in Listing 18-8, which must be entered on one line at your command prompt (and not inside `psql`). ``` psql -d analysis -U postgres -c1 'COPY state_regions FROM STDIN2 WITH (FORMAT CSV, HEADER);' <3 `C:\YourDirectory\`state_regions.csv ``` Listing 18-8: Importing data using `psql` with `COPY` To try it, you’ll need to first run `DELETE FROM state_regions;` inside `psql` to clear the table. Then exit `psql` by typing the meta-command `\q`. At your command prompt, enter the statement in Listing 18-8. We first use `psql` and the `-d` and `-U` commands to connect to your `analysis` database. Then comes the `-c` command 1, which we follow with the PostgreSQL statement for importing the data. The statement is similar to `COPY` statements we’ve used with one exception: after `FROM`, we use the keyword `STDIN` 2 instead of the complete file path and filename. `STDIN` means “standard input,” which is a stream of input data that can come from a device, a keyboard, or in this case the file *state_regions.csv*, which we direct 3 to `psql` using the less-than (`<`) symbol. You’ll need to supply the full path to the file. Running this entire command at your command prompt should import the CSV file and generate the message `COPY 56`. #### Saving Query Output to a File It’s sometimes helpful to save the query results and messages generated during a `psql` session to a file, such as to keep a history of your work or to use the output in a spreadsheet or other application. To send query output to a file, you can use the `\o` meta-command along with the full path and name of an output file that `psql` will create. For example, in Listing 18-9 we change the `psql` format style from a table to CSV and then output query results directly to a file. ``` 1 analysis=# \pset format csv Output format is csv. analysis=# SELECT * FROM grades ORDER BY student_id, course_id; 2 student_id,course_id,course,grade 1,1,Biology 2,F 1,2,English 11B,D 1,3,World History 11B,C 1,4,Trig 2,B 3 analysis=# \o '`C:/YourDirectory/`query_output.csv' analysis=# SELECT * FROM grades ORDER BY student_id, course_id; 4 analysis=# ``` Listing 18-9: Saving query output to a file First, we set the output format 1 using the meta-command `\pset format csv`. When you run a simple `SELECT` on the `grades` table, the output 2 should return as values separated by commas. Next, to send that data to a file the next time you run the query, use the `\o` meta-command and then provide a complete path to a file called `query_output.csv` 3. When you run the `SELECT` query again, there should be no output to the screen 4. Instead, you’ll find a file with the contents of the query in the directory specified at 3. Note that every time you run a query from this point, the output is appended to the same file specified after the `\o` (for *output*) command. To stop saving output to that file, you can either specify a new file or enter `\o` with no filename to resume having results output to the screen. #### Reading and Executing SQL Stored in a File To run SQL stored in a text file, you execute `psql` on the command line and supply the filename after an `-f` (for file) argument. This syntax lets you quickly run a query or table update from the command line or in conjunction with a system scheduler to run a job at regular intervals. Let’s say you saved the `SELECT` query from Listing 18-9 in a file called *display-grades.sql*. To run the saved query, use the following `psql` syntax at your command line: ``` psql -d analysis -U postgres -f `C:\YourDirectory\`display-grades.sql ``` When you press enter, `psql` should launch, run the stored query in the file, display the results, and exit. For repetitive tasks, this workflow can save considerable time because you avoid launching pgAdmin or rewriting a query. You also can stack multiple queries in the file so they run in succession, which, for example, you might do if you want to run several updates on your database. ## Additional Command Line Utilities to Expedite Tasks PostgreSQL also has its own set of command line utilities that you can enter in your command line interface without launching `psql`. A listing is available at [`www.postgresql.org/docs/current/reference-client.html`](https://www.postgresql.org/docs/current/reference-client.html), and I’ll explain several in Chapter 19 that are specific to database maintenance. Here I’ll cover two that are particularly useful: creating a database at the command line with the `createdb` utility and loading shapefiles into a PostGIS database via the `shp2pgsql` utility. ### Adding a Database with createdb Earlier in the chapter, you used `CREATE DATABASE` to add the database `test` to your PostgreSQL server. We can achieve the same thing using `createdb` at the command line. For example, to create a new database on your server named `box_office`, run the following at your command line: ``` createdb -U postgres -e box_office ``` The `-U` argument tells the command to connect to the PostgreSQL server using the `postgres` account. The `-e` argument (for *echo*) prints the commands generated by `createdb` as output. Running this command creates the database and prints output to the screen ending with `CREATE DATABASE box_office;`. You can then connect to the new database via `psql` using the following line: ``` psql -d box_office -U postgres ``` The `createdb` command accepts arguments to connect to a remote server (just like `psql` does) and to set options for the new database. A full list of arguments is available at[`www.postgresql.org/docs/current/app-createdb.html`](https://www.postgresql.org/docs/current/app-createdb.html). Again, the `createdb` command is a time-saver that comes in handy when you don’t have access to a GUI. ### Loading Shapefiles with shp2pgsql In Chapter 15, you learned about shapefiles, which contain data describing spatial objects. On Windows and some Linux distributions, you can import shapefiles into a PostGIS-enabled database using the Shapefile Import/Export Manager GUI tool (generally) included with PostGIS. However, the Shapefile Import/Export Manager is not always included with PostGIS on macOS or some flavors of Linux. In those cases (or if you’d rather work at the command line), you can import a shapefile using the PostGIS command line tool `shp2pgsql`. To import a shapefile into a new table from the command line, use the following syntax: ``` shp2pgsql -I -s `SRID` -W `encoding shapefile_name` `table_name` | psql -d `database` -U `user` ``` A lot is happening in this single line. Here’s a breakdown of the arguments (if you skipped Chapter 15, you might need to review it now): 1. `-I` Uses GiST to add an index on the new table’s geometry column. 2. `-s` Lets you specify an SRID for the geometric data. 3. `-W` Lets you specify encoding. (Recall that we used `Latin1` for census shapefiles.) 4. `shapefile_name` The name (including full path) of the file ending with the *.shp* extension. 5. `table_name` The name of the table the shapefile is imported to. Following these arguments, you place a pipe symbol (`|`) to direct the output of `shp2pgsql` to `psql`, which has the arguments for naming the database and user. For example, to load the *tl_2019_us_county.shp* shapefile into a `us_counties_2019_shp` table in the `analysis` database, you can run the following command. Note that although this command wraps onto two lines here, it should be entered as one line in the command line: ``` shp2pgsql -I -s 4269 -W Latin1 tl_2019_us_county.shp us_counties_2019_shp | psql -d analysis -U postgres ``` The server should respond with a number of SQL `INSERT` statements before creating the index and returning you to the command line. It might take some time to construct the entire set of arguments the first time around, but after you’ve done one, subsequent imports should take less time. You can simply substitute file and table names into the syntax you already wrote. ## Wrapping Up Feeling mysterious and powerful yet? Indeed, when you delve into a command line interface and make the computer do your bidding using text commands, you enter a world of computing that resembles a sci-fi movie sequence. Not only does working from the command line save you time, it also helps you overcome barriers you might hit when working in environments that don’t support graphical tools. In this chapter, you learned the basics of working with the command line plus PostgreSQL specifics. You discovered your operating system’s command line application and set it up to work with `psql`. Then you connected `psql` to a database and learned how to run SQL queries via the command line. Many experienced computer users prefer to use the command line for its simplicity and speed once they become familiar with using it. You might, too. In Chapter 19, we’ll review common database maintenance tasks including backing up data, changing server settings, and managing the growth of your database. These tasks will give you more control over your working environment and help you better manage your data analysis projects.
第十九章:维护你的数据库

在我们对 SQL 的探索结束时,我们将了解关键的数据库维护任务以及定制 PostgreSQL 的选项。本章中,你将学习如何跟踪和节省数据库空间,如何更改系统设置,以及如何备份和恢复数据库。你需要执行这些任务的频率取决于你当前的角色和兴趣。如果你想成为数据库管理员或后端开发者,这里所涵盖的主题至关重要。
值得注意的是,数据库维护和性能调优是足够庞大的话题,常常占据整本书的篇幅,而本章主要作为一些基本概念的介绍。如果你想深入了解,可以从附录中的资源开始。
让我们从 PostgreSQL 的VACUUM功能开始,它通过移除未使用的行来缩小表的大小。
使用 VACUUM 回收未使用的空间
PostgreSQL 的VACUUM命令帮助管理数据库的大小,正如第十章《更新大表时提升性能》一文中所讨论的,数据库的大小可能因日常操作而增长。
例如,当你更新行值时,数据库会创建该行的新版本,并保留(但隐藏)旧版本。PostgreSQL 文档将这些你看不见的行称为死元组,其中元组是 PostgreSQL 数据库中行的内部实现方式的名称。删除行时也会发生相同的情况。尽管该行对你不可见,但它仍作为死行存在于表中。
这是经过设计的,目的是让数据库能够在多个事务发生的环境中提供某些功能,旧版本的行可能会被当前事务之外的其他事务所需要。
VACUUM命令清理这些死行。单独运行VACUUM将死行所占的空间标记为可供数据库再次使用(假设任何使用该行的事务已经完成)。在大多数情况下,VACUUM并不会将空间返还给系统的磁盘,它只是将该空间标记为可以用于新数据。要真正缩小数据文件的大小,你可以运行VACUUM FULL,它会将表重新写成一个不包含死行空间的新版本,并删除旧版本。
尽管VACUUM FULL可以释放系统磁盘上的空间,但仍有几个注意事项需要记住。首先,VACUUM FULL的完成时间比VACUUM更长。其次,它在重写表时必须独占访问表,这意味着在操作期间没有人可以更新数据。常规的VACUUM命令可以在更新和其他操作进行时运行。最后,表中的并非所有死空间都是坏的。在许多情况下,拥有可用空间来存放新元组,而不是需要向操作系统请求更多磁盘空间,可以提高性能。
你可以根据需要运行VACUUM或VACUUM FULL,但 PostgreSQL 默认会运行一个自动清理后台进程,它会监控数据库并在需要时运行VACUUM。在本章的后面,我将展示如何监控自动清理并手动运行VACUUM命令。但首先,让我们来看一下表在更新后的增长情况,以及如何跟踪这种增长。
跟踪表大小
我们将创建一个小的测试表,并在向表中填充数据并执行更新时监控其增长。与本书中的所有资源一样,本练习的代码可以在nostarch.com/practical-sql-2nd-edition/找到。
创建一个表并检查其大小
清单 19-1 创建了一个vacuum_test表,该表只有一个列用来存储整数。运行代码后,我们将测量该表的大小。
CREATE TABLE vacuum_test (
integer_column integer
);
清单 19-1:创建一个表以测试清理
在我们向表中填充测试数据之前,让我们检查一下它在磁盘上占用了多少空间,以便建立一个参考点。我们可以通过两种方式来做到这一点:通过 pgAdmin 界面检查表的属性,或者使用 PostgreSQL 管理函数运行查询。在 pgAdmin 中,单击一次表格以突出显示它,然后点击统计选项卡。表的大小是列表中的二十多个指标之一。
我将在这里重点介绍运行查询的技术,因为了解这些查询在 pgAdmin 不可用或使用其他图形用户界面(GUI)时非常有用。清单 19-2 展示了如何使用 PostgreSQL 函数检查vacuum_test表的大小。
SELECT 1pg_size_pretty(
2pg_total_relation_size('vacuum_test')
);
清单 19-2:确定vacuum_test的大小
最外层的函数pg_size_pretty() 1 将字节转换为更易于理解的格式,如千字节、兆字节或千兆字节。内部嵌套的pg_total_relation_size()函数 2 报告了一个表、它的索引以及任何离线压缩数据在磁盘上占用了多少字节。由于此时表为空,运行 pgAdmin 中的代码应该返回0 字节,如下所示:
pg_size_pretty
----------------
0 bytes
你也可以使用命令行获取相同的信息。启动psql,如第十八章所学。然后,在提示符下输入元命令\dt+ vacuum_test,它应该显示包括表大小在内的以下信息(为了节省空间,我省略了一列):
List of relations
Schema | Name | Type | Owner | Persistence | Size
--------+-------------+-------+----------+-------------+---------
public | vacuum_test | table | postgres | permanent | 0 bytes
再次检查,当前的 vacuum_test 表的大小应显示为 0 字节。
添加新数据后检查表的大小
让我们向表中添加一些数据,然后再次检查其大小。我们将使用第十二章中介绍的 generate_series() 函数,将 500,000 行数据填充到表的 integer_column 列中。运行 列表 19-3 中的代码来完成此操作。
INSERT INTO vacuum_test
SELECT * FROM generate_series(1,500000);
列表 19-3:向 vacuum_test 表插入 500,000 行数据
这个标准的 INSERT INTO 语句将 generate_series() 的结果(即从 1 到 500,000 的一系列值)作为行插入表中。查询完成后,再次运行 列表 19-2 中的查询来检查表的大小。你应该看到以下输出:
pg_size_pretty
----------------
17 MB
查询报告显示,vacuum_test 表现在有一列 500,000 个整数,使用了 17MB 的磁盘空间。
更新后检查表的大小
现在,让我们更新数据,看看这如何影响表的大小。我们将使用 列表 19-4 中的代码,通过将 integer_column 的每个值加上 1 来更新 vacuum_test 表中的每一行,将现有值替换为更大的数字。
UPDATE vacuum_test
SET integer_column = integer_column + 1;
列表 19-4:更新 vacuum_test 表中的所有行
运行代码,然后再次测试表的大小。
pg_size_pretty
----------------
35 MB
表的大小从 17MB 翻倍到 35MB!增加的幅度看起来过大,因为 UPDATE 只是用相似大小的值替换了现有的数字。正如你可能猜到的那样,表大小增加的原因是,PostgreSQL 为每个更新的值创建了一个新行,而旧行依然保留在表中。即使你只看到了 500,000 行,表中实际有双倍数量的行。这种行为可能会给不监控磁盘空间的数据库所有者带来意外。
在查看 VACUUM 和 VACUUM FULL 如何影响表的磁盘大小之前,让我们回顾一下自动运行 VACUUM 的过程,以及如何检查与表清理相关的统计信息。
监控自动清理进程
PostgreSQL 的自动清理(autovacuum)进程监控数据库,并在检测到表中有大量死行时自动启动 VACUUM。尽管默认启用自动清理,你可以通过稍后在“更改服务器设置”一节中讲解的设置来开启或关闭它,并进行配置。由于自动清理在后台运行,你不会看到它正在工作的任何明显迹象,但你可以通过查询 PostgreSQL 收集的关于系统性能的数据来检查它的活动。
PostgreSQL 有自己的 统计收集器,用于跟踪数据库活动和使用情况。你可以通过查询系统提供的多个视图之一来查看统计信息。(查看 PostgreSQL 文档中“统计收集器”下的完整视图列表: www.postgresql.org/docs/current/monitoring-stats.html。)要检查自动清理的活动,我们查询 pg_stat_all_tables 视图,如 Listing 19-5 中所示。
SELECT 1relname,
2last_vacuum,
3last_autovacuum,
4vacuum_count,
5autovacuum_count
FROM pg_stat_all_tables
WHERE relname = 'vacuum_test';
Listing 19-5: 查看 vacuum_test 的自动清理统计信息
正如你在第十七章中学到的,视图提供了一个存储查询的结果。视图 pg_stat_all_tables 存储的查询返回一个名为 relname 的列,该列是表的名称,并且还有与索引扫描、插入和删除的行数以及其他数据相关的统计列。对于此查询,我们关心的是 last_vacuum 和 last_autovacuum,它们分别包含表格手动和自动清理的最后时间。我们还请求了 vacuum_count 和 autovacuum_count,它们显示手动和自动运行清理的次数。
默认情况下,自动清理每分钟检查一次表格。因此,如果自上次更新 vacuum_test 已经过去了一分钟,你应该会在运行 Listing 19-5 中的查询时看到清理活动的详细信息。以下是我的系统显示的内容(请注意,我已将时间中的秒数去除,以节省空间):
relname | last_vacuum | last_autovacuum | vacuum_count | autovacuum_count
-------------+-------------+------------------+--------------+------------------
vacuum_test | | 2021-09-02 14:46 | 0 | 1
该表显示了最后一次自动清理的日期和时间,autovacuum_count 列显示了一次出现的记录。这个结果表明,自动清理在该表上执行了 VACUUM 命令一次。然而,由于我们没有手动执行清理,last_vacuum 列为空,vacuum_count 为 0。
回想一下,VACUUM 会将死掉的行标记为可供数据库重新使用,但通常不会减少表格在磁盘上的大小。你可以通过重新运行 Listing 17-2 中的代码来确认这一点,它显示即使在自动清理之后,表格仍然保持在 35MB。
手动运行 VACUUM
要手动运行 VACUUM,可以使用 Listing 19-6 中的单行代码。
VACUUM vacuum_test;
Listing 19-6: 手动运行 VACUUM
该命令应该从服务器返回 VACUUM 的消息。现在,当你再次使用 Listing 17-5 中的查询提取统计信息时,你应该看到 last_vacuum 列反映了你刚刚运行的手动清理的日期和时间,并且 vacuum_count 列中的数字应该增加一个。
在这个例子中,我们对测试表执行了 VACUUM,但是你也可以通过省略表名来对整个数据库执行 VACUUM。此外,你可以添加 VERBOSE 关键字,以返回例如表格中找到的行数和删除的行数等信息。
使用 VACUUM FULL 减小表大小
接下来,我们将使用FULL选项运行VACUUM,该选项实际上会将被删除的元组所占的空间归还给磁盘。它通过创建一个新的表版本并丢弃死行来实现这一点。
要查看VACUUM FULL的工作原理,请运行清单 19-7 中的命令。
VACUUM FULL vacuum_test;
清单 19-7:使用VACUUM FULL来回收磁盘空间
命令执行后,再次测试表的大小。它应该已经恢复到 17MB,这是我们第一次插入数据时的大小。
永远不要让磁盘空间耗尽,因此,关注数据库文件的大小以及整体系统空间是一个值得建立的常规习惯。使用VACUUM来防止数据库文件比必要时更大,是一个不错的开始。
更改服务器设置
你可以通过编辑postgresql.conf中的值来更改 PostgreSQL 服务器的设置,这是控制服务器设置的几个配置文本文件之一。其他文件包括pg_hba.conf,它控制与服务器的连接,以及pg_ident.conf,数据库管理员可以使用它将网络上的用户名映射到 PostgreSQL 中的用户名。有关这些文件的详细信息,请参阅 PostgreSQL 文档;在这里,我们将只介绍postgresql.conf,因为它包含了你可能希望更改的设置。文件中的大多数值都设置为默认值,你可能永远不需要调整它们,但还是值得探索,以防你在微调系统时需要修改。让我们从基础开始。
定位和编辑 postgresql.conf
postgresql.conf的位置取决于你的操作系统和安装方式。你可以运行清单 19-8 中的命令来定位该文件。
SHOW config_file;
清单 19-8:显示postgresql.conf的位置
当我在 macOS 上运行该命令时,它显示文件的路径,如下所示:
/Users/anthony/Library/Application Support/Postgres/var-13/postgresql.conf
要编辑postgresql.conf,请在文件系统中导航到SHOW config_file;显示的目录,并使用文本编辑器打开该文件。不要使用像 Microsoft Word 这样的富文本编辑器,因为它可能会向文件中添加额外的格式。
当你打开文件时,前几行应该是这样的:
# -----------------------------
# PostgreSQL configuration file
# -----------------------------
#
# This file consists of lines of the form:
#
# name = value
`--snip--`
postgresql.conf文件被组织成多个部分,指定文件位置、安全性、信息日志记录和其他进程的设置。许多行以井号符号(#)开始,这表示该行被注释掉,显示的设置是有效的默认值。
例如,在postgresql.conf文件的“自动清理参数”部分,默认情况下启用了自动清理(这是一个很好的标准做法)。行前的井号符号(#)表示该行被注释掉,默认值仍然生效:
#autovacuum = on # Enable autovacuum subprocess? 'on'
要更改此项或其他默认设置,你需要去掉井号,调整设置值,并保存 postgresql.conf。某些更改(如内存分配的更改)需要重启服务器;这些更改会在 postgresql.conf 中注明。其他更改只需要重新加载设置文件。你可以通过在具有超级用户权限的账户下运行 pg_reload_conf() 函数,或者执行 pg_ctl 命令来重新加载设置文件,具体内容我们将在下一节介绍。
清单 19-9 显示了你可能想要更改的设置,摘自 postgresql.conf 中的“客户端连接默认值”部分。使用文本编辑器搜索文件以查找以下内容。
1 datestyle = 'iso, mdy'
2 timezone = 'America/New_York'
3 default_text_search_config = 'pg_catalog.english'
清单 19-9:示例 postgresql.conf 设置
你可以使用 datestyle 设置 1 来指定 PostgreSQL 在查询结果中显示日期的方式。此设置包含两个用逗号分隔的参数:输出格式和月份、日期和年份的排序。输出格式的默认值是 ISO 格式 YYYY-MM-DD,这是本书中始终使用的格式,我推荐使用它,因为它具有跨国便携性。不过,你也可以使用传统的 SQL 格式 MM/DD/YYYY,扩展的 Postgres 格式 Sun Nov 12 22:30:00 2023 EST,或德式格式 DD.MM.YYYY,在日期、月份和年份之间用点号分隔(如 12.11.2023)。要使用第二个参数指定格式,可以按你喜欢的顺序排列 m、d 和 y。
timezone 2 参数设置服务器的时区。清单 19-9 显示了值 America/New_York,它反映了我在安装 PostgreSQL 时机器上的时区。你的值应根据你所在的位置而有所不同。当将 PostgreSQL 设置为数据库应用程序的后端或在网络上使用时,管理员通常将此值设置为 UTC,并将其作为多个位置机器的标准。
default_text_search_config 3 值设置全文搜索操作使用的语言。这里,我的值设置为 english。根据需要,你可以将其设置为 spanish、german、russian 或你选择的其他语言。
这三个示例只是可调节设置的一小部分。除非你深入进行系统调优,否则通常不需要做太多其他调整。此外,在更改多个用户或应用程序使用的网络服务器上的设置时要谨慎;这些更改可能会带来意想不到的后果,因此最好先与同事沟通。
接下来,让我们看一下如何使用 pg_ctl 使更改生效。
使用 pg_ctl 重新加载设置
命令行工具 pg_ctl 允许你对 PostgreSQL 服务器执行操作,如启动、停止以及检查其状态。在这里,我们将使用该工具重新加载设置文件,以便我们所做的更改能够生效。运行此命令将一次性重新加载所有设置文件。
你需要像第十八章学习如何设置和使用psql时那样打开并配置命令行提示符。启动命令提示符后,使用以下命令之一重新加载,替换路径为 PostgreSQL 数据目录的路径:
-
在 Windows 上,使用
pg_ctl reload -D "``C:\path\to\data\directory\``"。 -
在 macOS 或 Linux 上,使用
pg_ctl reload -D '``/path/to/data/directory/``'。
要找到 PostgreSQL 数据目录的位置,请运行示例 19-10 中的查询。
SHOW data_directory;
示例 19-10:显示数据目录的位置
将路径放在-D参数后面,在 Windows 上使用双引号,在 macOS 或 Linux 上使用单引号。你需要在系统的命令提示符下运行此命令,而不是在psql应用程序内。输入命令并按回车键,它应该会返回server signaled的消息。设置文件将重新加载,并且更改应该生效。
如果你更改了需要重启服务器的设置,请将示例 19-10 中的reload替换为restart。
数据库的备份与恢复
你可能希望备份整个数据库,无论是为了保存数据还是为了将数据迁移到新服务器或升级后的服务器。PostgreSQL 提供了命令行工具,使备份和恢复操作变得简单。接下来的几个部分展示了如何将数据库或单个表的数据导出到文件,以及如何从导出文件恢复数据。
使用pg_dump导出数据库或表
PostgreSQL 命令行工具pg_dump创建一个包含你数据库所有数据的输出文件;包括重新创建表、视图、函数和其他数据库对象的 SQL 命令,以及将数据加载到表中的命令。你还可以使用pg_dump仅保存数据库中的特定表。默认情况下,pg_dump输出为文本文件;我将首先讨论一种自定义压缩格式,然后再讨论其他选项。
要将我们在练习中使用的analysis数据库导出到文件,请在系统的命令提示符下(不是在psql中)运行示例 19-11 中的命令。
pg_dump -d analysis -U `user_name` -Fc -v -f analysis_backup.dump
示例 19-11:使用pg_dump导出analysis数据库
在这里,我们以pg_dump开始命令,并使用与psql相似的连接参数。我们通过-d参数指定要导出的数据库,接着是-U参数和你的用户名。然后,我们使用-Fc参数指定我们希望以自定义 PostgreSQL 压缩格式生成此导出文件,并使用-v参数生成详细输出。接着,使用-f参数将pg_dump的输出导向名为analysis_backup.dump的文本文件。如果你希望将文件放在当前终端提示符所在目录之外的目录中,可以在文件名之前指定完整的目录路径。
执行命令时,取决于你的安装方式,你可能会看到一个密码提示。如果有提示,请填写密码。然后,根据数据库的大小,该命令可能需要几分钟才能完成。你将看到一系列关于命令正在读取和输出的对象的消息。当它完成时,它应该会返回一个新的命令提示符,并且你应该能在当前目录中看到一个名为analysis_backup.dump的文件。
若要将导出限制为匹配特定名称的一个或多个表,请使用-t参数,后跟单引号中的表名。例如,要仅备份train_rides表,可以使用以下命令:
pg_dump -t 'train_rides' -d analysis -U `user_name` -Fc -v -f train_backup.dump
现在让我们来看一下如何从导出文件中恢复数据,然后我们将探讨更多的pg_dump选项。
使用pg_restore恢复数据库导出
pg_restore工具从导出的数据库文件中恢复数据。你可能需要在将数据迁移到新服务器或升级到新的 PostgreSQL 主版本时恢复数据库。要恢复analysis数据库(假设你所在的服务器上没有analysis数据库),在命令提示符下,运行 Listing 19-12 中的命令。
pg_restore -C -v -d postgres -U `user_name` analysis_backup.dump
Listing 19-12: 使用pg_restore恢复analysis数据库
在pg_restore后,你添加-C参数,这告诉工具在服务器上创建analysis数据库。(它从导出文件中获取数据库名称。)然后,如前所述,-v参数提供详细输出,-d指定要连接的数据库名称,后跟-U参数和你的用户名。按回车键,恢复过程将开始。当它完成时,你应该能够通过psql或 pgAdmin 查看恢复的数据库。
探索更多的备份和恢复选项
你可以使用多个选项配置pg_dump,以包括或排除某些数据库对象,例如匹配名称模式的表,或指定输出格式。例如,当我们备份analysis数据库时,我们使用-Fc参数与pg_dump一起生成自定义的 PostgreSQL 压缩格式的备份。通过省略-Fc参数,工具将以纯文本格式输出,你可以使用文本编辑器查看备份内容。有关详细信息,请查看完整的pg_dump文档:www.postgresql.org/docs/current/app-pgdump.html。有关相应的恢复选项,请查看pg_restore文档:www.postgresql.org/docs/current/app-pgrestore.html。
你还可以探索pg_basebackup命令,它可以备份在 PostgreSQL 服务器上运行的多个数据库。详情请见www.postgresql.org/docs/current/app-pgbasebackup.html。一个更为强大的备份解决方案是 pgBackRest(pgbackrest.org/),这是一款免费的开源应用,提供如云存储集成等选项,并且支持创建完整备份、增量备份或差异备份。
总结
在本章中,你学习了如何使用 PostgreSQL 中的VACUUM功能来跟踪和节省数据库空间。你还学会了如何更改系统设置,以及如何使用其他命令行工具备份和恢复数据库。你可能不需要每天都执行这些任务,但你在这里学到的维护技巧可以帮助提升数据库的性能。请注意,这不是该主题的全面概述;更多关于数据库维护的资源请参阅附录。
在本书的下一章也是最后一章,我将分享如何识别隐藏的趋势并利用数据讲述有效故事的指南。
第二十章:讲述你的数据故事

学习 SQL 本身就可以很有趣,但它有更重要的意义:它帮助你挖掘数据中的故事。正如你所学到的,SQL 为你提供了找到数据中有趣的趋势、洞察或异常的工具,并基于这些发现做出明智的决策。但你如何从仅仅是一堆行和列的数据中识别出这些趋势呢?在识别出这些趋势后,你又该如何从中提取有意义的洞察?
在本章中,我将概述我作为记者和产品开发人员用来发现数据中故事并传达我的发现的过程。我将从如何通过提问和收集、探索数据来生成创意开始。然后,我会解释分析过程,最终是清晰地呈现你的发现。识别数据集中的趋势并将你的发现形成叙述,有时需要相当多的实验和足够的毅力来应对偶尔的死胡同。把这些建议当作指南,而不是检查清单,帮助确保一个全面的分析,最大程度减少错误。
从一个问题开始
好奇心、直觉,或有时仅仅是运气,往往能激发数据分析的灵感。如果你是一个细心的观察者,你可能会注意到社区随着时间的变化,并想知道是否能衡量这些变化。考虑一下你当地的房地产市场。如果你看到“待售”标志比平时更多地出现在镇上,你可能会开始提出问题。今年与去年相比,房屋销售是否大幅增加?如果是,增加了多少?哪些社区正在受益于这一波变化?这些问题为数据分析提供了一个很好的机会。如果你是记者,你可能会发现一个故事。如果你经营一家企业,你可能会看到一个营销机会。
同样地,如果你推测你的行业中出现了某种趋势,确认这一点可能会为你提供商业机会。例如,如果你怀疑某个产品的销量不景气,你可以分析数据来验证这个猜测,并适当调整库存或营销策略。
记录这些创意并根据其潜在价值进行优先排序。为了满足好奇心进行数据分析是完全可以的,但如果这些答案能让你的机构更高效或让你的公司更有利润,那就意味着它们值得追求。
记录你的过程
在进行分析之前,考虑如何使你的过程透明且可重复。为了保证可信度,组织内外的其他人都应该能够复制你的工作。此外,确保你记录下足够的过程细节,这样如果你将项目搁置几个星期,回来时你也不会遇到困难。
记录工作并没有一种唯一正确的方法。记录研究笔记或创建逐步的 SQL 查询,让其他人可以跟随这些步骤复制你的数据导入、清理和分析过程,可以让别人更容易验证你的发现。有些分析师将笔记和代码存储在文本文件中,另一些则使用版本控制系统,如 GitHub,或在代码笔记本中工作。重要的是你要创建一个文档管理系统,并始终如一地使用它。
收集你的数据
在你想出一个分析思路之后,下一步是找到与趋势或问题相关的数据。如果你在一个已经有相关数据的组织工作,那就太幸运了——你可以直接使用现成的数据!在这种情况下,你可能能够访问内部的市场营销或销售数据库、客户关系管理(CRM)系统,或订阅者或活动注册数据。但如果你的话题涉及更广泛的领域,如人口统计、经济学或行业特定的问题,你就需要做一些深入的挖掘。
一个好的起点是询问专家他们使用的数据来源。分析师、政府决策者和学者可以为你指明可用的数据来源,并描述其有用性。正如你在本书中看到的那样,联邦、州和地方政府会生产大量关于各种主题的数据。在美国,你可以查看联邦政府的数据目录网站 www.data.gov/ 或各个联邦机构的网站,如国家教育统计中心(NCES) nces.ed.gov/ 或劳工统计局 www.bls.gov/。
你还可以浏览当地政府网站。每当你看到一个让用户填写的表单或一个按行和列格式化的报告时,这通常是结构化数据可能可以用于分析的信号。如果你只能访问非结构化数据,别灰心——正如你在第十四章学到的,你甚至可以挖掘非结构化数据,如文本文件,进行分析。
如果你想分析的数据是多年来收集的,我建议你尽量分析五年、十年甚至更长时间的数据,而不仅仅是分析一两年的数据。分析一个月或一年的数据快照可能会得到有趣的结果,但许多趋势是在更长的时间段内展现的,如果你只看一年数据,可能看不到这些趋势。我将在“识别关键指标和趋势”一节中进一步讨论这个问题。
没有数据?建立自己的数据库
有时,没有人能提供你需要的格式的数据。如果你有时间、耐心和方法论,你可能能够建立自己的数据集。这就是我和我的今日美国同事罗伯特·戴维斯所做的,当时我们想研究与美国大学校园中学生死亡相关的问题。没有任何一个组织——无论是学校、州政府还是联邦政府——能够告诉我们每年有多少大学生在校园内因事故、药物过量或疾病死亡。我们决定收集自己的数据,并将信息结构化为数据库中的表格。
我们从研究与学生死亡相关的新闻文章、警察报告和诉讼开始。找到 2000 年至 2005 年间 600 多起学生死亡的报告后,我们进一步采访了教育专家、警察、学校官员和家长。从每个报告中,我们记录了每个学生的年龄、学校、死亡原因、年级以及是否与毒品或酒精有关。我们的发现最终促成了 2006 年在今日美国刊登的文章《在大学,第一年是最危险的》。该报道展示了我们 SQL 数据库分析的关键发现:大一新生尤为脆弱,占据了我们研究的学生死亡事件中最高的比例。
如果你缺乏所需的数据,你也可以创建一个数据库。关键是要识别出重要的信息,并系统地收集它们。
评估数据的来源
在你确定了一个数据集之后,尽可能多地了解其来源和维护方法。政府和机构以各种方式收集数据,而有些方法产生的数据比其他方法更具可信度和标准化。
例如,你已经看到,美国农业部(USDA)食品生产商的数据中同一公司名称有多种拼写方式。了解原因是值得的。(可能数据是手动从书面表格转录到计算机的。)类似地,你在第十二章分析的纽约市出租车数据记录了每次行程的开始和结束时间。这引发了一个问题,计时器什么时候开始和停止——是乘客上下车时,还是有其他触发条件?你应该了解这些细节,不仅为了从分析中得出更好的结论,还要将这些信息传递给可能解读你分析的其他人。
数据集的来源也可能影响你如何分析数据和报告你的发现。例如,对于美国人口普查局的数据,重要的是要知道每十年进行一次的普查是对人口的完整统计,而美国社区调查(ACS)仅基于部分家庭样本。因此,ACS 的数据存在误差范围,而十年一次的普查则没有。若不考虑误差范围的影响,报告 ACS 数据是不负责任的,因为误差范围可能使得数字之间的差异变得微不足道。
用查询采访数据
一旦你获得了数据,了解它的来源,并将其加载到数据库中,你就可以通过查询来探索数据。在本书中,我称这个步骤为采访数据,这是你应该做的事情,以了解数据的内容,并查看其中是否存在任何警示信号。
一个好的起点是聚合数据。计数、求和、按列值排序和分组应该能揭示最小值和最大值、可能存在的重复条目问题,以及数据的整体范围。如果你的数据库包含多个相关表格,尝试使用连接查询以确保你理解这些表格之间的关系。使用LEFT JOIN和RIGHT JOIN,正如你在第七章学到的那样,可以查看一个表中的关键值是否在另一个表中缺失。这可能会引起或不引起关注,但至少你能够识别潜在的问题并着手解决。列出你所有的问题或关注点,然后继续下一步。
咨询数据的所有者
在探索了你的数据库并形成了关于观察到的数据质量和趋势的初步结论之后,花时间向了解数据的人提出问题或关注点。这个人可能是为你提供数据的政府机构或公司的工作人员,或者是之前曾处理过这些数据的分析师。这一步是你澄清对数据的理解、验证初步发现并发现数据是否存在任何问题,进而判断其是否适合你的需求的机会。
例如,如果你在查询一个表格时,发现某些列中的值似乎是明显的异常值(比如,本应发生在过去的事件却出现了未来的日期),你应该询问这个不一致的情况。如果你期望在表格中找到某个人的名字(也许是你自己的名字),但却没有找到,这也应该引发另一个问题。是不是可能你没有获得完整的数据集,或者数据收集存在问题?
目标是获得专家的帮助,以完成以下工作:
-
了解数据的限制。确保你知道数据包含了什么,排除了什么,以及可能影响你分析方式的内容提示。
-
确保你拥有完整的数据集。核实你是否拥有应该看到的所有记录,并且如果有数据缺失,你了解其原因。
-
确定数据集是否符合你的需求。如果数据源承认数据质量存在问题,考虑寻找其他地方的数据以获得更可靠的来源。
每个数据集和情况都是独特的,但咨询其他用户或数据所有者可以帮助你避免不必要的失误。
确定关键指标和随时间变化的趋势
当你确认自己理解数据,并且对数据的可信度、完整性以及其对分析的适用性充满信心时,下一步就是运行查询,识别关键指标,并在可能的情况下,观察随时间变化的趋势。
你的目标是挖掘出可以用一句话总结的数据,或者可以作为演示文稿中的幻灯片展示的内容。一个发现的例子可能是这样的:“经过五年的下降后,Widget 大学的注册人数在连续两个学期内增长了 5%。”
要识别这种趋势,你将遵循一个两步过程:
-
选择一个指标进行跟踪。在普查数据中,它可能是超过 60 岁的人口比例;或者在纽约市出租车数据中,它可能是一年中每周工作日的平均出行次数。
-
跟踪这个指标多个年份,看看它是否有变化。
实际上,这些是我们在第七章中用来将百分比变化计算应用于多个年份的普查数据的步骤。在那种情况下,我们查看了 2010 年到 2019 年间各县人口的变化。人口估算是关键指标,而百分比变化展示了每个县在九年期间的趋势。
关于衡量时间变化的一个警告:即使你在任何两个年份之间看到剧烈的变化,仍然值得尽可能深入挖掘更多年份的数据,以便在长期趋势的背景下理解短期变化。任何年度变化可能看起来很剧烈,但将其放在多年活动的背景下可以帮助你评估其真正的重要性。
例如,美国国家健康统计中心每年发布出生人数数据。作为一个数据迷,这是我喜欢关注的指标之一,因为出生人数往往反映了文化或经济的广泛趋势。图 20-1 展示了 1910 年至 2020 年间每年的出生人数。

图 20-1:1910 年到 2020 年的美国出生人数。来源:美国国家健康统计中心
只看这个图表的最后五年(灰色部分),我们看到出生人数稳步下降,从 2016 年的 395 万人降至 2020 年的 361 万人。近期的下降确实值得注意(反映了出生率持续下降和人口老龄化)。但从长期背景来看,我们可以看到国家在过去 100 年里经历了几次婴儿潮和婴儿潮破裂。你可以在图 20-1 中看到的一个例子是第二次世界大战后,1940 年代中期出生人数的急剧上升,标志着婴儿潮一代的开始。
通过识别关键指标并观察短期和长期的变化,你可能会发现一个或多个值得呈现给他人或采取行动的发现。
问问为什么
数据分析可以告诉你发生了什么,但它并不总是能说明为什么会发生某事。要了解为什么,值得与相关领域的专家或数据所有者一起重新审视数据。在美国出生数据中,我们可以轻松计算出每年的百分比变化。但这些数据并没有告诉我们,为什么从 1980 年代初到 1990 年,出生人数持续增加。要了解这一信息,你可以咨询一位人口学家,他很可能会解释,在这些年里,出生人数的增加恰好与更多的婴儿潮一代进入生育年龄相吻合。
在与你的专家分享发现和方法时,要求他们注意任何看起来不太可能或值得进一步探讨的内容。对于他们能够证实的发现,要求他们帮助你理解背后的原因。如果他们愿意被引用,你可以使用他们的评论来补充你的报告或演示文稿。以这种方式引用专家对趋势的见解是记者常用的标准方法。
传达你的发现
你如何分享分析结果取决于你的角色。学生可能会通过论文或学位论文来展示他们的结果。在企业环境中工作的人可能会使用 PowerPoint、Keynote 或 Google Slides 来呈现他们的发现。记者可能会撰写故事或制作数据可视化。不管最终产品是什么,以下是展示信息的建议,以一个虚构的住宅销售分析为例:
-
根据你的发现确定一个总体主题。将主题作为你的演讲、论文或可视化的标题。例如,你可能会将有关房地产的演示命名为“郊区住宅销售上升,城市下降”。
-
展示总体数字以显示大体趋势。突出你的分析中的关键发现。例如,“所有郊区社区在过去两年中每年销售增长了 5%,扭转了三年的下降趋势。与此同时,城市社区的销售下降了 2%。”
-
强调支持这一趋势的具体例子。描述一两个相关的案例。例如,“在史密斯镇,住宅销售在 XYZ 公司总部搬迁后增长了 15%。”
-
承认与整体趋势相反的例子。这里也可以使用一两个相关的案例。例如,“两个城市的社区确实出现了住宅销售增长:Arvis(增长 4.5%)和 Zuma(增长 3%)。”
-
坚持事实。永远不要歪曲或夸大任何发现。
-
提供专家见解。使用引用或引文。
-
使用条形图、折线图或地图可视化数字。 表格有助于为观众提供具体数字,但通过可视化更容易理解趋势。
-
引用数据的来源及其分析包含或省略的内容。 提供涵盖的日期、提供者的名称以及任何影响分析的区分,例如:“基于沃尔顿县 2022 年和 2023 年的税务申报数据。排除商业地产。”
-
分享你的数据。将数据发布到网上供下载,并附上你分析过程中所采取步骤的描述。没有什么比与他人分享数据更能体现透明度了,这样他们可以进行自己的分析并验证你的发现。
通常,一个简短的报告能够清晰简洁地传达你的发现,并邀请听众进行对话,效果最佳。当然,你可以遵循自己偏好的数据工作和展示结论的方式。但多年来,这些步骤帮助我避免了数据错误和错误假设。
总结
最后,你已经完成了我们对 SQL 的实用探索!感谢你阅读本书,欢迎通过电子邮件向我提出建议和反馈:practicalsqlbook@gmail.com。本书末尾有一个附录,列出了你可能想尝试的其他 PostgreSQL 相关工具。
我希望你已经掌握了数据分析技能,能够立即应用到你日常遇到的数据中。更重要的是,我希望你已经看到每个数据集都有一个故事,或者多个故事可以讲述。识别并讲述这些故事是与数据工作值得的原因;它不仅仅是翻看一堆行和列。我期待听到你所发现的内容!
附录
其他 PostgreSQL 资源

本附录包含了帮助你了解 PostgreSQL 发展、寻找其他软件并获得帮助的资源。由于软件资源可能会发生变化,我将在包含本书所有资源的 GitHub 仓库中维护一份本附录的副本。你可以通过 nostarch.com/practical-sql-2nd-edition/ 找到指向 GitHub 的链接。
PostgreSQL 开发环境
在本书中,我们使用了图形用户界面 pgAdmin 来连接 PostgreSQL、执行查询并查看数据库对象。尽管 pgAdmin 是免费的、开源的且非常流行,但它并不是你使用 PostgreSQL 的唯一选择。维基条目“PostgreSQL 客户端”在 wiki.postgresql.org/wiki/PostgreSQL_Clients 中列出了许多替代方案。
以下列表展示了我尝试过的几个工具,包括免费和付费选项。免费的工具适合一般的分析工作。如果你深入数据库开发,可能需要升级到付费选项,后者通常提供高级功能和支持。
-
Beekeeper Studio 是一个免费的开源 GUI,支持 PostgreSQL、MySQL、Microsoft SQL Server、SQLite 和其他平台。Beekeeper 可在 Windows、macOS 和 Linux 上运行,并且在数据库 GUI 中拥有更精致的应用设计之一(见
www.beekeeperstudio.io/)。 -
DBeaver 被描述为一个“通用数据库工具”,支持 PostgreSQL、MySQL 以及其他许多数据库。DBeaver 包含一个可视化查询构建器、代码自动补全和其他高级功能。它有 Windows、macOS 和 Linux 的付费版和免费版(见
dbeaver.com/)。 -
DataGrip 是一个 SQL 开发环境,提供代码补全、错误检测以及简化代码的建议等许多功能。它是付费产品,但公司 JetBrains 为学生、教育工作者和非营利组织提供折扣和免费版本(见
www.jetbrains.com/datagrip/)。 -
Navicat 是一个功能丰富的 SQL 开发环境,支持 PostgreSQL 以及 MySQL、Oracle、MongoDB 和 Microsoft SQL Server 等其他数据库的版本。Navicat 没有免费版本,但公司提供 14 天的免费试用(见
www.navicat.com/)。 -
Postbird 是一个简单的跨平台 PostgreSQL GUI,用于编写查询和查看对象。免费且开源(见
github.com/Paxa/postbird/)。 -
Postico 是一个仅限 macOS 的客户端,由 Postgres.app 的开发者制作,灵感来源于 Apple 设计。完整版是付费的,但可以使用功能受限的版本,且没有时间限制(见
eggerapps.at/postico/)。
一个试用版本可以帮助你决定该产品是否适合你。
PostgreSQL 实用工具、工具和扩展
你可以通过许多第三方工具、实用程序和扩展来扩展 PostgreSQL 的功能。这些工具包括额外的备份和导入/导出选项、改进的命令行格式以及强大的统计包。你可以在线找到一个精选列表,网址为 github.com/dhamaniasad/awesome-postgres/,但这里有几个值得关注的:
-
Devart Excel 插件 for PostgreSQL 一个 Excel 插件,允许你直接在 Excel 工作簿中加载和编辑 PostgreSQL 数据(详见
www.devart.com/excel-addins/postgresql.html)。 -
MADlib 一个为大数据集设计的机器学习和分析库,集成了 PostgreSQL(详见
madlib.apache.org/)。 -
pgAgent 一个作业管理器,让你能够在预定时间运行查询及执行其他任务(详见
www.pgadmin.org/docs/pgadmin4/latest/pgagent.html)。 -
pgBackRest 一个先进的数据库备份与恢复管理工具(详见
pgbackrest.org/)。 -
pgcli一个替代psql的命令行界面,包含自动补全和语法高亮功能(详见github.com/dbcli/pgcli/)。 -
pgRouting 使支持 PostGIS 的 PostgreSQL 数据库能够执行网络分析任务,例如沿道路计算行车距离(详见
pgrouting.org/)。 -
PL/R 一个可加载的过程性语言,提供在 PostgreSQL 函数和触发器中使用 R 统计编程语言的能力(详见
www.joeconway.com/plr.html)。 -
pspg将psql的输出格式化为可排序、可滚动的表格,支持多种颜色主题(详见github.com/okbob/pspg/)。
PostgreSQL 新闻与社区
现在你已经是一个真正的 PostgreSQL 用户,保持对社区新闻的关注是明智的。PostgreSQL 开发团队定期更新软件,更新可能会影响你写的代码或使用的工具。你甚至可能会发现新的分析机会。
以下是一些在线资源,帮助你保持信息更新:
-
Crunchy Data 博客 来自 Crunchy Data 团队的帖子,Crunchy Data 提供企业级 PostgreSQL 支持和解决方案(详见
blog.crunchydata.com/blog/)。 -
EDB 博客 来自 EDB 团队的帖子,EDB 是一家提供 PostgreSQL 服务的公司,提供本书中提到的 Windows 安装程序,并主导 pgAdmin 的开发(详见
www.enterprisedb.com/blog/)。 -
Planet PostgreSQL 汇集了数据库社区的博客文章和公告(请参见
planet.postgresql.org/)。 -
Postgres Weekly 一份电子邮件通讯,汇总了公告、博客文章和产品发布(请参见
postgresweekly.com/)。 -
PostgreSQL 邮件列表 这些列表对于向社区专家提问非常有用。pgsql-novice 和 pgsql-general 列表特别适合初学者,尽管需要注意邮件量可能较大(请参见
www.postgresql.org/list/)。 -
PostgreSQL 新闻档案 来自 PostgreSQL 团队的官方新闻(请参见
www.postgresql.org/about/newsarchive/)。 -
PostgreSQL 非营利组织 与 PostgreSQL 相关的慈善组织包括美国 PostgreSQL 协会和 PostgreSQL 欧洲。两者都提供有关该产品的教育、活动和倡导(请参见
postgresql.us/和www.postgresql.eu/)。 -
PostgreSQL 用户组 一个列出提供聚会和其他活动的社区小组的列表(请参见
www.postgresql.org/community/user-groups/)。 -
PostGIS 博客 关于 PostGIS 扩展的公告和更新(请参见
postgis.net/blog/)。
此外,我建议你关注你使用的任何与 PostgreSQL 相关软件的开发者注释,例如 pgAdmin。
文档
在本书中,我经常引用 PostgreSQL 官方文档中的页面。你可以在主页www.postgresql.org/docs/找到每个版本的文档以及常见问题解答和维基。随着你对某个主题(例如索引)了解得越来越多,阅读手册的相关部分是很有价值的,或者你可以查找函数的所有选项。特别是,“前言”、“教程”和“SQL 语言”部分涵盖了本书章节中介绍的许多内容。
其他有价值的文档资源包括 Postgres Guide(请参见postgresguide.com/)和 Stack Overflow,在那里你可以找到由开发者发布的相关问题和答案(请参见stackoverflow.com/questions/tagged/postgresql/)。你还可以查看 PostGIS 的问答网站(请参见gis.stackexchange.com/questions/tagged/postgis/)。







浙公网安备 33010602011771号