1use std::collections::BTreeSet;
7
8use serde::Deserialize;
9use ssp2::segment::{Column, ColumnType};
10
11#[derive(Debug, Clone, Deserialize)]
12pub struct SchemaIr {
13 pub version: i32,
14 pub tables: Vec<TableIr>,
15}
16
17#[derive(Debug, Clone, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct TableIr {
20 pub name: String,
21 pub columns: Vec<ColumnIr>,
22 pub primary_key: String,
23 pub scopes: Vec<ScopePatternIr>,
24 #[serde(default)]
27 pub indexes: Vec<IndexIr>,
28 #[serde(default, rename = "ftsIndexes")]
30 pub fts_indexes: Vec<FtsIndexIr>,
31}
32
33#[derive(Debug, Clone, Deserialize)]
34pub struct ColumnIr {
35 pub name: String,
36 #[serde(rename = "type")]
37 pub column_type: String,
38 pub nullable: bool,
39 #[serde(default)]
42 pub encrypted: bool,
43 #[serde(default, rename = "declaredType")]
45 pub declared_type: Option<String>,
46}
47
48#[derive(Debug, Clone, Deserialize)]
50pub struct IndexIr {
51 pub name: String,
52 pub columns: Vec<String>,
53 pub unique: bool,
54}
55
56#[derive(Debug, Clone, Deserialize)]
58pub struct FtsIndexIr {
59 pub name: String,
60 pub columns: Vec<String>,
61 pub tokenize: String,
62}
63
64#[derive(Debug, Clone)]
66pub struct IndexSchema {
67 pub name: String,
68 pub columns: Vec<String>,
69 pub unique: bool,
70}
71
72#[derive(Debug, Clone)]
73pub struct FtsIndexSchema {
74 pub name: String,
75 pub columns: Vec<String>,
76 pub tokenize: String,
77}
78
79#[derive(Debug, Clone, Deserialize)]
80pub struct ScopePatternIr {
81 pub pattern: String,
82 #[serde(default)]
83 pub column: Option<String>,
84}
85
86#[derive(Debug, Clone)]
88pub struct ScopeVariable {
89 pub variable: String,
90 pub column: String,
91 pub prefix: String,
92}
93
94#[derive(Debug, Clone)]
98pub struct EncryptedColumn {
99 pub index: usize,
100 pub declared_type: String,
101}
102
103#[derive(Debug, Clone)]
104pub struct TableSchema {
105 pub name: String,
106 pub columns: Vec<Column>,
111 pub wire_columns: Vec<Column>,
116 pub primary_key: String,
117 pub pk_index: usize,
118 pub scope_variables: Vec<ScopeVariable>,
119 pub indexes: Vec<IndexSchema>,
121 pub fts_indexes: Vec<FtsIndexSchema>,
123 pub encrypted_columns: Vec<EncryptedColumn>,
125}
126
127impl TableSchema {
128 pub fn has_encrypted_columns(&self) -> bool {
130 !self.encrypted_columns.is_empty()
131 }
132}
133
134impl TableSchema {
135 pub fn scope_column(&self, variable: &str) -> Option<&str> {
138 self.scope_variables
139 .iter()
140 .find(|s| s.variable == variable)
141 .map(|s| s.column.as_str())
142 }
143}
144
145#[derive(Debug, Clone)]
146pub struct ClientSchema {
147 pub version: i32,
148 pub tables: Vec<TableSchema>,
149}
150
151impl ClientSchema {
152 pub fn table(&self, name: &str) -> Option<&TableSchema> {
153 self.tables.iter().find(|t| t.name == name)
154 }
155}
156
157fn parse_column_type(name: &str) -> Result<ColumnType, String> {
158 match name {
159 "string" => Ok(ColumnType::String),
160 "integer" => Ok(ColumnType::Integer),
161 "float" => Ok(ColumnType::Float),
162 "boolean" => Ok(ColumnType::Boolean),
163 "json" => Ok(ColumnType::Json),
164 "bytes" => Ok(ColumnType::Bytes),
165 "blob_ref" => Ok(ColumnType::BlobRef),
166 "crdt" => Ok(ColumnType::Crdt),
167 other => Err(format!("unknown column type {other:?}")),
168 }
169}
170
171fn parse_pattern_variable(pattern: &str) -> Result<String, String> {
173 let open = pattern
174 .find('{')
175 .ok_or_else(|| format!("scope pattern {pattern:?} has no {{variable}}"))?;
176 let close = pattern
177 .rfind('}')
178 .filter(|end| *end > open)
179 .ok_or_else(|| format!("scope pattern {pattern:?} has no closing brace"))?;
180 let variable = &pattern[open + 1..close];
181 if variable.is_empty() {
182 return Err(format!("scope pattern {pattern:?} has an empty variable"));
183 }
184 Ok(variable.to_owned())
185}
186
187pub fn compile_schema(ir: &SchemaIr) -> Result<ClientSchema, String> {
188 let mut tables = Vec::with_capacity(ir.tables.len());
189 let mut schema_object_names = BTreeSet::new();
190 for table in &ir.tables {
191 if !schema_object_names.insert(table.name.clone()) {
192 return Err(format!("duplicate table or schema object {:?}", table.name));
193 }
194 }
195 for table in &ir.tables {
196 let mut wire_columns = Vec::with_capacity(table.columns.len());
200 let mut columns = Vec::with_capacity(table.columns.len());
201 let mut encrypted_columns = Vec::new();
202 for (index, column) in table.columns.iter().enumerate() {
203 let wire_ty = parse_column_type(&column.column_type)?;
204 wire_columns.push(Column {
205 name: column.name.clone(),
206 ty: wire_ty,
207 nullable: column.nullable,
208 });
209 if column.encrypted {
210 let declared_name = column.declared_type.clone().ok_or_else(|| {
211 format!(
212 "table {:?}: encrypted column {:?} has no declaredType (§5.11)",
213 table.name, column.name
214 )
215 })?;
216 let declared_ty = parse_column_type(&declared_name)?;
217 columns.push(Column {
218 name: column.name.clone(),
219 ty: declared_ty,
220 nullable: column.nullable,
221 });
222 encrypted_columns.push(EncryptedColumn {
223 index,
224 declared_type: declared_name,
225 });
226 } else {
227 columns.push(Column {
228 name: column.name.clone(),
229 ty: wire_ty,
230 nullable: column.nullable,
231 });
232 }
233 }
234 let pk_index = columns
235 .iter()
236 .position(|c| c.name == table.primary_key)
237 .ok_or_else(|| {
238 format!(
239 "table {:?}: primary key {:?} is not a column",
240 table.name, table.primary_key
241 )
242 })?;
243 let mut scope_variables = Vec::with_capacity(table.scopes.len());
244 for scope in &table.scopes {
245 let variable = parse_pattern_variable(&scope.pattern)?;
246 let column = scope.column.clone().unwrap_or_else(|| variable.clone());
247 let open = scope.pattern.find('{').expect("validated scope pattern");
248 let prefix = scope.pattern[..open]
249 .strip_suffix(':')
250 .unwrap_or(&scope.pattern[..open])
251 .to_owned();
252 scope_variables.push(ScopeVariable {
253 variable,
254 column,
255 prefix,
256 });
257 }
258 let mut indexes = Vec::with_capacity(table.indexes.len());
259 for index in &table.indexes {
260 if !schema_object_names.insert(index.name.clone()) {
261 return Err(format!(
262 "table {:?}: index {:?} conflicts with another schema object",
263 table.name, index.name
264 ));
265 }
266 for col in &index.columns {
267 if !columns.iter().any(|c| &c.name == col) {
268 return Err(format!(
269 "table {:?}: index {:?} names unknown column {col:?}",
270 table.name, index.name
271 ));
272 }
273 }
274 indexes.push(IndexSchema {
275 name: index.name.clone(),
276 columns: index.columns.clone(),
277 unique: index.unique,
278 });
279 }
280 let mut fts_indexes = Vec::with_capacity(table.fts_indexes.len());
281 for index in &table.fts_indexes {
282 if !schema_object_names.insert(index.name.clone()) {
283 return Err(format!(
284 "table {:?}: FTS projection {:?} conflicts with another schema object",
285 table.name, index.name
286 ));
287 }
288 if index.columns.is_empty() || index.columns.len() > 32 {
289 return Err(format!(
290 "table {:?}: FTS projection {:?} needs between 1 and 32 columns",
291 table.name, index.name
292 ));
293 }
294 let mut seen_columns = BTreeSet::new();
295 for column_name in &index.columns {
296 if !seen_columns.insert(column_name) {
297 return Err(format!(
298 "table {:?}: FTS projection {:?} repeats column {:?}",
299 table.name, index.name, column_name
300 ));
301 }
302 let Some((column_index, column)) = columns
303 .iter()
304 .enumerate()
305 .find(|(_, candidate)| &candidate.name == column_name)
306 else {
307 return Err(format!(
308 "table {:?}: FTS projection {:?} names unknown column {:?}",
309 table.name, index.name, column_name
310 ));
311 };
312 if !matches!(column.ty, ColumnType::String) {
313 return Err(format!(
314 "table {:?}: FTS projection {:?} column {:?} must have string type",
315 table.name, index.name, column_name
316 ));
317 }
318 if encrypted_columns
319 .iter()
320 .any(|encrypted| encrypted.index == column_index)
321 {
322 return Err(format!(
323 "table {:?}: FTS projection {:?} cannot index encrypted column {:?}",
324 table.name, index.name, column_name
325 ));
326 }
327 }
328 if !matches!(
329 index.tokenize.as_str(),
330 "unicode61"
331 | "unicode61 remove_diacritics 0"
332 | "unicode61 remove_diacritics 1"
333 | "unicode61 remove_diacritics 2"
334 | "porter unicode61"
335 | "trigram"
336 ) {
337 return Err(format!(
338 "table {:?}: FTS projection {:?} tokenizer {:?} is not allowlisted",
339 table.name, index.name, index.tokenize
340 ));
341 }
342 fts_indexes.push(FtsIndexSchema {
343 name: index.name.clone(),
344 columns: index.columns.clone(),
345 tokenize: index.tokenize.clone(),
346 });
347 }
348 tables.push(TableSchema {
349 name: table.name.clone(),
350 columns,
351 wire_columns,
352 primary_key: table.primary_key.clone(),
353 pk_index,
354 scope_variables,
355 indexes,
356 fts_indexes,
357 encrypted_columns,
358 });
359 }
360 Ok(ClientSchema {
361 version: ir.version,
362 tables,
363 })
364}
365
366pub fn parse_schema_json(json: &serde_json::Value) -> Result<ClientSchema, String> {
367 let ir: SchemaIr =
368 serde_json::from_value(json.clone()).map_err(|e| format!("bad schema IR: {e}"))?;
369 compile_schema(&ir)
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use serde_json::json;
376
377 fn schema_with_fts(column: serde_json::Value, tokenize: &str) -> serde_json::Value {
378 json!({
379 "version": 1,
380 "tables": [{
381 "name": "docs",
382 "primaryKey": "id",
383 "columns": [
384 { "name": "id", "type": "string", "nullable": false },
385 { "name": "org_id", "type": "string", "nullable": false },
386 column
387 ],
388 "scopes": [{ "pattern": "org:{org_id}" }],
389 "ftsIndexes": [{
390 "name": "docs_fts",
391 "columns": ["body"],
392 "tokenize": tokenize
393 }]
394 }]
395 })
396 }
397
398 #[test]
399 fn compiles_an_allowlisted_string_fts_projection() {
400 let schema = parse_schema_json(&schema_with_fts(
401 json!({ "name": "body", "type": "string", "nullable": false }),
402 "unicode61 remove_diacritics 2",
403 ))
404 .expect("valid FTS schema");
405 assert_eq!(schema.tables[0].fts_indexes[0].name, "docs_fts");
406 }
407
408 #[test]
409 fn rejects_encrypted_non_string_and_unknown_tokenizer_fts_definitions() {
410 let encrypted = schema_with_fts(
411 json!({
412 "name": "body", "type": "bytes", "nullable": false,
413 "encrypted": true, "declaredType": "string"
414 }),
415 "unicode61",
416 );
417 assert!(parse_schema_json(&encrypted)
418 .expect_err("encrypted FTS must fail")
419 .contains("cannot index encrypted"));
420
421 let non_string = schema_with_fts(
422 json!({ "name": "body", "type": "integer", "nullable": false }),
423 "unicode61",
424 );
425 assert!(parse_schema_json(&non_string)
426 .expect_err("non-string FTS must fail")
427 .contains("must have string type"));
428
429 let tokenizer = schema_with_fts(
430 json!({ "name": "body", "type": "string", "nullable": false }),
431 "custom",
432 );
433 assert!(parse_schema_json(&tokenizer)
434 .expect_err("custom tokenizer must fail")
435 .contains("not allowlisted"));
436 }
437}