Skip to main content

hyperdb_compile_check/
registry.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Table and struct registry for compile-time validation.
5//!
6//! `derive(Table) #[hyperdb(register)]` calls into this registry at macro
7//! expansion time to record:
8//! - The SQL `CREATE TABLE` statement for the table (for lazy seeding).
9//! - The struct's field-name list (for the name-subset diff in `validate.rs`).
10//!
11//! Tables are seeded into the `CompileTimeDb` lazily: only when a `query_as!`
12//! dry-run returns SQLSTATE `42P01` (undefined_table) do we seed the relevant
13//! table and retry. This handles cross-file macro expansion ordering without
14//! requiring a client-side SQL parser.
15
16use std::collections::HashMap;
17use std::sync::OnceLock;
18
19use parking_lot::Mutex;
20
21/// Information about a registered table derived from `#[derive(Table)]`.
22#[derive(Debug, Clone)]
23pub struct TableEntry {
24    /// The SQL `CREATE TABLE` statement emitted by `derive(Table)`.
25    pub create_sql: String,
26    /// Struct field names that map to columns (honoring `#[hyperdb(rename)]`,
27    /// excluding `#[hyperdb(index = N)]` fields).
28    pub fields: Vec<String>,
29}
30
31/// Global registry: **both** table name and struct ident → entry.
32///
33/// Keyed by table name for the dry-run seed-and-retry path (Hyper reports the
34/// SQL table name in 42P01 errors). Also indexed by struct name so that
35/// `validate_query_as(struct_name, sql)` — which receives the Rust ident, not
36/// the SQL name — can find the entry without knowing the table name upfront.
37static REGISTRY: OnceLock<Mutex<HashMap<String, TableEntry>>> = OnceLock::new();
38
39/// Reverse map: struct ident → SQL table name. Populated alongside REGISTRY.
40static STRUCT_TO_TABLE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
41
42fn registry() -> &'static Mutex<HashMap<String, TableEntry>> {
43    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
44}
45
46fn struct_to_table() -> &'static Mutex<HashMap<String, String>> {
47    STRUCT_TO_TABLE.get_or_init(|| Mutex::new(HashMap::new()))
48}
49
50/// Register a table and its associated struct field list.
51///
52/// Called by the `derive(Table) #[hyperdb(register)]` expansion.
53/// - `struct_name`: the Rust struct ident (e.g. `"User"`), used by
54///   `validate_query_as` which receives the ident from `query_as!(User, …)`.
55/// - `table_name`: the SQL table name (e.g. `"users"`), used when Hyper
56///   reports a missing table via SQLSTATE 42P01.
57/// - `fields`: column names the struct expects in query results.
58pub fn register(
59    struct_name: impl Into<String>,
60    table_name: impl Into<String>,
61    create_sql: impl Into<String>,
62    fields: Vec<String>,
63) {
64    let struct_name = struct_name.into();
65    let table_name = table_name.into();
66    let entry = TableEntry {
67        create_sql: create_sql.into(),
68        fields,
69    };
70    registry().lock().insert(table_name.clone(), entry.clone());
71    struct_to_table()
72        .lock()
73        .insert(struct_name, table_name.clone());
74}
75
76/// Look up a registered entry by **SQL table name**.
77pub fn get_by_table(table_name: &str) -> Option<TableEntry> {
78    registry().lock().get(table_name).cloned()
79}
80
81/// Look up a registered entry by **Rust struct ident**.
82/// Returns `(table_name, entry)` so callers have the SQL name for seeding.
83pub fn get_by_struct(struct_name: &str) -> Option<(String, TableEntry)> {
84    let table_name = struct_to_table().lock().get(struct_name).cloned()?;
85    let entry = registry().lock().get(&table_name).cloned()?;
86    Some((table_name, entry))
87}
88
89/// Returns true if the **SQL table name** is known to the registry.
90pub fn contains(table_name: &str) -> bool {
91    registry().lock().contains_key(table_name)
92}
93
94/// All registered table names (for diagnostics).
95pub fn registered_names() -> Vec<String> {
96    registry().lock().keys().cloned().collect()
97}
98
99/// The public `Registry` type — a thin newtype that provides the seeding
100/// interface against a live `CompileTimeDb`. Created from a lock guard by
101/// `validate_query_as`.
102#[derive(Debug)]
103pub struct Registry;
104
105impl Registry {
106    /// Seed a registered table into `db` if it hasn't been created yet.
107    ///
108    /// Returns `true` if the table was seeded, `false` if unknown.
109    ///
110    /// # Errors
111    ///
112    /// Returns a Hyper error if the `CREATE TABLE` command fails.
113    pub fn seed_if_known(
114        table_name: &str,
115        db: &mut crate::db::CompileTimeDb,
116    ) -> hyperdb_api::Result<bool> {
117        let Some(entry) = get_by_table(table_name) else {
118            return Ok(false);
119        };
120        db.conn.execute_command(&entry.create_sql)?;
121        Ok(true)
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    // Tests use unique table-name prefixes so they don't share global state
130    // and can run in parallel without races.
131
132    #[test]
133    fn register_and_retrieve_by_table() {
134        register(
135            "RegTestUser",
136            "reg_test_users",
137            "CREATE TABLE reg_test_users (id BIGINT, name TEXT)",
138            vec!["id".into(), "name".into()],
139        );
140        let entry = get_by_table("reg_test_users").expect("lookup by table name");
141        assert_eq!(entry.fields, &["id", "name"]);
142        assert!(entry.create_sql.contains("reg_test_users"));
143    }
144
145    #[test]
146    fn register_and_retrieve_by_struct() {
147        register(
148            "RegTestProfile",
149            "reg_test_profiles",
150            "CREATE TABLE reg_test_profiles (id BIGINT, bio TEXT)",
151            vec!["id".into(), "bio".into()],
152        );
153        let (table_name, entry) = get_by_struct("RegTestProfile").expect("lookup by struct name");
154        assert_eq!(table_name, "reg_test_profiles");
155        assert_eq!(entry.fields, &["id", "bio"]);
156    }
157
158    #[test]
159    fn contains_returns_false_for_unknown() {
160        assert!(!contains("reg_test_nonexistent_xyzzy"));
161    }
162
163    #[test]
164    fn registration_ordering_independent() {
165        register(
166            "RegTestOrder",
167            "reg_test_orders",
168            "CREATE TABLE reg_test_orders (id BIGINT, user_id BIGINT)",
169            vec!["id".into(), "user_id".into()],
170        );
171        register(
172            "RegTestCustomer",
173            "reg_test_customers",
174            "CREATE TABLE reg_test_customers (id BIGINT)",
175            vec!["id".into()],
176        );
177        assert!(contains("reg_test_orders"));
178        assert!(contains("reg_test_customers"));
179        assert!(get_by_struct("RegTestOrder").is_some());
180        assert!(get_by_struct("RegTestCustomer").is_some());
181    }
182}