Skip to main content

graphar_flight/
registry.rs

1use std::{
2    collections::HashMap,
3    sync::{Arc, RwLock},
4};
5
6use arrow_schema::{DataType, Field, Schema, SchemaRef};
7
8/// Maps Cypher query strings to their expected Arrow return schemas.
9///
10/// ## Two modes
11///
12/// **Auto-string (default)** — leave the registry empty or only register the
13/// queries you care about. Unregistered queries fall back to an all-`Utf8`
14/// schema inferred from FalkorDB's response header at query time.
15///
16/// **Registered (typed)** — pre-declare the schema so columns like `_gar_id`,
17/// `src`, and `dst` come back as `Int64` instead of strings. Use the
18/// convenience helpers [`register_vertex_query`] and [`register_edge_query`]
19/// for the common cases, or [`register`] for full control.
20///
21/// [`register_vertex_query`]: SchemaRegistry::register_vertex_query
22/// [`register_edge_query`]: SchemaRegistry::register_edge_query
23/// [`register`]: SchemaRegistry::register
24///
25/// ## Example — minimal setup (auto-string only)
26///
27/// ```rust
28/// use std::sync::Arc;
29/// use graphar_flight::SchemaRegistry;
30///
31/// // An empty registry is valid; all queries use auto-string mode.
32/// let registry = Arc::new(SchemaRegistry::new());
33/// ```
34///
35/// ## Example — typed vertex + edge schemas
36///
37/// ```rust
38/// use std::sync::Arc;
39/// use graphar_flight::SchemaRegistry;
40///
41/// let registry = Arc::new(SchemaRegistry::new());
42///
43/// // _gar_id → Int64, extra columns → Utf8.
44/// registry.register_vertex_query(
45///     "MATCH (n:Person) RETURN n._gar_id AS _gar_id, n.name, n.age",
46///     &["name", "age"],
47/// );
48///
49/// // src + dst → Int64, extra columns → Utf8.
50/// registry.register_edge_query(
51///     "MATCH (a:Person)-[r:KNOWS]->(b:Person) \
52///      RETURN a._gar_id AS src, b._gar_id AS dst, r.since",
53///     &["since"],
54/// );
55/// ```
56#[derive(Debug, Default, Clone)]
57pub struct SchemaRegistry {
58    inner: Arc<RwLock<HashMap<String, SchemaRef>>>,
59    /// Friendly table name → the Cypher query key it stands for. Lets the
60    /// Flight SQL metadata surface advertise short table names (`GetTables`)
61    /// while the query itself stays the registry key. Empty for queries
62    /// registered without a name.
63    names: Arc<RwLock<HashMap<String, String>>>,
64}
65
66impl SchemaRegistry {
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Register an arbitrary schema under `key`. Overwrites any existing entry.
72    ///
73    /// Use this when you need full control over column types. For the common
74    /// vertex / edge patterns prefer [`register_vertex_query`] and
75    /// [`register_edge_query`].
76    ///
77    /// [`register_vertex_query`]: SchemaRegistry::register_vertex_query
78    /// [`register_edge_query`]: SchemaRegistry::register_edge_query
79    ///
80    /// ```rust
81    /// use std::sync::Arc;
82    /// use graphar_flight::SchemaRegistry;
83    /// use arrow_schema::{DataType, Field, Schema};
84    ///
85    /// let registry = SchemaRegistry::new();
86    ///
87    /// registry.register(
88    ///     "MATCH (n:Person) RETURN n._gar_id AS _gar_id, n.name, n.age",
89    ///     Arc::new(Schema::new(vec![
90    ///         Field::new("_gar_id", DataType::Int64, false),
91    ///         Field::new("name",    DataType::Utf8,  true),
92    ///         Field::new("age",     DataType::Int64, true),   // typed!
93    ///     ])),
94    /// );
95    /// ```
96    pub fn register(&self, key: impl Into<String>, schema: SchemaRef) -> &Self {
97        self.inner.write().unwrap().insert(key.into(), schema);
98        self
99    }
100
101    /// Register a **vertex** query schema.
102    ///
103    /// Adds `_gar_id: Int64` (non-nullable) as the first column, then appends
104    /// each name in `extra_cols` as a nullable `Utf8` column.
105    ///
106    /// The `query` string must exactly match what the Flight SQL client sends.
107    /// By convention, the `RETURN` clause should alias the internal ID as
108    /// `_gar_id` and list the extra columns in the same order as `extra_cols`.
109    ///
110    /// ## Example
111    ///
112    /// ```rust
113    /// use std::sync::Arc;
114    /// use graphar_flight::SchemaRegistry;
115    ///
116    /// let registry = SchemaRegistry::new();
117    ///
118    /// // Person vertices — id as Int64, everything else as Utf8.
119    /// registry.register_vertex_query(
120    ///     "MATCH (n:Person) RETURN n._gar_id AS _gar_id, n.name, n.city",
121    ///     &["name", "city"],
122    /// );
123    ///
124    /// // No extra properties — only _gar_id.
125    /// registry.register_vertex_query(
126    ///     "MATCH (n:Company) RETURN n._gar_id AS _gar_id",
127    ///     &[],
128    /// );
129    /// ```
130    pub fn register_vertex_query(&self, query: &str, extra_cols: &[&str]) -> &Self {
131        let mut fields = vec![Field::new("_gar_id", DataType::Int64, false)];
132        fields.extend(
133            extra_cols
134                .iter()
135                .map(|&n| Field::new(n, DataType::Utf8, true)),
136        );
137        self.register(query, Arc::new(Schema::new(fields)))
138    }
139
140    /// Register an **edge** query schema.
141    ///
142    /// Adds `src: Int64` and `dst: Int64` (both non-nullable) as the first two
143    /// columns, then appends each name in `extra_cols` as a nullable `Utf8` column.
144    ///
145    /// The `RETURN` clause should alias the source `_gar_id` as `src` and the
146    /// destination `_gar_id` as `dst`, matching GraphAr's internal IDs.
147    ///
148    /// ## Example
149    ///
150    /// ```rust
151    /// use std::sync::Arc;
152    /// use graphar_flight::SchemaRegistry;
153    ///
154    /// let registry = SchemaRegistry::new();
155    ///
156    /// // Edges with no properties — just src + dst.
157    /// registry.register_edge_query(
158    ///     "MATCH (a:Person)-[:FOLLOWS]->(b:Person) \
159    ///      RETURN a._gar_id AS src, b._gar_id AS dst",
160    ///     &[],
161    /// );
162    ///
163    /// // Edges with a string property.
164    /// registry.register_edge_query(
165    ///     "MATCH (a:Person)-[r:KNOWS]->(b:Person) \
166    ///      RETURN a._gar_id AS src, b._gar_id AS dst, r.since",
167    ///     &["since"],
168    /// );
169    ///
170    /// // Multiple edge properties.
171    /// registry.register_edge_query(
172    ///     "MATCH (a:Person)-[r:RATED]->(b:Movie) \
173    ///      RETURN a._gar_id AS src, b._gar_id AS dst, r.score, r.review",
174    ///     &["score", "review"],
175    /// );
176    /// ```
177    pub fn register_edge_query(&self, query: &str, extra_cols: &[&str]) -> &Self {
178        let mut fields = vec![
179            Field::new("src", DataType::Int64, false),
180            Field::new("dst", DataType::Int64, false),
181        ];
182        fields.extend(
183            extra_cols
184                .iter()
185                .map(|&n| Field::new(n, DataType::Utf8, true)),
186        );
187        self.register(query, Arc::new(Schema::new(fields)))
188    }
189
190    /// Register a query under a friendly **table name**.
191    ///
192    /// The schema is stored under `query` (so direct execution of the Cypher
193    /// still works) and `name` becomes an alias the Flight SQL metadata
194    /// surface advertises in `GetTables`. A generic ADBC/ODBC client can then
195    /// `SELECT * FROM <name>` and the server rewrites it to `query`.
196    ///
197    /// ```rust
198    /// use graphar_flight::SchemaRegistry;
199    /// use std::sync::Arc;
200    /// use arrow_schema::{DataType, Field, Schema};
201    ///
202    /// let registry = SchemaRegistry::new();
203    /// registry.register_named(
204    ///     "people",
205    ///     "MATCH (n:Person) RETURN n._gar_id AS _gar_id, n.name",
206    ///     Arc::new(Schema::new(vec![
207    ///         Field::new("_gar_id", DataType::Int64, false),
208    ///         Field::new("name",    DataType::Utf8,  true),
209    ///     ])),
210    /// );
211    /// assert_eq!(
212    ///     registry.resolve_table("people").as_deref(),
213    ///     Some("MATCH (n:Person) RETURN n._gar_id AS _gar_id, n.name"),
214    /// );
215    /// ```
216    pub fn register_named(
217        &self,
218        name: impl Into<String>,
219        query: impl Into<String>,
220        schema: SchemaRef,
221    ) -> &Self {
222        let query = query.into();
223        self.names
224            .write()
225            .unwrap()
226            .insert(name.into(), query.clone());
227        self.register(query, schema)
228    }
229
230    /// Look up a schema by exact key. Returns `None` for unregistered queries
231    /// (the server then falls back to auto-string mode).
232    pub fn get(&self, key: &str) -> Option<SchemaRef> {
233        self.inner.read().unwrap().get(key).cloned()
234    }
235
236    /// List all registered query keys.
237    pub fn keys(&self) -> Vec<String> {
238        self.inner.read().unwrap().keys().cloned().collect()
239    }
240
241    /// The friendly table name a Cypher query was registered under, if any.
242    /// The inverse of [`resolve_table`]: used by per-query authorization so a
243    /// policy can name a query by its short table name. Returns `None` for a
244    /// query registered without a name (the caller then keys on the Cypher).
245    ///
246    /// [`resolve_table`]: SchemaRegistry::resolve_table
247    pub fn name_of(&self, query: &str) -> Option<String> {
248        self.names
249            .read()
250            .unwrap()
251            .iter()
252            .find(|(_, q)| q.as_str() == query)
253            .map(|(name, _)| name.clone())
254    }
255
256    /// Resolve a `GetTables`-style identifier (a friendly name or the Cypher
257    /// key itself) to the Cypher query to execute. Returns `None` when neither
258    /// a name alias nor a registered query matches.
259    pub fn resolve_table(&self, ident: &str) -> Option<String> {
260        if let Some(query) = self.names.read().unwrap().get(ident) {
261            return Some(query.clone());
262        }
263        if self.inner.read().unwrap().contains_key(ident) {
264            return Some(ident.to_string());
265        }
266        None
267    }
268
269    /// Every registered query as `(table_name, schema)`, for the Flight SQL
270    /// `GetTables` surface. Named queries report their alias; queries
271    /// registered without a name report the Cypher string as the table name.
272    pub fn tables(&self) -> Vec<(String, SchemaRef)> {
273        let names = self.names.read().unwrap();
274        let query_to_name: HashMap<&str, &str> = names
275            .iter()
276            .map(|(n, q)| (q.as_str(), n.as_str()))
277            .collect();
278        self.inner
279            .read()
280            .unwrap()
281            .iter()
282            .map(|(query, schema)| {
283                let table = query_to_name
284                    .get(query.as_str())
285                    .map(|n| n.to_string())
286                    .unwrap_or_else(|| query.clone());
287                (table, schema.clone())
288            })
289            .collect()
290    }
291}
292
293/// Extract the table identifier from a trivial `SELECT … FROM <ident>` query,
294/// stripping surrounding quotes/backticks. Returns `None` for anything that
295/// isn't a single-table SELECT (the caller then treats the input as Cypher).
296///
297/// This is deliberately minimal — generic Flight SQL drivers (Power BI's ADBC
298/// connector, `adbc_driver_flightsql`) emit `SELECT * FROM "table"` to read a
299/// table they discovered through `GetTables`; that is the shape we rewrite.
300pub fn parse_select_from(sql: &str) -> Option<String> {
301    let lower = sql.to_ascii_lowercase();
302    let from = lower.find(" from ")?;
303    let after = sql[from + 6..].trim();
304    // Take the first whitespace/`;`-delimited token after FROM.
305    let token = after
306        .split(|c: char| c.is_whitespace() || c == ';')
307        .next()?
308        .trim();
309    if token.is_empty() {
310        return None;
311    }
312    let unquoted = token.trim_matches('"').trim_matches('`').trim_matches('\'');
313    Some(unquoted.to_string())
314}