#![allow(clippy::doc_markdown, reason = "documentation quotes upstream property and file names throughout")]
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use bzip2::read::MultiBzDecoder;
use clap::Args as ClapArgs;
use serde::Deserialize;
use sqlx::PgPool;
const BATCH: usize = 8192;
const P_MBID: &str = "P434";
const P_INFLUENCED_BY: &str = "P737";
const P_FORMATION_PLACE: &str = "P740";
const P_BIRTH_PLACE: &str = "P19";
const P_INCEPTION: &str = "P571";
const P_GENRE: &str = "P136";
const P_LABEL: &str = "P264";
const P_COUNTRY: &str = "P495";
const DEFAULT_URL: &str = "https://dumps.wikimedia.org/wikidatawiki/entities/latest-all.json.bz2";
#[derive(ClapArgs)]
pub struct Args {
#[arg(long, value_name = "FILE", conflicts_with = "url")]
pub dump: Option<PathBuf>,
#[arg(long, value_name = "URL")]
pub url: Option<String>,
#[arg(long = "dump-version", value_name = "VERSION")]
pub version: Option<String>,
#[arg(long, value_name = "N")]
pub limit: Option<u64>,
}
#[derive(Deserialize)]
struct Entity {
id: String,
#[serde(default)]
labels: HashMap<String, LabelValue>,
#[serde(default)]
claims: HashMap<String, Vec<Statement>>,
#[serde(default)]
sitelinks: HashMap<String, Sitelink>,
}
#[derive(Deserialize)]
struct LabelValue {
value: String,
}
#[derive(Deserialize)]
struct Sitelink {
title: String,
}
#[derive(Deserialize)]
struct Statement {
mainsnak: Snak,
}
#[derive(Deserialize)]
struct Snak {
#[serde(default)]
datavalue: Option<DataValue>,
}
#[derive(Deserialize)]
struct DataValue {
value: serde_json::Value,
}
impl DataValue {
fn entity_qid(&self) -> Option<i32> {
self.value.get("id").and_then(serde_json::Value::as_str).and_then(qid_number)
}
fn string(&self) -> Option<&str> {
self.value.as_str()
}
fn year(&self) -> Option<i16> {
let time = self.value.get("time")?.as_str()?;
let (sign, rest) = time.split_at(1);
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
let year: i32 = digits.parse().ok()?;
let signed = if sign == "-" { -year } else { year };
i16::try_from(signed).ok()
}
}
fn qid_number(id: &str) -> Option<i32> {
id.strip_prefix('Q')?.parse().ok()
}
#[derive(Default)]
struct Harvest {
by_mbid: HashMap<String, Facts>,
labels: HashMap<i32, String>,
entities: u64,
with_mbid: u64,
}
#[derive(Default)]
struct Facts {
qid: i32,
enwiki_title: Option<String>,
origin_qid: Option<i32>,
origin_is_birth: bool,
inception_year: Option<i16>,
country_qid: Option<i32>,
genres: Vec<i32>,
labels: Vec<i32>,
influenced_by: Vec<i32>,
}
pub async fn run(pool: &PgPool, args: &Args) -> Result<()> {
let canon = load_canon(pool).await?;
if canon.is_empty() {
bail!("no artists in the canon: run `lyrid import musicbrainz` first");
}
tracing::info!(artists = canon.len(), "resolving Wikidata against the canon");
let harvest = read_dump(args, &canon)?;
let version = args.version.clone().unwrap_or_else(|| "latest".to_string());
tracing::info!(
entities = harvest.entities,
with_mbid = harvest.with_mbid,
matched = harvest.by_mbid.len(),
"dump read; writing to PostgreSQL"
);
write(pool, &canon, &harvest, &version).await
}
async fn load_canon(pool: &PgPool) -> Result<HashMap<String, i32>> {
let rows: Vec<(uuid::Uuid, i32)> = sqlx::query_as("SELECT mbid, id FROM artist")
.fetch_all(pool)
.await
.context("failed to read the canon")?;
Ok(rows.into_iter().map(|(mbid, id)| (mbid.to_string(), id)).collect())
}
fn open_dump(args: &Args) -> Result<Box<dyn BufRead>> {
if let Some(path) = &args.dump {
let file = File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
let size = file.metadata().map_or(0, |m| m.len());
tracing::info!(dump = %path.display(), size_gb = size / 1_073_741_824, "reading the Wikidata dump");
let decoder = MultiBzDecoder::new(BufReader::with_capacity(1 << 22, file));
return Ok(Box::new(BufReader::with_capacity(1 << 22, decoder)));
}
let url = args.url.as_deref().unwrap_or(DEFAULT_URL);
tracing::info!(url, "streaming the Wikidata dump; nothing is written to disk");
let response = ureq::get(url).call().with_context(|| format!("cannot fetch {url}"))?;
let decoder = MultiBzDecoder::new(BufReader::with_capacity(1 << 22, response.into_body().into_reader()));
Ok(Box::new(BufReader::with_capacity(1 << 22, decoder)))
}
fn read_dump(args: &Args, canon: &HashMap<String, i32>) -> Result<Harvest> {
let reader = open_dump(args)?;
let mut harvest = Harvest::default();
for line in reader.lines() {
let line = line.context("failed to read a dump line")?;
harvest.entities += take_line(&line, canon, &mut harvest.by_mbid, &mut harvest.labels, &mut harvest.with_mbid);
if harvest.entities % 1_000_000 == 0 && harvest.entities > 0 {
tracing::info!(
entities = harvest.entities,
matched = harvest.by_mbid.len(),
labels = harvest.labels.len(),
"still reading"
);
}
if args.limit.is_some_and(|limit| harvest.entities >= limit) {
tracing::info!(entities = harvest.entities, "stopping at the requested limit");
break;
}
}
Ok(harvest)
}
fn take_line(line: &str, canon: &HashMap<String, i32>, by_mbid: &mut HashMap<String, Facts>, labels: &mut HashMap<i32, String>, with_mbid: &mut u64) -> u64 {
let trimmed = line.trim().trim_end_matches(',');
if trimmed.is_empty() || trimmed == "[" || trimmed == "]" {
return 0;
}
let Ok(entity) = serde_json::from_str::<Entity>(trimmed) else {
return 0;
};
let Some(qid) = qid_number(&entity.id) else {
return 1;
};
if let Some(label) = entity.labels.get("en") {
labels.insert(qid, label.value.clone());
}
let Some(mbid_statements) = entity.claims.get(P_MBID) else {
return 1;
};
*with_mbid += 1;
let Some(mbid) = mbid_statements
.iter()
.filter_map(|s| s.mainsnak.datavalue.as_ref())
.filter_map(DataValue::string)
.find(|mbid| canon.contains_key(*mbid))
else {
return 1;
};
let mut facts = Facts {
qid,
enwiki_title: entity.sitelinks.get("enwiki").map(|s| s.title.clone()),
..Facts::default()
};
if let Some(place) = first_entity(&entity, P_FORMATION_PLACE) {
facts.origin_qid = Some(place);
facts.origin_is_birth = false;
} else if let Some(place) = first_entity(&entity, P_BIRTH_PLACE) {
facts.origin_qid = Some(place);
facts.origin_is_birth = true;
}
facts.country_qid = first_entity(&entity, P_COUNTRY);
facts.inception_year = entity
.claims
.get(P_INCEPTION)
.and_then(|statements| statements.first())
.and_then(|s| s.mainsnak.datavalue.as_ref())
.and_then(DataValue::year);
facts.genres = all_entities(&entity, P_GENRE);
facts.labels = all_entities(&entity, P_LABEL);
facts.influenced_by = all_entities(&entity, P_INFLUENCED_BY);
by_mbid.insert(mbid.to_string(), facts);
1
}
fn first_entity(entity: &Entity, property: &str) -> Option<i32> {
entity
.claims
.get(property)?
.iter()
.filter_map(|s| s.mainsnak.datavalue.as_ref())
.find_map(DataValue::entity_qid)
}
fn all_entities(entity: &Entity, property: &str) -> Vec<i32> {
entity
.claims
.get(property)
.map(|statements| {
statements
.iter()
.filter_map(|s| s.mainsnak.datavalue.as_ref())
.filter_map(DataValue::entity_qid)
.collect()
})
.unwrap_or_default()
}
async fn write(pool: &PgPool, canon: &HashMap<String, i32>, harvest: &Harvest, version: &str) -> Result<()> {
let resolved: Vec<(i32, &Facts)> = harvest
.by_mbid
.iter()
.filter_map(|(mbid, facts)| canon.get(mbid).map(|artist| (*artist, facts)))
.collect();
let artist_of_qid: HashMap<i32, i32> = resolved.iter().map(|(artist, facts)| (facts.qid, *artist)).collect();
let mut tx = pool.begin().await.context("failed to open the import transaction")?;
let import_id: i32 = sqlx::query_scalar(
"INSERT INTO dump_import (source, version) VALUES ('wikidata', $1)
ON CONFLICT (source, version) DO UPDATE SET started_at = now(), finished_at = NULL, rows_imported = NULL
RETURNING id",
)
.bind(version)
.fetch_one(&mut *tx)
.await
.context("failed to record the import")?;
sqlx::query("TRUNCATE artist_wikidata, artist_fact, artist_wikidata_genre, artist_wikidata_label, artist_influence, wikidata_item CASCADE")
.execute(&mut *tx)
.await
.context("failed to clear the previous Wikidata facts")?;
let mut written: i64 = 0;
written += write_links(&mut tx, &resolved).await?;
written += write_facts(&mut tx, &resolved).await?;
written += write_multi(&mut tx, &resolved).await?;
written += write_influence(&mut tx, &resolved, &artist_of_qid).await?;
written += write_item_labels(&mut tx, &resolved, harvest).await?;
sqlx::query("UPDATE dump_import SET finished_at = now(), rows_imported = $2 WHERE id = $1")
.bind(import_id)
.bind(written)
.execute(&mut *tx)
.await
.context("failed to close the import record")?;
tx.commit().await.context("failed to commit the import")?;
tracing::info!(version, rows = written, "Wikidata import complete");
Ok(())
}
async fn write_links(tx: &mut sqlx::PgTransaction<'_>, resolved: &[(i32, &Facts)]) -> Result<i64> {
let mut written = 0i64;
for chunk in resolved.chunks(BATCH) {
let artists: Vec<i32> = chunk.iter().map(|(a, _)| *a).collect();
let qids: Vec<i32> = chunk.iter().map(|(_, f)| f.qid).collect();
let titles: Vec<Option<&str>> = chunk.iter().map(|(_, f)| f.enwiki_title.as_deref()).collect();
sqlx::query(
"INSERT INTO artist_wikidata (artist_id, qid, enwiki_title)
SELECT * FROM UNNEST($1::int[], $2::int[], $3::text[])
ON CONFLICT (artist_id) DO NOTHING",
)
.bind(&artists)
.bind(&qids)
.bind(&titles)
.execute(&mut **tx)
.await
.context("failed to write Wikidata links")?;
written += i64::try_from(chunk.len()).unwrap_or(i64::MAX);
}
tracing::info!(rows = written, "Wikidata links written");
Ok(written)
}
async fn write_facts(tx: &mut sqlx::PgTransaction<'_>, resolved: &[(i32, &Facts)]) -> Result<i64> {
let mut written = 0i64;
for chunk in resolved.chunks(BATCH) {
let artists: Vec<i32> = chunk.iter().map(|(a, _)| *a).collect();
let origins: Vec<Option<i32>> = chunk.iter().map(|(_, f)| f.origin_qid).collect();
let is_birth: Vec<Option<bool>> = chunk.iter().map(|(_, f)| f.origin_qid.map(|_| f.origin_is_birth)).collect();
let years: Vec<Option<i16>> = chunk.iter().map(|(_, f)| f.inception_year).collect();
let countries: Vec<Option<i32>> = chunk.iter().map(|(_, f)| f.country_qid).collect();
sqlx::query(
"INSERT INTO artist_fact (artist_id, origin_qid, origin_is_birth, inception_year, country_qid)
SELECT * FROM UNNEST($1::int[], $2::int[], $3::bool[], $4::smallint[], $5::int[])
ON CONFLICT (artist_id) DO NOTHING",
)
.bind(&artists)
.bind(&origins)
.bind(&is_birth)
.bind(&years)
.bind(&countries)
.execute(&mut **tx)
.await
.context("failed to write artist facts")?;
written += i64::try_from(chunk.len()).unwrap_or(i64::MAX);
}
tracing::info!(rows = written, "artist facts written");
Ok(written)
}
async fn write_multi(tx: &mut sqlx::PgTransaction<'_>, resolved: &[(i32, &Facts)]) -> Result<i64> {
let genre_rows: Vec<(i32, i32)> = resolved
.iter()
.flat_map(|(artist, facts)| facts.genres.iter().map(move |qid| (*artist, *qid)))
.collect();
let label_rows: Vec<(i32, i32)> = resolved
.iter()
.flat_map(|(artist, facts)| facts.labels.iter().map(move |qid| (*artist, *qid)))
.collect();
let mut written = 0i64;
for chunk in genre_rows.chunks(BATCH) {
let artists: Vec<i32> = chunk.iter().map(|(a, _)| *a).collect();
let qids: Vec<i32> = chunk.iter().map(|(_, q)| *q).collect();
sqlx::query(
"INSERT INTO artist_wikidata_genre (artist_id, genre_qid)
SELECT * FROM UNNEST($1::int[], $2::int[])
ON CONFLICT DO NOTHING",
)
.bind(&artists)
.bind(&qids)
.execute(&mut **tx)
.await
.context("failed to write Wikidata genres")?;
written += i64::try_from(chunk.len()).unwrap_or(i64::MAX);
}
for chunk in label_rows.chunks(BATCH) {
let artists: Vec<i32> = chunk.iter().map(|(a, _)| *a).collect();
let qids: Vec<i32> = chunk.iter().map(|(_, q)| *q).collect();
sqlx::query(
"INSERT INTO artist_wikidata_label (artist_id, label_qid)
SELECT * FROM UNNEST($1::int[], $2::int[])
ON CONFLICT DO NOTHING",
)
.bind(&artists)
.bind(&qids)
.execute(&mut **tx)
.await
.context("failed to write Wikidata labels")?;
written += i64::try_from(chunk.len()).unwrap_or(i64::MAX);
}
tracing::info!(rows = written, "Wikidata genres and labels written");
Ok(written)
}
async fn write_influence(tx: &mut sqlx::PgTransaction<'_>, resolved: &[(i32, &Facts)], artist_of_qid: &HashMap<i32, i32>) -> Result<i64> {
let mut dropped = 0u64;
let mut rows: Vec<(i32, i32)> = Vec::new();
for (artist, facts) in resolved {
for qid in &facts.influenced_by {
match artist_of_qid.get(qid) {
None => dropped += 1,
Some(other) if other == artist => dropped += 1,
Some(other) => rows.push((*artist, *other)),
}
}
}
let mut written = 0i64;
for chunk in rows.chunks(BATCH) {
let artists: Vec<i32> = chunk.iter().map(|(a, _)| *a).collect();
let influences: Vec<i32> = chunk.iter().map(|(_, i)| *i).collect();
sqlx::query(
"INSERT INTO artist_influence (artist_id, influence_id)
SELECT * FROM UNNEST($1::int[], $2::int[])
ON CONFLICT DO NOTHING",
)
.bind(&artists)
.bind(&influences)
.execute(&mut **tx)
.await
.context("failed to write influence edges")?;
written += i64::try_from(chunk.len()).unwrap_or(i64::MAX);
}
tracing::info!(rows = written, dropped_outside_canon = dropped, "influence edges written");
Ok(written)
}
async fn write_item_labels(tx: &mut sqlx::PgTransaction<'_>, resolved: &[(i32, &Facts)], harvest: &Harvest) -> Result<i64> {
let mut referenced: HashSet<i32> = HashSet::new();
for (_, facts) in resolved {
referenced.extend(facts.origin_qid);
referenced.extend(facts.country_qid);
referenced.extend(facts.genres.iter().copied());
referenced.extend(facts.labels.iter().copied());
}
let rows: Vec<(i32, Option<&str>)> = referenced.iter().map(|qid| (*qid, harvest.labels.get(qid).map(String::as_str))).collect();
let mut written = 0i64;
for chunk in rows.chunks(BATCH) {
let qids: Vec<i32> = chunk.iter().map(|(q, _)| *q).collect();
let labels: Vec<Option<&str>> = chunk.iter().map(|(_, l)| *l).collect();
sqlx::query(
"INSERT INTO wikidata_item (qid, label)
SELECT * FROM UNNEST($1::int[], $2::text[])
ON CONFLICT (qid) DO UPDATE SET label = EXCLUDED.label",
)
.bind(&qids)
.bind(&labels)
.execute(&mut **tx)
.await
.context("failed to write item labels")?;
written += i64::try_from(chunk.len()).unwrap_or(i64::MAX);
}
tracing::info!(rows = written, "item labels written");
Ok(written)
}
#[cfg(test)]
mod tests {
use super::*;
const MBID_A: &str = "5b11f4ce-a62d-471e-81fc-a69a8278c7da";
const MBID_B: &str = "9282c8b4-ca0b-4c6b-b7e3-4f7762dfc4d6";
fn canon() -> HashMap<String, i32> {
HashMap::from([(MBID_A.to_string(), 1), (MBID_B.to_string(), 2)])
}
fn take(lines: &[&str]) -> (HashMap<String, Facts>, HashMap<i32, String>, u64, u64) {
let canon = canon();
let (mut by_mbid, mut labels, mut with_mbid, mut read) = (HashMap::new(), HashMap::new(), 0, 0);
for line in lines {
read += take_line(line, &canon, &mut by_mbid, &mut labels, &mut with_mbid);
}
(by_mbid, labels, with_mbid, read)
}
fn entity(qid: &str, body: &str) -> String {
format!(r#"{{"type":"item","id":"{qid}",{body}}},"#)
}
fn mbid_claim(mbid: &str) -> String {
format!(r#""P434":[{{"mainsnak":{{"datavalue":{{"value":"{mbid}","type":"string"}}}}}}]"#)
}
fn item_claim(property: &str, qid: i32) -> String {
format!(
r#""{property}":[{{"mainsnak":{{"datavalue":{{"value":{{"entity-type":"item","numeric-id":{qid},"id":"Q{qid}"}},"type":"wikibase-entityid"}}}}}}]"#
)
}
#[test]
fn skips_the_arrays_brackets() {
let (facts, _, _, read) = take(&["[", "]", " "]);
assert!(facts.is_empty());
assert_eq!(read, 0);
}
#[test]
fn keeps_only_entities_the_canon_knows() {
let line_known = entity("Q1", &format!(r#""claims":{{{}}}"#, mbid_claim(MBID_A)));
let line_stranger = entity("Q2", &format!(r#""claims":{{{}}}"#, mbid_claim("00000000-0000-0000-0000-000000000000")));
let (facts, _, with_mbid, read) = take(&[&line_known, &line_stranger]);
assert_eq!(read, 2);
assert_eq!(with_mbid, 2, "both had an MBID");
assert_eq!(facts.len(), 1, "only the canonical one was kept");
assert!(facts.contains_key(MBID_A));
}
#[test]
fn keeps_every_label_even_for_entities_it_ignores() {
let city = entity("Q24826", r#""labels":{"en":{"language":"en","value":"Liverpool"}}"#);
let (_, labels, _, _) = take(&[&city]);
assert_eq!(labels.get(&24826).map(String::as_str), Some("Liverpool"));
}
#[test]
fn prefers_formation_place_over_birth_place() {
let line = entity(
"Q11649",
&format!(
r#""claims":{{{},{},{}}}"#,
mbid_claim(MBID_A),
item_claim(P_FORMATION_PLACE, 233_808),
item_claim(P_BIRTH_PLACE, 24826)
),
);
let (facts, _, _, _) = take(&[&line]);
let found = &facts[MBID_A];
assert_eq!(found.origin_qid, Some(233_808));
assert!(!found.origin_is_birth);
}
#[test]
fn falls_back_to_birth_place_for_people() {
let line = entity("Q1", &format!(r#""claims":{{{},{}}}"#, mbid_claim(MBID_A), item_claim(P_BIRTH_PLACE, 24826)));
let (facts, _, _, _) = take(&[&line]);
assert_eq!(facts[MBID_A].origin_qid, Some(24826));
assert!(facts[MBID_A].origin_is_birth);
}
#[test]
fn reads_the_year_out_of_a_signed_time_value() {
let time = r#""P571":[{"mainsnak":{"datavalue":{"value":{"time":"+1987-01-01T00:00:00Z","precision":9},"type":"time"}}}]"#;
let line = entity("Q1", &format!(r#""claims":{{{},{time}}}"#, mbid_claim(MBID_A)));
let (facts, _, _, _) = take(&[&line]);
assert_eq!(facts[MBID_A].inception_year, Some(1987));
}
#[test]
fn keeps_the_enwiki_title_for_the_prose_import() {
let line = entity(
"Q11649",
&format!(
r#""claims":{{{}}},"sitelinks":{{"enwiki":{{"site":"enwiki","title":"Nirvana (band)","badges":[]}},"ruwiki":{{"site":"ruwiki","title":"Nirvana"}}}}"#,
mbid_claim(MBID_A)
),
);
let (facts, _, _, _) = take(&[&line]);
assert_eq!(facts[MBID_A].enwiki_title.as_deref(), Some("Nirvana (band)"));
}
#[test]
fn collects_every_value_of_the_many_valued_properties() {
let genres = format!(
r#""P136":[{},{}]"#,
r#"{"mainsnak":{"datavalue":{"value":{"entity-type":"item","id":"Q11399"},"type":"wikibase-entityid"}}}"#,
r#"{"mainsnak":{"datavalue":{"value":{"entity-type":"item","id":"Q83440"},"type":"wikibase-entityid"}}}"#
);
let line = entity("Q1", &format!(r#""claims":{{{},{genres}}}"#, mbid_claim(MBID_A)));
let (facts, _, _, _) = take(&[&line]);
assert_eq!(facts[MBID_A].genres, vec![11399, 83440]);
}
#[test]
fn survives_a_malformed_line() {
let good = entity("Q1", &format!(r#""claims":{{{}}}"#, mbid_claim(MBID_A)));
let (facts, _, _, read) = take(&["{not json", &good]);
assert_eq!(read, 1, "the broken line is not counted as an entity");
assert_eq!(facts.len(), 1);
}
#[test]
fn ignores_snaks_with_no_value() {
let unknown = r#""P740":[{"mainsnak":{"snaktype":"somevalue","property":"P740"}}]"#;
let line = entity("Q1", &format!(r#""claims":{{{},{unknown}}}"#, mbid_claim(MBID_A)));
let (facts, _, _, _) = take(&[&line]);
assert_eq!(facts[MBID_A].origin_qid, None);
}
#[test]
fn ignores_entities_that_are_not_items() {
let lexeme = r#"{"type":"lexeme","id":"L1234","lemmas":{}},"#;
let (facts, labels, _, read) = take(&[lexeme]);
assert_eq!(read, 1, "it was still a line of the dump");
assert!(facts.is_empty());
assert!(labels.is_empty());
}
}