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}