Книга: «Нечеткое сопоставление данных в SQL»
Привет, Хаброжители!
Если бы вам предоставили два разных, но связанных между собой набора данных, какие инструменты вы бы использовали для поиска совпадений? А если все, что у вас есть, — это доступ к базе данных через SQL SELECT? Джим Лемер предлагает лучшие практики, методики и приемы, которые помогут вам импортировать, очищать, сопоставлять, оценивать и осмысливать разнообразные данные с помощью SQL.
Администраторы баз данных, программисты, бизнес-аналитики и специалисты по исследованию данных узнают, как выявлять и удалять дубликаты, разбирать строки, извлекать данные из XML и JSON, генерировать SQL с помощью SQL, упорядочивать данные и готовить наборы данных, а также применять подходы к качеству данных и ETL для поиска сходств и различий между различными выражениями одних и тех же данных.
Примеры, приведенные в книге, полны реальных приемов и содержат рабочий код.
Если вы постоянно работаете с SQL и знаете такие функции, как translate и datediff, то можете сразу переходить к главе 3, где начинается основное содержание книги. Обзор стоит изучить, если вложение SQL-функций, представленное ниже (мы разберем его позже), вам неизвестно или непонятно:
DECLARE @FullName VARCHAR(100)
SET @FullName = 'Mortimer Snedley'
SELECT
@FullName [Full Name],
-- LEFT для имени
LEFT(@FullName,
CHARINDEX(' ', @FullName) — 1) [First Name],
-- Подстрока для извлечения фамилии
SUBSTRING(@FullName,
CHARINDEX(' ', @FullName) + 1,
PATINDEX('%, %', @FullName) -.
CHARINDEX(' ', @FullName) — 1) [Last Name],
-- RIGHT извлекает суффикс
RIGHT(@FullName,
LEN(@FullName) — 1 — PATINDEX('%, %', @FullName)) [Suffix];
Если код выше кажется вам непонятным (или совсем дебрями, а я гарантирую, что это не так), советую вам прочитать главу 1, чтобы познакомиться с оператором select, и главу 2, чтобы познакомиться со всеми функциями SQL, используемыми в этой книге.
Нормализация
Предполагает устранение общих проблем с представлением и качеством данных, чтобы облегчить успешное сопоставление. (Подробнее о значении термина нормализованный и других терминов, часто встречающихся в этой книге, см. в глоссарии.)
Оценка
Определение степени совпадения двух наборов данных.
Представление
Анализ и отчет о результатах.
Чтобы охватить эти этапы, в книге рассматриваются следующие темы.
Часть I. Обзор
Первая часть представляет собой краткий обзор некоторых элементов SQL, часто используемых в книге. Вы можете пропустить ее, если хорошо знаете SQL:
- Глава 1 «Обзор оператора SELECT» содержит краткий обзор запросов с оператором select и объединений. Предполагается, что вы уже знакомы с SQL — эта глава не учебник, а просто сборник советов, которые мне кажутся полезными.
- Глава 2 «Сочетание функций» содержит описание функций SQL, которые чаще всего используются в книге.
Вторая часть поможет позже провести сопоставление, поскольку расскажет, как «нормализовать» данные и избавиться от распространенных проблем:
- Что сказать о главе 3 «Имена, имена, имена»? Имена — это сложно (https://oreil.ly/jPYKn). Имена людей. Названия компаний. Ваше имя. Мое имя (большинству людей трудно произнести «Lehmer» правильно, потому что они не говорят по-немецки).
- В главе 4 «Адреса, адреса, адреса» объясняется, что адреса тоже не так просты (https://oreil.ly/ZgNDc).
- Глава 5 «Даты, даты, даты» посвящена датам рождения и других не относящихся к делу событий. Вы когда-нибудь встречали дату рождения в будущем? Я — да. Разумеется, на продакшен! Для человека, вводящего дату, «время не имеет значения». И это не считая того, что даты и время тоже сложны (https://oreil.ly/Tgmsi). Как и часовые пояса (https://oreil.ly/ZmIe_).
- В главе 6 «Электронная почта» говорится о том, что поскольку номера налоговых идентификаторов/социального страхования являются приватными (и это правильно), неплохим уникальным идентификатором может стать адрес электронной почты. Почти всегда. Но его все равно приходится проверять на достоверность. Вы удивитесь, как мало нужно, чтобы составить «действительный» адрес электронной почты.
- В главе 7 «Телефонные номера» рассказывается, что делать с данными типа »555–555–1234 Aunt Judy«s #» в телефонных номерах. В вашей рабочей базе данных они есть? Не волнуйтесь. Мы поговорим об этом.
- В главе 8 «Недопустимые символы» речь идет о недопустимых символах, и это не о клиентской базе. Типы данных. Наборы символов. Кодировка символов. Неразрывные пробелы. Скрытые символы помимо пробелов и табуляции, о которых функция TRIM может не знать.
- В главе 9 «Ортогональные данные» обсуждаются, соответственно, «ортогональные данные». Это образное выражение описывает ситуацию, когда кто-то пытается запихнуть 10 фунтов (или килограммов, как угодно) в мешок, рассчитанный на 5 фунтов (или килограммов). Мы поговорим о таких данных и о том, как их обрабатывать, парсить и, возможно, даже находить в них смысл.
В третьей части рассматриваются основные концепции книги, оценивается степень соответствия и анализируются результаты:
- В главе 10 «Большой куш» рассказывается о том, как соотнести данные и понять, совпадают ли они. Эта глава представляет собой ядро книги и показывает, как сравнить очищенные, нормализованные данные и определить, насколько они совпадают.
- Глава 11 «Качество данных, или GIGO» посвящена качеству данных. Качество данных в рабочей системе. Качество данных, с которыми проводится сопоставление. Что сделать, чтобы избежать попадания неидеальных (то есть «реальных») данных. И наконец, как обойти проблемы (не в последнюю очередь отсюда и слово нечеткое в названии).
- В главе 12 «Все целиком» приводится пример, в котором используются все техники, представленные в книге, чтобы вы увидели, как они работают совместно.
- Глава 13 «Код — это тоже данные!» заключительная. Если данные о людях не ваша тема, то что насчет SQL (кода), генерирующего SQL (код) для поиска определенных имен объектов во всех типах исходного кода и прочих текстовых артефактах? Мы поговорим о том, что это означает и почему это «реальный» пример того, как автоматизировать импакт-анализ и сэкономить сотни человеко-часов работы.
В приложении рассматривается «модель» данных, используемая в книге.
Разумеется, в этой книге нечеткое сравнение данных рассматривается довольно ограниченно — главным образом в отношении данных о людях. Однако эти методы могут применяться к самым разным наборам данных. Парсинг строки — это парсинг строки, и вне зависимости от «смысла» строки он проводится одинаково. Еще одно универсальное свойство данных, рассматриваемое в книге, — ввел ли их человек либо были ли они импортированы из другой системы. Если да, то скорее всего, у таких данных есть проблемы с качеством, которые необходимо решить еще до начала сопоставления, и об этом мы тоже поговорим.
Все целиком
Пришло время объединить полученные знания. В этой главе мы возьмем наборы данных, с которыми уже работали, и примеры из каждой главы и посмотрим, что мы можем выяснить, чтобы ответить на следующие вопросы:
- Сколько существующих клиентов содержится в импортируемых данных?
- И сколько потенциальных клиентов содержится в импортируемых данных?
Чтобы ответить на эти вопросы, нам также придется провести большую работу по очистке и нормализации данных. И конечно же, вы снова встретитесь со Снедли.
Подход
Код ниже может напоминать функциональное программирование. Знакомо?
SELECT
CustomField3,
CASE
/*
Если NULL — не беспокойтесь.
*/
WHEN CustomField3 IS NULL THEN 'Адрес электронной почты не найден'
/*
Если в строке отсутствует знак @, адрес электронной почты не найден.
*/
WHEN CHARINDEX('@', CustomField3) = 0 THEN 'Адрес электронной почты не
найден'
/*
Если в строке есть знак @ и нет запятой, то строка содержит только
адрес электронной почты.
*/
WHEN CHARINDEX('@', CustomField3) > 0
AND CHARINDEX(',', CustomField3) = 0
THEN CustomField3
/*
Если адрес электронной почты находится слева, в начале строки,
захватите его.
*/
WHEN CHARINDEX('@', CustomField3) > 0
AND CHARINDEX(',', CustomField3) > CHARINDEX('@', CustomField3)
THEN LEFT(CustomField3, CHARINDEX(',', CustomField3) — 1)
/*
Если адрес электронной почты находится посередине строки,
продолжайте!
*/
WHEN CHARINDEX(',', CustomField3) > 0
AND CHARINDEX('@', RIGHT(CustomField3, LEN(CustomField3) -.
CHARINDEX(',', CustomField3)) >
CHARINDEX(',', CustomField3)
AND CHARINDEX('@', RIGHT(CustomField3, LEN(CustomField3) -.
CHARINDEX(',', CustomField3)) <
CHARINDEX(',', RIGHT(CustomField3, LEN(CustomField3) -.
CHARINDEX(',', CustomField3))
THEN SUBSTRING(CustomField3,
CHARINDEX(',', CustomField3) + 1,
CHARINDEX(',', RIGHT(CustomField3,
LEN(CustomField3) -
CHARINDEX(',', CustomField3))) — 1)
/*
Если адрес электронной почты находится справа, в конце строки,
захватите его.
*/
WHEN CHARINDEX(',', CustomField3) > 0
AND CHARINDEX('@', CustomField3) > CHARINDEX(',', CustomField3)
THEN RIGHT(CustomField3, LEN (CustomField3) — CHARINDEX(',', CustomField3))
ELSE 'Адрес электронной почты не найден'
END [Электронная почта?]
FROM staging.CustomFields;
Считайте, что select — это анонимная (лямбда) функция, которая не принимает никаких параметров и возвращает преобразованный набор данных из известного (вызывающей стороне и функции) набора данных. Конечно, при наличии DDL-доступа к базе данных (с помощью create) можно создавать реальные SQL-функции и хранимые процедуры, которые затем можно вызывать по имени, передавать в них параметры и т. д.
Однако мы используем несколько простых операций ETL, чтобы придать данным нужную нам форму, и тогда сможем ответить на наши вопросы. Если вы придерживаетесь функционального подхода, то, возможно, согласитесь со мной, поскольку я давно считаю, что этот подход аналогичен концепции «map-reduce»:
SELECT transformed-columns /* Здесь можно проводить всевозможные преобразования. */
INTO target-table /* Целевая таблица ETL */
FROM source-table /* Исходная таблица ETL */
WHERE filter-conditions /* Различные критерии отбора. */
ORDER BY new-order /* Отличная группа! Здесь можно повеселиться! */
Он похож на преобразование наборов данных в новые, обобщенные наборы, с применением фильтрации и сортировки. Кроме того, при его использовании движок реляционной БД выполняет множество операций параллельной обработки и конвейеризации за вас. Помогает ли это? Посмотрим на пример из предыдущей главы:
/*
Импортируйте данные в собственную рабочую область. Без условия WHERE
из-за размера может оказаться непрактичным проводить фильтрацию даже
на этом шаге. В данном случае имитируем ее с помощью TOP 10.
*/
SELECT TOP 10 *
INTO staging.Customers
FROM crm.NormalizedCustomer
ORDER BY LastName;
Это максимально просто. Обратите внимание, что, как и в функциональном программировании, мы не изменяем исходные данные; мы преобразуем их во что-то другое, а на следующем шаге работаем с результатом преобразований, и т. д.
Какая оценка?
Для своих целей мы получили данные из отдела маркетинга и импортировали их с помощью SQL Server Management Studio (SSMS) в dbo .PotentialMatches. Схема выглядит так:
/*
Можно обсудить — обычно я не бываю так любезен.
*/
CREATE TABLE dbo.PotentialMatches
(
first_name NVARCHAR(50) NULL,
last_name NVARCHAR(50) NULL,
company_name NVARCHAR(50) NULL,
address NVARCHAR(50) NULL,
city NVARCHAR(50) NULL,
county NVARCHAR(50) NULL,
state NVARCHAR(50) NULL,
zip NVARCHAR(50) NULL,
country NVARCHAR(50) NULL,
phone1 NVARCHAR(50) NULL,
phone2 NVARCHAR(50) NULL,
email NVARCHAR(50) NULL,
web_address NVARCHAR(50) NULL
)
Это довольно типичная схема инструментального импорта. Просто возьмите все данные, запихните в тип varchar и убедитесь, что все они допускают null, потому что доверять источнику данных не стоит, даже если вам предоставили схему и поклялись, что обязательные столбцы всегда будут ненулевыми. Возможно, если это постоянный и проверенный поставщик данных, вы измените некоторые типы данных на более точный (но ни в коем случае не импортируйте ZIP-коды как int!) и пометите некоторые столбцы not null. Но чтобы начать доверять поставщику, нужно подождать некоторое время и сначала посмотреть, как обстоят дела с качеством его данных.
Проверим несколько столбцов и строк на значение Snedley:
SELECT TOP 10
first_name,
last_name,
company_name
FROM dbo.PotentialMatches
WHERE
last_name LIKE '%Snedley%'
OR company_name LIKE '%Snedley%'
ORDER BY last_name, first_name, company_name;
Пока никаких сюрпризов. Мы видим, что в некоторых строках нет ни имен, ни фамилий, только название компании, в некоторых — только имена и фамилии, без названия компании, а в некоторых — и то и другое.
Мы подошли к тому моменту, когда нужно решить, что именно мы будем оценивать. Глядя на столбцы входных данных, мы видим, что они совпадают с нашей CRM-системой. «Нечеткость» в том числе заключается в предположении, какие столбцы одной стороны сравнения соответствуют столбцам другой стороны по названию, но в нашем случае эта работа была проделана за нас, и все названия столбцов имеют соответствия в CRM. Итак, какие из них полезнее всего сравнивать? Некоторые, очевидно, сразу можно отбросить: например, у нескольких человек может быть один и тот же адрес сайта, если они работают в одной компании, и получится, что для большого массива данных (с обеих сторон) адреса сайта вообще не будет. Поэтому для дальнейшего сравнения он бесполезен (как всегда, с пометкой «если только нет особых требований бизнеса»).
И наоборот, работая с нашей внутренней системой CRM, мы знаем, что phone1 в ней — это номер мобильного телефона, а phone2 — номер домашнего или рабочего телефона (это отражено в представлении crm.NormalizedCustomer). Мы предполагаем, что то же самое верно и для импортируемых данных, но будем сравнивать оба номера с обоими. Телефоны, как и электронные адреса, хорошо подходят для снятия неопределенности, поэтому они автоматически попадают в список сравниваемых атрибутов.
Возможно, вы не заметили ее отсутствия, но дата рождения — это еще один «золотой стандарт» сопоставления данных. Если у вас есть (почти) полное имя и дата рождения, зачастую этого достаточно. Именно поэтому вас просят указать эти два атрибута данных, чтобы идентифицировать себя, когда вы попадаете в медицинское учреждение.Однако при сопоставлении демографических данных во входящем наборе редко бывает дата рождения. Если она есть, оценить степень соответствия может быть очень просто при условии, что вы провели обычную очистку данных (даты рождения в будущем и т. д.). Но эта книга о «нечетком» сопоставлении данных, когда дни рождения, налоговые идентификаторы и другие совершенно естественные и искусственные ключи оказываются недоступны, поскольку эта информация позволяет идентифицировать личность или содержит персональные данные и является конфиденциальной, или, если она все же есть в наборе данных, она замаскирована, вызывая дополнительную «нечеткость»!
Из 13 столбцов мы выбрали 4. На этом этапе нужно, пожалуй, провести небольшой анализ EDA: покопаться в разных столбцах файла импорта, посмотреть на распределение значений и понять, достаточно ли их вообще. Чтобы проверить наши предположения касательно веб-адресов:
SELECT
COUNT(*) [Всего строк],
COUNT(web_address) [Не-NULL-значения],
COUNT(DISTINCT web_address) [Уникальные домены]
FROM crm.Customer;
Учитывая следующий результат, кажется маловероятным, что использование этого столбца для сравнения будет плодотворным:
508 — 144 = 364 веб-адреса со значением NULL
364 / 508×100 = 71,7% значений NULL
Попробуем, однако, следующий вариант:
SELECT
COUNT(*) [Всего строк],
COUNT(phone1) [Не-NULL-значения],
COUNT(DISTINCT phone1) [Уникальные номера мобильных телефонов]
FROM crm.Customer;
Получилось:
508 — 505 = 3 номера телефона со значением NULL
505 / 508×100 = 99,4% значений не NULL
Следовательно, номер мобильного телефона, или что там у нас обозначает phonel, выглядит хорошим кандидатом на сравнение, тем более что подавляющее большинство телефонных номеров уникальны.
Переходим к остальным атрибутам. Как обычно, добавим в оценку фамилию, имя и название компании. Используем хотя бы часть названия улицы. Возможно, я выгляжу провокатором, но вы должны помнить из главы 10, что я считаю большинство элементов адреса, кроме почтового индекса, бесполезными для сравнения, поэтому мы будем использовать именно индекс. Из первоначальных 13 столбцов:
- first_name
- last_name
- company_name
- address
- city
- county
- state
- zip
- country
- phone1
- phone2
- web_address
мы оставим 8:
Очень хорошее начало. В процессе настройки можно будет добавить или удалить столбцы (быстрее обработка, меньше наборы данных), но обычно для сравнения важны 5–10 столбцов, которые могут увеличить степень соответствия:
/*
Совпадения только в столбцах, выбранных для оценки.
*/
SELECT
COUNT(*) [Всего совпадений]
FROM crm.Customer C
INNER JOIN dbo.PotentialMatches P ON
C.first_name = P.first_name
AND C.last_name = P.last_name
AND C.company_name = P.company_name
AND C.address = P.address
AND C.zip = P.zip
AND C.phone1 = P.phone1
AND C.phone2 = P.phone2
AND C.email = P.email;
Первый проход: наивное сопоставление
Начнем с наивного сопоставления имеющихся атрибутов:
Может быть, нам повезло. Может быть.
А может быть, мы забыли о значениях null и о том, как SQL их обрабатывает:
/*
Убрать NULL.
*/
SELECT
COUNT(*) [Всего совпадений]
FROM crm.Customer C
INNER JOIN dbo.PotentialMatches P ON
COALESCE(C.first_name, '') = COALESCE(P.first_name, '')
AND COALESCE(C.last_name, '') = COALESCE(P.last_name, '')
AND COALESCE(C.company_name, '') = COALESCE(P.company_name, '')
AND COALESCE(C.address, '') = COALESCE(P.address, '')
AND COALESCE(C.zip, '') = COALESCE(P.zip, '')
AND COALESCE(C.phone1, '') = COALESCE(P.phone1, '')
AND COALESCE(C.phone2, '') = COALESCE(P.phone2, '')
AND COALESCE(C.email, '') = COALESCE(P.email, '');
Опять нет. Надеемся, что, заменив все and на or, мы получим хоть что-то; в конце концов, это будет основой для оценки соответствия:
/*
Убрать NULL.
*/
SELECT
COUNT(*) [Всего совпадений]
FROM crm.Customer C
INNER JOIN dbo.PotentialMatches P ON
COALESCE(C.first_name, '') = COALESCE(P.first_name, '')
OR COALESCE(C.last_name, '') = COALESCE(P.last_name, '')
OR COALESCE(C.company_name, '') = COALESCE(P.company_name, '')
OR COALESCE(C.address, '') = COALESCE(P.address, '')
OR COALESCE(C.zip, '') = COALESCE(P.zip, '')
OR COALESCE(C.phone1, '') = COALESCE(P.phone1, '')
OR COALESCE(C.phone2, '') = COALESCE(P.phone2, '')
OR COALESCE(C.email, '') = COALESCE(P.email, '');
Хммм… учитывая, что в нашей CRM-системе всего 508 строк, а в импортируемых данных — 34, это не совсем то число, которое мы ожидали увидеть. Это соединение, несмотря на использование INNER JOIN, в итоге посчитало за отдельные «совпадения» все комбинации совпадающих пар полей. Это не полное декартово соединение (508×34 = 17 272), но близкое к нему.
Вернемся пока к использованию and и добавим немного магии нормализации:
/*
Попробуйте нормализовать.
*/
SELECT
COUNT(*) [Всего совпадений]
FROM crm.Customer C
INNER JOIN dbo.PotentialMatches P ON
LEFT(C.first_name, 1) = LEFT(P.first_name, 1)
AND TRANSLATE(C.last_name, '.,', ' ') =
TRANSLATE(P.last_name, '.,', ' ')
AND REPLACE(C.company_name, 'L.L.C.', 'LLC') =
REPLACE(P.company_name, 'L.L.C.', 'LLC')
AND LEFT(C.address, 10) = LEFT(P.address, 10)
AND LEFT(C.zip, 5) = LEFT(P.zip, 5)
AND REPLACE(C.phone1, '-', '') =
REPLACE(P.phone1, '-', '')
AND REPLACE(C.phone2, '-', '') =
REPLACE(P.phone2, '-', '')
AND C.email = P.email;
Это удивительно, если задуматься. Нам понадобились все эти маневры, чтобы найти хотя бы одну строку, достаточно близкую к точному совпадению, которое, очевидно, таковым не является, по крайней мере формально в отношении данных. Но с семантической точки зрения, вероятно, да, это совпадение. Перейдем от подсчета к атрибутам из таблицы Customer (псевдоним C) и посмотрим, что это за совпадение:
/*
Что же это за таинственное совпадение?
*/
SELECT
'CRM Data' Source,
C.first_name,
C.last_name,
C.company_name,
C.address,
C.zip,
C.phone1,
C.phone2,
C.email
FROM crm.Customer C
INNER JOIN dbo.PotentialMatches P ON
LEFT(C.first_name, 1) = LEFT(P.first_name, 1)
AND TRANSLATE(C.last_name, '.,', ' ') =
TRANSLATE(P.last_name, '.,', ' ')
AND REPLACE(C.company_name, 'L.L.C.', 'LLC') =
REPLACE(P.company_name, 'L.L.C.', 'LLC')
AND LEFT(C.address, 10) = LEFT(P.address, 10)
AND LEFT(C.zip, 5) = LEFT(P.zip, 5)
AND REPLACE(C.phone1, '-', '') =
REPLACE(P.phone1, '-', '')
AND REPLACE(C.phone2, '-', '') =
REPLACE(P.phone2, '-', '')
AND C.email = P.email
UNION
/*
А как оно выглядит в данных импорта?
*/
SELECT
'Import Data',
P.first_name,
P.last_name,
P.company_name,
P.address,
P.zip,
P.phone1,
P.phone2,
P.email
FROM crm.Customer C
INNER JOIN dbo.PotentialMatches P ON
LEFT(C.first_name, 1) = LEFT(P.first_name, 1)
AND TRANSLATE(C.last_name, '.,', ' ') =
TRANSLATE(P.last_name, '.,', ' ')
AND REPLACE(C.company_name, 'L.L.C.', 'LLC') =
REPLACE(P.company_name, 'L.L.C.', 'LLC')
AND LEFT(C.address, 10) = LEFT(P.address, 10)
AND LEFT(C.zip, 5) = LEFT(P.zip, 5)
AND REPLACE(C.phone1, '-', '') =
REPLACE(P.phone1, '-', '')
AND REPLACE(C.phone2, '-', '') =
REPLACE(P.phone2, '-', '')
AND C.email = P.email
ORDER BY 1;
Видим небольшие различия между данными CRM и импорта:
/*
Выполним diff.
*/
SELECT
C.address [Customer Address],
P.address [Import Address],
C.zip [Customer ZIP],
P.zip [Import ZIP]
FROM crm.Customer C
INNER JOIN dbo.PotentialMatches P ON
LEFT(C.first_name, 1) = LEFT(P.first_name, 1)
AND TRANSLATE(C.last_name, '.,', ' ') =
TRANSLATE(P.last_name, '.,', ' ')
AND REPLACE(C.company_name, 'L.L.C.', 'LLC') =
REPLACE(P.company_name, 'L.L.C.', 'LLC')
AND LEFT(C.address, 10) = LEFT(P.address, 10)
AND LEFT(C.zip, 5) = LEFT(P.zip, 5)
AND REPLACE(C.phone1, '-', '') =
REPLACE(P.phone1, '-', '')
AND REPLACE(C.phone2, '-', '') =
REPLACE(P.phone2, '-', '')
AND C.email = P.email;
Отлично, но мы далеки от завершения.
Его богатый опыт работы с SQL начался в конце 1980-х годов с DB2 v1.2 на MVS (теперь z/OS) и продолжился на Sybase и Microsoft SQL Server в начале и середине 1990-х годов. Джим работал над продуктами для подключения к базам данных и «промежуточным ПО» для различных вендоров программного обеспечения, прежде чем стать архитектором и консультантом. Он также является автором книги Ten Steps to Linux Survival: Bash for Windows People, которая была опубликована в сокращенном цифровом виде издательством O«Reilly (https://oreil.ly/wNpov).
Сейчас Джим живет в горах на севере Нью-Мексико со своей женой Лесли. Время, свободное от консультирования или написания книг, он обычно проводит где-нибудь в Скалистых горах или в Колорадо — ходит в короткие или многодневные походы, занимается скалолазанием, ходит на снегоступах и занимается другими вещами, которые гораздо интереснее, чем работа с данными!
Более подробно с книгой можно ознакомиться на сайте издательства:
» Оглавление
» Отрывок
По факту оплаты бумажной версии книги на e-mail высылается электронная книга.
Для Хаброжителей скидка 25% по купону — SQL