Skip to main content

pdg_rs/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3#![allow(clippy::empty_docs)]
4#![doc = ""]
5use std::path::{Path, PathBuf};
6
7use rusqlite::{Connection, OptionalExtension, params_from_iter, types::Value};
8use thiserror::Error;
9
10mod database;
11mod models;
12pub use models::*;
13
14/// The default PDG edition used by this crate.
15pub const LATEST_EDITION: &str = "2025";
16
17/// Result type returned by fallible `pdg-rs` operations.
18pub type PdgResult<T> = Result<T, PdgError>;
19
20/// Errors returned by database access, code parsing, and quantum number conversion.
21#[derive(Error, Debug)]
22pub enum PdgError {
23    /// A `SQLite` error from the PDG database.
24    #[error(transparent)]
25    SqliteError(#[from] rusqlite::Error),
26    /// An I/O error occurred while reading, writing, or caching the PDG database.
27    #[error(transparent)]
28    Io(#[from] std::io::Error),
29    /// No OS-specific cache directory could be found.
30    #[error("PDG database cache directory is unavailable")]
31    CacheDirectoryUnavailable,
32    /// The PDG database is not cached and network access is disabled.
33    #[error("PDG database is not cached at {0:?} and downloads are disabled")]
34    OfflineDatabaseMissing(PathBuf),
35    /// Downloading the PDG database failed.
36    #[error("failed to download PDG database: {0}")]
37    Download(String),
38    /// A cached or downloaded PDG database has the wrong byte length.
39    #[error(
40        "PDG database size mismatch for {path:?}: expected {expected} bytes, got {actual} bytes"
41    )]
42    DatabaseSizeMismatch {
43        /// Path to the database file that was checked.
44        path: PathBuf,
45        /// Expected byte length.
46        expected: u64,
47        /// Actual byte length.
48        actual: u64,
49    },
50    /// A cached or downloaded PDG database has the wrong SHA-256 digest.
51    #[error("PDG database checksum mismatch for {path:?}: expected {expected}, got {actual}")]
52    DatabaseChecksumMismatch {
53        /// Path to the database file that was checked.
54        path: PathBuf,
55        /// Expected SHA-256 digest.
56        expected: &'static str,
57        /// Actual SHA-256 digest.
58        actual: String,
59    },
60    /// A value type code was not recognized.
61    #[error("Failed to parse ValueType: {0}")]
62    ParseValueType(String),
63    /// A limit type code was not recognized.
64    #[error("Failed to parse LimitType: {0}")]
65    ParseLimitType(String),
66    /// A data type code was not recognized.
67    #[error("Failed to parse DataType: {0}")]
68    ParseDataType(String),
69    /// A quantum number could not be converted into the requested numeric representation.
70    #[error(transparent)]
71    QuantumNumberConversion(#[from] QuantumNumberConversionError),
72    /// An application-specific error message.
73    #[error("Custom error: {0}")]
74    Custom(String),
75}
76
77/// Handle for querying the Particle Data Group database.
78///
79/// Create a handle with [`Pdg::open`], then use lookup methods such as
80/// [`Pdg::particle`], [`Pdg::mcid`], [`Pdg::search_particles`], and
81/// [`Pdg::search_text`] to retrieve typed records.
82///
83/// # Examples
84///
85/// ```no_run
86/// use pdg_rs::Pdg;
87///
88/// # fn main() -> pdg_rs::PdgResult<()> {
89/// let pdg = Pdg::open()?;
90/// let pion = pdg.particle("pi+")?.expect("pi+ is in the PDG database");
91///
92/// assert_eq!(pion.name, "pi+");
93/// # Ok(())
94/// # }
95/// ```
96#[derive(Debug)]
97pub struct Pdg {
98    conn: Connection,
99}
100
101impl Pdg {
102    const PARTICLE_COLUMNS: &'static str = "pdgparticle.pdgid, name, pdgid.description, cc_type, pdgid.flags, mcid, charge, quantum_i, quantum_g, quantum_j, quantum_p, quantum_c";
103    const PARTICLE_JOIN: &'static str =
104        "JOIN pdgid ON pdgid.pdgid = pdgparticle.pdgid AND pdgid.data_type = 'PART'";
105
106    /// Opens the default PDG `SQLite` database.
107    ///
108    /// If `PDG_RS_DB_PATH` is set, that exact database file is opened. Otherwise,
109    /// the default database is loaded from the local cache, downloading and
110    /// verifying it first when needed.
111    ///
112    /// This also initializes the temporary full-text search index used by
113    /// [`Pdg::search_text`].
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if the configured database cannot be opened, the cached
118    /// database cannot be verified, or the default database cannot be
119    /// downloaded.
120    pub fn open() -> PdgResult<Self> {
121        Self::open_path(database::ensure_database()?)
122    }
123
124    /// Opens the default PDG `SQLite` database without downloading it.
125    ///
126    /// If `PDG_RS_DB_PATH` is set, that exact database file is opened. Otherwise,
127    /// this opens the verified cached copy of the default database.
128    ///
129    /// # Errors
130    ///
131    /// Returns [`PdgError::OfflineDatabaseMissing`] if the default database is
132    /// not cached. Returns another error if the configured database cannot be
133    /// opened or the cached database cannot be verified.
134    pub fn open_cached() -> PdgResult<Self> {
135        Self::open_path(database::cached_database()?)
136    }
137
138    /// Opens a PDG `SQLite` database at `path`.
139    ///
140    /// This is useful for applications that manage their own database file or
141    /// want to use a different PDG edition. Use [`Pdg::open`] for the default
142    /// cache-or-download behavior.
143    ///
144    /// # Errors
145    ///
146    /// Returns a database error if `path` cannot be opened or initialized.
147    pub fn open_path(path: impl AsRef<Path>) -> PdgResult<Self> {
148        let conn = Connection::open(path)?;
149        let pdg = Self { conn };
150        pdg.initialize_text_search()?;
151        Ok(pdg)
152    }
153
154    /// Ensures the default database exists in the local cache and returns its path.
155    ///
156    /// If `PDG_RS_DB_PATH` is set, this returns that path without downloading or
157    /// validating it. Otherwise, this downloads the default database when it is
158    /// missing or invalid, unless `PDG_RS_OFFLINE` disables network access.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if the cache directory is unavailable, the database
163    /// cannot be downloaded, or the downloaded file fails verification.
164    pub fn ensure_database() -> PdgResult<PathBuf> {
165        database::ensure_database()
166    }
167
168    /// Returns the cache path for the default database.
169    ///
170    /// This does not check whether the database exists and does not consider
171    /// `PDG_RS_DB_PATH`, which is an explicit override rather than a cache path.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`PdgError::CacheDirectoryUnavailable`] if no cache directory can
176    /// be found.
177    pub fn cached_database_path() -> PdgResult<PathBuf> {
178        database::cached_database_path()
179    }
180
181    /// Returns the underlying `SQLite` connection.
182    ///
183    /// This is useful for advanced queries that are not covered by the typed
184    /// API. Prefer the typed methods where possible because they preserve links
185    /// back to this [`Pdg`] handle.
186    #[must_use]
187    pub const fn db(&self) -> &Connection {
188        &self.conn
189    }
190
191    fn initialize_text_search(&self) -> PdgResult<()> {
192        self.conn.execute_batch(
193            "CREATE VIRTUAL TABLE temp.pdg_text_search USING fts5(
194                body,
195                source UNINDEXED,
196                pdgid UNINDEXED,
197                text_type UNINDEXED,
198                sort UNINDEXED,
199                tokenize = 'unicode61'
200            );
201            INSERT INTO pdg_text_search(body, source, pdgid, text_type, sort)
202                SELECT description, 'description', pdgid, NULL, sort
203                FROM pdgid
204                WHERE description != '';
205            INSERT INTO pdg_text_search(body, source, pdgid, text_type, sort)
206                SELECT text, 'text', pdgid, type, sort
207                FROM pdgtext
208                WHERE text IS NOT NULL AND text != '';
209            INSERT INTO pdg_text_search(body, source, pdgid, text_type, sort)
210                SELECT text, 'footnote', pdgid, NULL, footnote_index
211                FROM pdgfootnote
212                WHERE text IS NOT NULL AND text != '';",
213        )?;
214        Ok(())
215    }
216
217    /// Looks up a particle by its PDG item name.
218    ///
219    /// Use [`Pdg::particle_by_pdgid`] when you already have a PDG identifier,
220    /// or [`Pdg::mcid`] when you have a Monte Carlo particle ID.
221    ///
222    /// # Errors
223    ///
224    /// Returns a database error if the query cannot be executed.
225    pub fn particle(&self, name: impl Into<String>) -> PdgResult<Option<PdgParticle<'_>>> {
226        let name = name.into();
227        let sql = format!(
228            "SELECT {} FROM pdgparticle {} WHERE name = ?1",
229            Self::PARTICLE_COLUMNS,
230            Self::PARTICLE_JOIN
231        );
232        let mut stmt = self.conn.prepare(&sql)?;
233        Ok(stmt
234            .query_row([&name], |row| PdgParticle::from_row(self, row))
235            .optional()?)
236    }
237
238    /// Looks up a particle by PDG identifier, case-insensitively.
239    ///
240    /// # Errors
241    ///
242    /// Returns a database error if the query cannot be executed.
243    pub fn particle_by_pdgid(
244        &self,
245        pdgid: impl Into<String>,
246    ) -> PdgResult<Option<PdgParticle<'_>>> {
247        let pdgid = pdgid.into();
248        let sql = format!(
249            "SELECT {} FROM pdgparticle {} WHERE upper(pdgparticle.pdgid) = upper(?1)",
250            Self::PARTICLE_COLUMNS,
251            Self::PARTICLE_JOIN
252        );
253        let mut stmt = self.conn.prepare(&sql)?;
254        Ok(stmt
255            .query_row([&pdgid], |row| PdgParticle::from_row(self, row))
256            .optional()?)
257    }
258
259    /// Looks up raw metadata for a PDG identifier.
260    ///
261    /// This returns a [`PdgIdEntry`] for any PDG row type, not just particle
262    /// rows.
263    ///
264    /// # Errors
265    ///
266    /// Returns a database error if the query cannot be executed.
267    pub fn pdgid(&self, pdgid: impl Into<String>) -> PdgResult<Option<PdgIdEntry>> {
268        let pdgid = pdgid.into();
269        let mut stmt = self.conn.prepare(
270            "SELECT id, pdgid, parent_pdgid, description, mode_number, data_type, flags, year_added, sort
271            FROM pdgid
272            WHERE upper(pdgid) = upper(?1)",
273        )?;
274        Ok(stmt
275            .query_row([&pdgid], |row| PdgIdEntry::try_from(row))
276            .optional()?)
277    }
278
279    /// Searches descriptions, text blocks, and footnotes with `SQLite` FTS5.
280    ///
281    /// Non-alphanumeric separators are normalized into individual quoted search
282    /// terms before querying the index.
283    ///
284    /// # Examples
285    ///
286    /// ```no_run
287    /// use pdg_rs::{Pdg, TextSearchSource};
288    ///
289    /// # fn main() -> pdg_rs::PdgResult<()> {
290    /// let pdg = Pdg::open()?;
291    /// let results = pdg.search_text("K(S)0 mean life")?;
292    ///
293    /// assert!(results.iter().any(|result| {
294    ///     result.source == TextSearchSource::Description
295    /// }));
296    /// # Ok(())
297    /// # }
298    /// ```
299    ///
300    /// # Errors
301    ///
302    /// Returns a database error if the search query cannot be executed.
303    pub fn search_text(&self, query: impl Into<String>) -> PdgResult<Vec<TextSearchResult>> {
304        let Some(query) = fts_query(&query.into()) else {
305            return Ok(Vec::new());
306        };
307
308        let mut stmt = self.conn.prepare(
309            "SELECT
310                pdgid,
311                source,
312                text_type,
313                sort,
314                body,
315                snippet(pdg_text_search, 0, '[', ']', '...', 24),
316                bm25(pdg_text_search)
317            FROM pdg_text_search
318            WHERE pdg_text_search MATCH ?1
319            ORDER BY bm25(pdg_text_search), source, sort",
320        )?;
321        Ok(stmt
322            .query_map([&query], |row| {
323                let pdgid = row.get::<_, PdgId>(0)?;
324                let source = row.get::<_, String>(1)?;
325                let text_type = row.get::<_, Option<String>>(2)?;
326                let sort = row.get::<_, Option<isize>>(3)?;
327                let text = row.get::<_, String>(4)?;
328                let snippet = row.get::<_, String>(5)?;
329                let score = row.get::<_, f64>(6)?;
330                let (source, pdg_text) = match source.as_str() {
331                    "text" => {
332                        let text_type = text_type.unwrap_or_default();
333                        let sort = sort.unwrap_or_default();
334                        (
335                            TextSearchSource::Text {
336                                text_type: text_type.clone(),
337                                sort,
338                            },
339                            Some(PdgText {
340                                pdgid: pdgid.clone(),
341                                text_type,
342                                text: Some(text.clone()),
343                                sort,
344                            }),
345                        )
346                    }
347                    "footnote" => (
348                        TextSearchSource::Footnote {
349                            index: sort.unwrap_or_default(),
350                        },
351                        None,
352                    ),
353                    _ => (TextSearchSource::Description, None),
354                };
355                Ok(TextSearchResult {
356                    pdgid,
357                    source,
358                    text,
359                    snippet,
360                    score,
361                    pdg_text,
362                })
363            })?
364            .collect::<Result<Vec<_>, _>>()?)
365    }
366
367    /// Looks up a particle by its Monte Carlo particle ID.
368    ///
369    /// # Errors
370    ///
371    /// Returns a database error if the query cannot be executed.
372    pub fn mcid(&self, mcid: isize) -> PdgResult<Option<PdgParticle<'_>>> {
373        let sql = format!(
374            "SELECT {} FROM pdgparticle {} WHERE mcid = ?1",
375            Self::PARTICLE_COLUMNS,
376            Self::PARTICLE_JOIN
377        );
378        let mut stmt = self.conn.prepare(&sql)?;
379        Ok(stmt
380            .query_row([&mcid], |row| PdgParticle::from_row(self, row))
381            .optional()?)
382    }
383
384    #[allow(clippy::too_many_lines)]
385    /// Searches particles using a [`ParticleSearchQuery`].
386    ///
387    /// # Examples
388    ///
389    /// ```no_run
390    /// use pdg_rs::{Charge, ParticleClass, ParticleSearchQuery, Pdg};
391    ///
392    /// # fn main() -> pdg_rs::PdgResult<()> {
393    /// let pdg = Pdg::open()?;
394    /// let charged_mesons = pdg.search_particles(
395    ///     ParticleSearchQuery::new()
396    ///         .class(ParticleClass::Meson)
397    ///         .charge(Charge::Plus),
398    /// )?;
399    ///
400    /// assert!(charged_mesons.iter().any(|particle| particle.name == "pi+"));
401    /// # Ok(())
402    /// # }
403    /// ```
404    ///
405    /// # Errors
406    ///
407    /// Returns a database error if any particle, property, or decay filter query
408    /// cannot be executed.
409    pub fn search_particles(&self, query: ParticleSearchQuery) -> PdgResult<Vec<PdgParticle<'_>>> {
410        let mut sql = format!(
411            "SELECT {} FROM pdgparticle {} WHERE 1 = 1",
412            Self::PARTICLE_COLUMNS,
413            Self::PARTICLE_JOIN
414        );
415        let mut params = Vec::new();
416        let mass_range = query.mass_range_mev;
417        let width_range = query.width_range_mev;
418        let lifetime_range = query.lifetime_range_seconds;
419        let decays_to = query.decays_to.clone();
420        let decays_from = query.decays_from.clone();
421        let decay_state_expansion = query.decay_state_expansion;
422
423        if let Some(name_contains) = query.name_contains {
424            sql.push_str(" AND name LIKE '%' || ? || '%'");
425            params.push(Value::Text(name_contains));
426        }
427
428        if let Some(particle_class) = query.particle_class {
429            sql.push_str(" AND pdgid.flags = ?");
430            params.push(Value::Text(particle_class.to_code().to_string()));
431        }
432
433        if let Some(particle_type) = query.particle_type {
434            sql.push_str(" AND cc_type = ?");
435            params.push(Value::Text(particle_type.to_code().to_string()));
436        }
437
438        if let Some(charge) = query.charge {
439            sql.push_str(" AND ABS(charge - ?) < 1e-12");
440            params.push(Value::Real(charge.as_f64()));
441        }
442
443        Self::push_quantum_filter(&mut sql, &mut params, "quantum_i", query.isospin);
444        Self::push_quantum_filter(&mut sql, &mut params, "quantum_g", query.g_parity);
445        Self::push_quantum_filter(&mut sql, &mut params, "quantum_j", query.angular_momentum);
446        Self::push_quantum_filter(&mut sql, &mut params, "quantum_p", query.parity);
447        Self::push_quantum_filter(&mut sql, &mut params, "quantum_c", query.charge_conjugation);
448
449        self.push_decay_filters(
450            &mut sql,
451            &mut params,
452            decays_to.states.clone(),
453            true,
454            decay_state_expansion,
455        )?;
456        self.push_decay_filters(
457            &mut sql,
458            &mut params,
459            decays_from,
460            false,
461            decay_state_expansion,
462        )?;
463
464        sql.push_str(" ORDER BY pdgparticle.pdgid, name");
465        let mut stmt = self.conn.prepare(&sql)?;
466        let particles = stmt
467            .query_map(params_from_iter(params), |row| {
468                PdgParticle::from_row(self, row)
469            })?
470            .collect::<Result<Vec<_>, _>>()?;
471
472        let mass_entries = if mass_range.is_some() {
473            Some(self.property_entries_by_parent(DataType::Mass)?)
474        } else {
475            None
476        };
477        let width_entries = if width_range.is_some() {
478            Some(self.property_entries_by_parent(DataType::FullWidth)?)
479        } else {
480            None
481        };
482        let lifetime_entries = if lifetime_range.is_some() {
483            Some(self.property_entries_by_parent(DataType::Lifetime)?)
484        } else {
485            None
486        };
487
488        let mut filtered_particles = Vec::new();
489        for particle in particles {
490            if !matches_data_range(
491                mass_entries.as_ref(),
492                &particle.pdgid,
493                mass_range,
494                Unit::Mev,
495            ) || !matches_data_range(
496                width_entries.as_ref(),
497                &particle.pdgid,
498                width_range,
499                Unit::Mev,
500            ) || !matches_data_range(
501                lifetime_entries.as_ref(),
502                &particle.pdgid,
503                lifetime_range,
504                Unit::Seconds,
505            ) {
506                continue;
507            }
508
509            if decays_to.mode == DecayMatchMode::Exact
510                && !decays_to.states.is_empty()
511                && !self.particle_matches_exact_decay(
512                    &particle.pdgid,
513                    &decays_to.states,
514                    decay_state_expansion,
515                )?
516            {
517                continue;
518            }
519
520            filtered_particles.push(particle);
521        }
522
523        Ok(filtered_particles)
524    }
525
526    /// Looks up a PDG item by name.
527    ///
528    /// Items include particles, groups, aliases, charge multiplets, and other
529    /// names used to organize decays.
530    ///
531    /// # Errors
532    ///
533    /// Returns a database error if the query cannot be executed.
534    pub fn item(&self, name: impl Into<String>) -> PdgResult<Option<PdgItem<'_>>> {
535        let name = name.into();
536        let mut stmt = self
537            .conn
538            .prepare("SELECT name, item_type FROM pdgitem WHERE name = ?1")?;
539        Ok(stmt
540            .query_row([&name], |row| PdgItem::from_row(self, row))
541            .optional()?)
542    }
543
544    /// Returns child items for a PDG item name.
545    ///
546    /// # Errors
547    ///
548    /// Returns a database error if the item map or particle lookup cannot be
549    /// queried.
550    pub fn item_children(&self, name: impl Into<String>) -> PdgResult<Vec<PdgItemChild<'_>>> {
551        let name = name.into();
552        let child_items = {
553            let mut stmt = self.conn.prepare(
554                "SELECT child.name, child.item_type, pdgitem_map.sort FROM pdgitem_map JOIN pdgitem parent ON parent.id = pdgitem_map.pdgitem_id JOIN pdgitem child ON child.id = pdgitem_map.target_id WHERE parent.name = ?1 ORDER BY pdgitem_map.sort",
555            )?;
556            stmt.query_map([&name], |row| {
557                Ok((PdgItem::from_row(self, row)?, row.get::<_, isize>(2)?))
558            })?
559            .collect::<Result<Vec<_>, _>>()?
560        };
561
562        child_items
563            .into_iter()
564            .map(|(item, sort)| {
565                let particle = match &item.item_type {
566                    PdgItemType::Particle => self.particle(&item.name)?,
567                    _ => None,
568                };
569                Ok(PdgItemChild {
570                    item,
571                    sort,
572                    particle,
573                })
574            })
575            .collect()
576    }
577
578    /// Returns parent items for a PDG item name.
579    ///
580    /// # Errors
581    ///
582    /// Returns a database error if the query cannot be executed.
583    pub fn item_parents(&self, name: impl Into<String>) -> PdgResult<Vec<PdgItem<'_>>> {
584        let name = name.into();
585        let mut stmt = self.conn.prepare(
586            "SELECT parent.name, parent.item_type FROM pdgitem_map JOIN pdgitem parent ON parent.id = pdgitem_map.pdgitem_id JOIN pdgitem child ON child.id = pdgitem_map.target_id WHERE child.name = ?1 ORDER BY parent.item_type, parent.name",
587        )?;
588        Ok(stmt
589            .query_map([&name], |row| PdgItem::from_row(self, row))?
590            .collect::<Result<Vec<_>, _>>()?)
591    }
592
593    /// Returns PDG identifier rows whose parent is `pdgid`.
594    ///
595    /// # Errors
596    ///
597    /// Returns a database error if the query cannot be executed.
598    pub fn children_for_pdgid(&self, pdgid: impl Into<String>) -> PdgResult<Vec<PdgIdEntry>> {
599        let pdgid = pdgid.into();
600        let mut stmt = self.conn.prepare(
601            "SELECT id, pdgid, parent_pdgid, description, mode_number, data_type, flags, year_added, sort
602            FROM pdgid
603            WHERE upper(parent_pdgid) = upper(?1)
604            ORDER BY sort, pdgid",
605        )?;
606        Ok(stmt
607            .query_map([&pdgid], |row| PdgIdEntry::try_from(row))?
608            .collect::<Result<Vec<_>, _>>()?)
609    }
610
611    /// Returns PDG identifier rows linked from `pdgid` through the mapping table.
612    ///
613    /// # Errors
614    ///
615    /// Returns a database error if the query cannot be executed.
616    pub fn mapped_entries_for_pdgid(&self, pdgid: impl Into<String>) -> PdgResult<Vec<PdgIdEntry>> {
617        let pdgid = pdgid.into();
618        let mut stmt = self.conn.prepare(
619            "SELECT target.id, target.pdgid, target.parent_pdgid, target.description, target.mode_number, target.data_type, target.flags, target.year_added, target.sort
620            FROM pdgid_map
621            JOIN pdgid target ON target.id = pdgid_map.target_id
622            WHERE upper(pdgid_map.source) = upper(?1)
623            ORDER BY pdgid_map.sort, target.pdgid",
624        )?;
625        Ok(stmt
626            .query_map([&pdgid], |row| PdgIdEntry::try_from(row))?
627            .collect::<Result<Vec<_>, _>>()?)
628    }
629
630    /// Returns latest-edition numeric data rows for a PDG identifier.
631    ///
632    /// # Errors
633    ///
634    /// Returns a database error if the query cannot be executed.
635    pub fn data_for(&self, pdgid: impl Into<String>) -> PdgResult<Vec<DataEntry<'_>>> {
636        let pdgid = pdgid.into();
637        let sql = format!(
638            "SELECT {} FROM pdgdata WHERE upper(pdgid) = upper(?1) AND edition = ?2 ORDER BY sort",
639            DataEntry::COLUMNS
640        );
641        let mut stmt = self.conn.prepare(&sql)?;
642        Ok(stmt
643            .query_map([&pdgid, LATEST_EDITION], |row| {
644                DataEntry::from_row(self, row)
645            })?
646            .collect::<Result<Vec<_>, _>>()?)
647    }
648
649    /// Returns text blocks attached to a PDG identifier.
650    ///
651    /// # Errors
652    ///
653    /// Returns a database error if the query cannot be executed.
654    pub fn texts_for(&self, pdgid: impl Into<String>) -> PdgResult<Vec<PdgText>> {
655        let pdgid = pdgid.into();
656        let mut stmt = self.conn.prepare(
657            "SELECT pdgid, type, text, sort FROM pdgtext WHERE pdgid = ?1 ORDER BY sort",
658        )?;
659        Ok(stmt
660            .query_map([&pdgid], |row| PdgText::try_from(row))?
661            .collect::<Result<Vec<_>, _>>()?)
662    }
663
664    /// Returns footnotes attached to a PDG identifier.
665    ///
666    /// # Errors
667    ///
668    /// Returns a database error if the query cannot be executed.
669    pub fn footnotes_for(&self, pdgid: impl Into<String>) -> PdgResult<Vec<PdgFootnote>> {
670        let pdgid = pdgid.into();
671        let mut stmt = self.conn.prepare(
672            "SELECT pdgid, footnote_index, text, changebar FROM pdgfootnote WHERE pdgid = ?1 ORDER BY footnote_index",
673        )?;
674        Ok(stmt
675            .query_map([&pdgid], |row| PdgFootnote::try_from(row))?
676            .collect::<Result<Vec<_>, _>>()?)
677    }
678
679    /// Returns measurement rows, values, references, and footnotes for a PDG identifier.
680    ///
681    /// # Errors
682    ///
683    /// Returns a database error if measurement, value, or footnote queries cannot
684    /// be executed.
685    pub fn measurements_for(&self, pdgid: impl Into<String>) -> PdgResult<Vec<PdgMeasurement>> {
686        let pdgid = pdgid.into();
687        let mut stmt = self.conn.prepare(
688            "SELECT pdgmeasurement.id, pdgmeasurement.pdgid, event_count, confidence_level, place, technique, charge, changebar, comment, sort, document_id, publication_name, publication_year, doi, inspire_id, title FROM pdgmeasurement JOIN pdgreference ON pdgreference.id = pdgmeasurement.pdgreference_id WHERE pdgmeasurement.pdgid = ?1 ORDER BY sort",
689        )?;
690        let mut measurements = stmt
691            .query_map([&pdgid], |row| PdgMeasurement::try_from(row))?
692            .collect::<Result<Vec<_>, _>>()?;
693
694        let mut value_stmt = self.conn.prepare(
695            "SELECT column_name, value_text, unit_text, display_value_text, display_power_of_ten, display_in_percent, limit_type, used_in_average, used_in_fit, value, error_positive, error_negative, stat_error_positive, stat_error_negative, syst_error_positive, syst_error_negative, sort FROM pdgmeasurement_values WHERE pdgmeasurement_id = ?1 ORDER BY sort",
696        )?;
697        let mut footnote_stmt = self.conn.prepare(
698            "SELECT pdgfootnote.pdgid, footnote_index, text, changebar FROM pdgmeasurement_footnote JOIN pdgfootnote ON pdgfootnote.id = pdgmeasurement_footnote.pdgfootnote_id WHERE pdgmeasurement_id = ?1 ORDER BY footnote_index",
699        )?;
700        for measurement in &mut measurements {
701            measurement.values = value_stmt
702                .query_map([measurement.id], |row| PdgMeasurementValue::try_from(row))?
703                .collect::<Result<Vec<_>, _>>()?;
704            measurement.footnotes = footnote_stmt
705                .query_map([measurement.id], |row| PdgFootnote::try_from(row))?
706                .collect::<Result<Vec<_>, _>>()?;
707        }
708
709        Ok(measurements)
710    }
711
712    fn push_decay_filters(
713        &self,
714        sql: &mut String,
715        params: &mut Vec<Value>,
716        states: Vec<String>,
717        is_outgoing: bool,
718        expansion: DecayStateExpansion,
719    ) -> PdgResult<()> {
720        if states.is_empty() {
721            return Ok(());
722        }
723
724        sql.push_str(
725            " AND pdgparticle.pdgid IN (
726                SELECT decay_pdgid.parent_pdgid
727                FROM pdgid decay_pdgid
728                WHERE decay_pdgid.data_type IN ('BFX', 'BFX1', 'BFX2', 'BFX3', 'BFX4', 'BFX5', 'BFI', 'BFI1', 'BFI2', 'BFI3', 'BFI4', 'BFI5')",
729        );
730
731        for state in states {
732            let names = self.expand_decay_state_names(state, expansion)?;
733            let placeholders = std::iter::repeat_n("?", names.len())
734                .collect::<Vec<_>>()
735                .join(", ");
736            sql.push_str(&format!(
737                " AND EXISTS (
738                    SELECT 1
739                    FROM pdgdecay
740                    WHERE pdgdecay.pdgid = decay_pdgid.pdgid
741                        AND pdgdecay.is_outgoing = ?
742                        AND pdgdecay.name IN ({placeholders})
743                )"
744            ));
745            params.push(Value::Integer(i64::from(is_outgoing)));
746            params.extend(names.into_iter().map(Value::Text));
747        }
748
749        sql.push(')');
750        Ok(())
751    }
752
753    fn push_quantum_filter<T: ToString>(
754        sql: &mut String,
755        params: &mut Vec<Value>,
756        column: &str,
757        filter: QuantumFilter<T>,
758    ) {
759        match filter {
760            QuantumFilter::Any => {}
761            QuantumFilter::Missing => {
762                sql.push_str(&format!(" AND {column} IS NULL"));
763            }
764            QuantumFilter::Value(value) => {
765                sql.push_str(&format!(" AND {column} = ?"));
766                params.push(Value::Text(value.to_string()));
767            }
768        }
769    }
770
771    fn particle_matches_exact_decay(
772        &self,
773        pdgid: &str,
774        states: &[String],
775        expansion: DecayStateExpansion,
776    ) -> PdgResult<bool> {
777        let requested = states
778            .iter()
779            .map(|state| self.expand_decay_state_names(state.clone(), expansion))
780            .collect::<PdgResult<Vec<_>>>()?;
781        let mut stmt = self.conn.prepare(
782            "SELECT decay_pdgid.pdgid, pdgdecay.name, pdgdecay.multiplier
783            FROM pdgid decay_pdgid
784            JOIN pdgdecay ON pdgdecay.pdgid = decay_pdgid.pdgid
785            WHERE decay_pdgid.parent_pdgid = ?1
786                AND decay_pdgid.data_type IN ('BFX', 'BFX1', 'BFX2', 'BFX3', 'BFX4', 'BFX5', 'BFI', 'BFI1', 'BFI2', 'BFI3', 'BFI4', 'BFI5')
787                AND pdgdecay.is_outgoing = 1
788            ORDER BY decay_pdgid.sort ASC, pdgdecay.sort ASC",
789        )?;
790        let rows = stmt
791            .query_map([pdgid], |row| {
792                Ok((
793                    row.get::<_, PdgId>(0)?,
794                    row.get::<_, String>(1)?,
795                    row.get::<_, i64>(2)?,
796                ))
797            })?
798            .collect::<Result<Vec<_>, _>>()?;
799
800        let mut modes = std::collections::HashMap::<PdgId, Vec<String>>::new();
801        for (mode_pdgid, name, multiplier) in rows {
802            let products = modes.entry(mode_pdgid).or_default();
803            for _ in 0..multiplier {
804                products.push(name.clone());
805            }
806        }
807
808        Ok(modes
809            .values()
810            .any(|products| exact_decay_products_match(&requested, products)))
811    }
812
813    fn expand_decay_state_names(
814        &self,
815        name: String,
816        expansion: DecayStateExpansion,
817    ) -> PdgResult<Vec<String>> {
818        if expansion == DecayStateExpansion::Literal {
819            return Ok(vec![name]);
820        }
821
822        let mut names = vec![name.clone()];
823        let mut seen = std::collections::HashSet::from([name.clone()]);
824        let mut parents = Vec::new();
825
826        let mut stmt = self.conn.prepare(
827            "SELECT child.name, 0 AS is_parent, pdgitem_map.sort
828            FROM pdgitem_map
829            JOIN pdgitem parent ON parent.id = pdgitem_map.pdgitem_id
830            JOIN pdgitem child ON child.id = pdgitem_map.target_id
831            WHERE parent.name = ?1
832            UNION ALL
833            SELECT parent.name, 1 AS is_parent, pdgitem_map.sort
834            FROM pdgitem_map
835            JOIN pdgitem parent ON parent.id = pdgitem_map.pdgitem_id
836            JOIN pdgitem child ON child.id = pdgitem_map.target_id
837            WHERE child.name = ?1
838            ORDER BY is_parent, sort",
839        )?;
840        for (relative, is_parent) in stmt
841            .query_map([&name], |row| {
842                Ok((row.get::<_, String>(0)?, row.get::<_, bool>(1)?))
843            })?
844            .collect::<Result<Vec<_>, _>>()?
845        {
846            if is_parent {
847                parents.push(relative.clone());
848            }
849            if seen.insert(relative.clone()) {
850                names.push(relative);
851            }
852        }
853
854        if self.is_antiparticle_item(&name)? || self.is_neutral_meson_particle(&name)? {
855            for parent in parents {
856                let alias = format!("{parent}bar");
857                if self.decay_state_exists(&alias)? && seen.insert(alias.clone()) {
858                    names.push(alias);
859                }
860            }
861        }
862
863        Ok(names)
864    }
865
866    fn is_antiparticle_item(&self, name: &str) -> PdgResult<bool> {
867        Ok(self
868            .conn
869            .query_row(
870                "SELECT 1 FROM pdgparticle WHERE name = ?1 AND cc_type = 'A'",
871                [name],
872                |_| Ok(()),
873            )
874            .optional()?
875            .is_some())
876    }
877
878    fn is_neutral_meson_particle(&self, name: &str) -> PdgResult<bool> {
879        Ok(self
880            .conn
881            .query_row(
882                "SELECT 1
883                FROM pdgparticle
884                JOIN pdgid ON pdgid.pdgid = pdgparticle.pdgid AND pdgid.data_type = 'PART'
885                WHERE pdgparticle.name = ?1
886                    AND ABS(pdgparticle.charge) < 1e-12
887                    AND pdgid.flags = 'M'",
888                [name],
889                |_| Ok(()),
890            )
891            .optional()?
892            .is_some())
893    }
894
895    fn decay_state_exists(&self, name: &str) -> PdgResult<bool> {
896        Ok(self
897            .conn
898            .query_row(
899                "SELECT 1
900                WHERE EXISTS (SELECT 1 FROM pdgitem WHERE name = ?1)
901                    OR EXISTS (SELECT 1 FROM pdgdecay WHERE name = ?1)",
902                [name],
903                |_| Ok(()),
904            )
905            .optional()?
906            .is_some())
907    }
908
909    fn property_entries_by_parent(
910        &self,
911        data_type: DataType,
912    ) -> PdgResult<std::collections::HashMap<PdgId, Vec<DataEntry<'_>>>> {
913        let data_type_code = data_type.to_code();
914        let direct_sql = format!(
915            "SELECT {}, pdgid.parent_pdgid FROM pdgdata JOIN pdgid ON pdgid.id = pdgdata.pdgid_id WHERE pdgid.data_type = ?1 AND pdgdata.edition = ?2",
916            DataEntry::COLUMNS
917        );
918        let mut direct_stmt = self.conn.prepare(&direct_sql)?;
919        let direct_rows = direct_stmt
920            .query_map([data_type_code, LATEST_EDITION], |row| {
921                Ok((
922                    row.get::<_, PdgId>(DataEntry::COLUMN_COUNT)?,
923                    DataEntry::from_row(self, row)?,
924                ))
925            })?
926            .collect::<Result<Vec<_>, _>>()?;
927
928        let section_sql = format!(
929            "SELECT {}, section.parent_pdgid FROM pdgdata
930            JOIN pdgid child ON child.id = pdgdata.pdgid_id
931            JOIN pdgid section ON section.pdgid = child.parent_pdgid
932            WHERE child.data_type = ?1
933                AND section.data_type = ?2
934                AND pdgdata.edition = ?3",
935            DataEntry::COLUMNS
936        );
937        let mut section_stmt = self.conn.prepare(&section_sql)?;
938        let section_rows = section_stmt
939            .query_map(
940                [data_type_code, DataType::Section.to_code(), LATEST_EDITION],
941                |row| {
942                    Ok((
943                        row.get::<_, PdgId>(DataEntry::COLUMN_COUNT)?,
944                        DataEntry::from_row(self, row)?,
945                    ))
946                },
947            )?
948            .collect::<Result<Vec<_>, _>>()?;
949
950        let direct_entries = group_property_entries(direct_rows);
951        let section_entries = group_property_entries(section_rows);
952
953        Ok(section_entries.into_iter().chain(direct_entries).collect())
954    }
955}
956
957fn group_property_entries<'pdg>(
958    rows: Vec<(PdgId, DataEntry<'pdg>)>,
959) -> std::collections::HashMap<PdgId, Vec<DataEntry<'pdg>>> {
960    let mut grouped =
961        std::collections::HashMap::<PdgId, (Vec<DataEntry<'pdg>>, Vec<DataEntry<'pdg>>)>::new();
962    for (parent_pdgid, entry) in rows {
963        let (all_entries, summary_entries) = grouped.entry(parent_pdgid).or_default();
964        all_entries.push(entry.clone());
965        if entry.in_summary_table {
966            summary_entries.push(entry);
967        }
968    }
969
970    grouped
971        .into_iter()
972        .map(|(pdgid, (all_entries, summary_entries))| {
973            let entries = if summary_entries.is_empty() {
974                all_entries
975            } else {
976                summary_entries
977            };
978            (pdgid, entries)
979        })
980        .collect()
981}
982
983#[derive(Copy, Clone)]
984enum Unit {
985    Mev,
986    Seconds,
987}
988
989#[derive(Copy, Clone)]
990struct Interval {
991    min: f64,
992    max: f64,
993}
994
995impl Interval {
996    fn overlaps(self, min: f64, max: f64) -> bool {
997        self.min <= max && self.max >= min
998    }
999}
1000
1001fn matches_data_range(
1002    entries_by_parent: Option<&std::collections::HashMap<PdgId, Vec<DataEntry<'_>>>>,
1003    pdgid: &str,
1004    range: Option<(f64, f64)>,
1005    unit: Unit,
1006) -> bool {
1007    let Some((min, max)) = range else {
1008        return true;
1009    };
1010    let Some(entries) = entries_by_parent.and_then(|entries| entries.get(pdgid)) else {
1011        return true;
1012    };
1013    if entries.is_empty() {
1014        return true;
1015    }
1016
1017    entries
1018        .iter()
1019        .any(|entry| data_interval(entry, unit).is_none_or(|interval| interval.overlaps(min, max)))
1020}
1021
1022fn exact_decay_products_match(requested: &[Vec<String>], products: &[String]) -> bool {
1023    if requested.len() != products.len() {
1024        return false;
1025    }
1026
1027    let mut used = vec![false; products.len()];
1028    exact_decay_products_match_from(requested, products, &mut used, 0)
1029}
1030
1031fn exact_decay_products_match_from(
1032    requested: &[Vec<String>],
1033    products: &[String],
1034    used: &mut [bool],
1035    index: usize,
1036) -> bool {
1037    if index == requested.len() {
1038        return true;
1039    }
1040
1041    for (product_index, product) in products.iter().enumerate() {
1042        if used[product_index] || !requested[index].contains(product) {
1043            continue;
1044        }
1045
1046        used[product_index] = true;
1047        if exact_decay_products_match_from(requested, products, used, index + 1) {
1048            return true;
1049        }
1050        used[product_index] = false;
1051    }
1052
1053    false
1054}
1055
1056fn data_interval(entry: &DataEntry, unit: Unit) -> Option<Interval> {
1057    let factor = unit_factor(&entry.unit_text, unit)?;
1058
1059    if entry.limit_type == Some(LimitType::Range) {
1060        return parse_interval(entry).map(|interval| Interval {
1061            min: interval.min * factor,
1062            max: interval.max * factor,
1063        });
1064    }
1065
1066    let value = entry.value?;
1067    let value = value * factor;
1068    match entry.limit_type {
1069        Some(LimitType::UpperLimit) => Some(Interval {
1070            min: f64::NEG_INFINITY,
1071            max: value,
1072        }),
1073        Some(LimitType::LowerLimit) => Some(Interval {
1074            min: value,
1075            max: f64::INFINITY,
1076        }),
1077        Some(LimitType::RangeExclusion) => None,
1078        Some(LimitType::Range) => unreachable!(),
1079        None => {
1080            let error_positive = entry.error_positive.unwrap_or(0.0) * factor;
1081            let error_negative = entry.error_negative.unwrap_or(0.0) * factor;
1082            Some(Interval {
1083                min: value - error_negative,
1084                max: value + error_positive,
1085            })
1086        }
1087    }
1088}
1089
1090fn parse_interval(entry: &DataEntry) -> Option<Interval> {
1091    let text = entry
1092        .value_text
1093        .as_deref()
1094        .unwrap_or(entry.display_value_text.as_str());
1095    let values = parse_numbers(text);
1096    let min = values.iter().copied().reduce(f64::min)?;
1097    let max = values.iter().copied().reduce(f64::max)?;
1098    Some(Interval { min, max })
1099}
1100
1101fn parse_numbers(text: &str) -> Vec<f64> {
1102    let chars = text.char_indices().collect::<Vec<_>>();
1103    let mut numbers = Vec::new();
1104    let mut index = 0;
1105    while index < chars.len() {
1106        let (start, ch) = chars[index];
1107        let next = chars.get(index + 1).map(|(_, ch)| *ch);
1108        let starts_number = ch.is_ascii_digit()
1109            || (ch == '.' && next.is_some_and(|ch| ch.is_ascii_digit()))
1110            || ((ch == '+' || ch == '-')
1111                && next.is_some_and(|ch| ch.is_ascii_digit() || ch == '.'));
1112        if !starts_number {
1113            index += 1;
1114            continue;
1115        }
1116
1117        let mut end_index = index + 1;
1118        let mut previous = ch;
1119        while end_index < chars.len() {
1120            let (_, current) = chars[end_index];
1121            if current.is_ascii_digit()
1122                || current == '.'
1123                || current == 'e'
1124                || current == 'E'
1125                || ((current == '+' || current == '-') && (previous == 'e' || previous == 'E'))
1126            {
1127                previous = current;
1128                end_index += 1;
1129            } else {
1130                break;
1131            }
1132        }
1133
1134        let end = chars
1135            .get(end_index)
1136            .map_or(text.len(), |(char_index, _)| *char_index);
1137        if let Ok(value) = text[start..end].parse::<f64>() {
1138            numbers.push(value);
1139        }
1140        index = end_index;
1141    }
1142    numbers
1143}
1144
1145fn unit_factor(unit_text: &str, unit: Unit) -> Option<f64> {
1146    match unit {
1147        Unit::Mev => match unit_text {
1148            "MeV" => Some(1.0),
1149            "GeV" => Some(1000.0),
1150            "keV" => Some(0.001),
1151            "eV" => Some(0.000_001),
1152            "u" => Some(931.494_102_42),
1153            _ => None,
1154        },
1155        Unit::Seconds => match unit_text {
1156            "s" => Some(1.0),
1157            "yr" | "years" => Some(31_557_600.0),
1158            _ => None,
1159        },
1160    }
1161}
1162
1163fn fts_query(query: &str) -> Option<String> {
1164    let mut terms = Vec::new();
1165    let mut term = String::new();
1166    for ch in query.chars() {
1167        if ch.is_alphanumeric() {
1168            term.push(ch);
1169        } else if !term.is_empty() {
1170            terms.push(std::mem::take(&mut term));
1171        }
1172    }
1173    if !term.is_empty() {
1174        terms.push(term);
1175    }
1176
1177    (!terms.is_empty()).then(|| {
1178        terms
1179            .into_iter()
1180            .map(|term| format!("\"{term}\""))
1181            .collect::<Vec<_>>()
1182            .join(" ")
1183    })
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188    use super::*;
1189
1190    fn test_pdg() -> Pdg {
1191        Pdg::open_path(concat!(
1192            env!("CARGO_MANIFEST_DIR"),
1193            "/data/pdgall-2025-v0.2.2.sqlite"
1194        ))
1195        .unwrap()
1196    }
1197
1198    #[test]
1199    fn charged_decay_states_do_not_expand_to_antiparticle_siblings() {
1200        let db = test_pdg();
1201        let names = db
1202            .expand_decay_state_names("pi+".to_string(), DecayStateExpansion::Inclusive)
1203            .unwrap();
1204
1205        assert!(names.contains(&"pi+".to_string()));
1206        assert!(names.contains(&"pi".to_string()));
1207        assert!(!names.contains(&"pi-".to_string()));
1208    }
1209
1210    #[test]
1211    fn text_search_finds_pdgid_descriptions() {
1212        let db = test_pdg();
1213        let results = db.search_text("K(S)0 MEAN LIFE").unwrap();
1214
1215        let result = results
1216            .iter()
1217            .find(|result| {
1218                result.pdgid == "S012205" && result.source == TextSearchSource::Description
1219            })
1220            .unwrap();
1221
1222        assert!(result.text.contains("K(S)0 MEAN LIFE"));
1223        assert!(!result.snippet.is_empty());
1224        assert!(result.pdg_text.is_none());
1225    }
1226
1227    #[test]
1228    fn text_search_finds_pdgtext_rows() {
1229        let db = test_pdg();
1230        let results = db
1231            .search_text("Measurements Kbar0 divided convert")
1232            .unwrap();
1233
1234        let result = results
1235            .iter()
1236            .find(|result| matches!(result.source, TextSearchSource::Text { .. }))
1237            .unwrap();
1238
1239        assert!(result.text.contains("Measurements given as a Kbar0 ratio"));
1240        assert!(!result.snippet.is_empty());
1241        assert_eq!(
1242            result.pdg_text.as_ref().unwrap().text.as_deref(),
1243            Some(result.text.as_str())
1244        );
1245    }
1246
1247    #[test]
1248    fn text_search_finds_footnote_rows() {
1249        let db = test_pdg();
1250        let results = db.search_text("normalisation decay").unwrap();
1251
1252        let result = results
1253            .iter()
1254            .find(|result| matches!(result.source, TextSearchSource::Footnote { .. }))
1255            .unwrap();
1256
1257        assert_eq!(result.pdgid, "S042P86");
1258        assert!(result.text.contains("normalisation decay"));
1259        assert!(!result.snippet.is_empty());
1260        assert!(result.pdg_text.is_none());
1261    }
1262
1263    #[test]
1264    fn text_search_handles_punctuation_heavy_queries() {
1265        let db = test_pdg();
1266        let results = db.search_text("K(S)0").unwrap();
1267
1268        assert!(!results.is_empty());
1269        assert!(results.iter().any(|result| result.text.contains("K(S)0")));
1270    }
1271
1272    #[test]
1273    fn text_search_orders_by_score() {
1274        let db = test_pdg();
1275        let results = db.search_text("form factors").unwrap();
1276
1277        assert!(results.len() > 1);
1278        assert!(
1279            results
1280                .windows(2)
1281                .all(|window| window[0].score <= window[1].score)
1282        );
1283    }
1284
1285    #[test]
1286    fn text_search_returns_empty_results_for_empty_or_missing_queries() {
1287        let db = test_pdg();
1288
1289        assert!(db.search_text(".,()").unwrap().is_empty());
1290        assert!(db.search_text("zzzzzznotapdgterm").unwrap().is_empty());
1291    }
1292}