1use std::collections::BTreeSet;
7
8use serde::Deserialize;
9use ssp2::segment::{Column, ColumnType};
10
11#[derive(Debug, Clone, Deserialize)]
12pub struct SchemaIr {
13 #[serde(alias = "schemaVersion")]
14 pub version: i32,
15 pub tables: Vec<TableIr>,
16}
17
18#[derive(Debug, Clone, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct TableIr {
21 pub name: String,
22 pub columns: Vec<ColumnIr>,
23 pub primary_key: String,
24 pub scopes: Vec<ScopePatternIr>,
25 #[serde(default)]
28 pub indexes: Vec<IndexIr>,
29 #[serde(default, rename = "ftsIndexes")]
31 pub fts_indexes: Vec<FtsIndexIr>,
32}
33
34#[derive(Debug, Clone, Deserialize)]
35pub struct ColumnIr {
36 pub name: String,
37 #[serde(rename = "type")]
38 pub column_type: String,
39 pub nullable: bool,
40 #[serde(default)]
43 pub encrypted: bool,
44 #[serde(default, rename = "declaredType")]
46 pub declared_type: Option<String>,
47}
48
49#[derive(Debug, Clone, Deserialize)]
51pub struct IndexIr {
52 pub name: String,
53 pub columns: Vec<String>,
54 pub unique: bool,
55}
56
57#[derive(Debug, Clone, Deserialize)]
59pub struct FtsIndexIr {
60 pub name: String,
61 pub columns: Vec<String>,
62 pub tokenize: String,
63}
64
65#[derive(Debug, Clone)]
67pub struct IndexSchema {
68 pub name: String,
69 pub columns: Vec<String>,
70 pub unique: bool,
71}
72
73#[derive(Debug, Clone)]
74pub struct FtsIndexSchema {
75 pub name: String,
76 pub columns: Vec<String>,
77 pub tokenize: String,
78}
79
80#[derive(Debug, Clone, Deserialize)]
81pub struct ScopePatternIr {
82 pub pattern: String,
83 #[serde(default)]
84 pub column: Option<String>,
85}
86
87#[derive(Debug, Clone)]
89pub struct ScopeVariable {
90 pub variable: String,
91 pub column: String,
92 pub prefix: String,
93}
94
95#[derive(Debug, Clone)]
99pub struct EncryptedColumn {
100 pub index: usize,
101 pub declared_type: String,
102}
103
104#[derive(Debug, Clone)]
105pub struct TableSchema {
106 pub name: String,
107 pub columns: Vec<Column>,
112 pub wire_columns: Vec<Column>,
117 pub primary_key: String,
118 pub pk_index: usize,
119 pub scope_variables: Vec<ScopeVariable>,
120 pub indexes: Vec<IndexSchema>,
122 pub fts_indexes: Vec<FtsIndexSchema>,
124 pub encrypted_columns: Vec<EncryptedColumn>,
126}
127
128impl TableSchema {
129 pub fn has_encrypted_columns(&self) -> bool {
131 !self.encrypted_columns.is_empty()
132 }
133}
134
135impl TableSchema {
136 pub fn scope_column(&self, variable: &str) -> Option<&str> {
139 self.scope_variables
140 .iter()
141 .find(|s| s.variable == variable)
142 .map(|s| s.column.as_str())
143 }
144}
145
146#[derive(Debug, Clone)]
147pub struct ClientSchema {
148 pub version: i32,
149 pub tables: Vec<TableSchema>,
150}
151
152impl ClientSchema {
153 pub fn table(&self, name: &str) -> Option<&TableSchema> {
154 self.tables.iter().find(|t| t.name == name)
155 }
156}
157
158fn parse_column_type(name: &str) -> Result<ColumnType, String> {
159 match name {
160 "string" => Ok(ColumnType::String),
161 "integer" => Ok(ColumnType::Integer),
162 "float" => Ok(ColumnType::Float),
163 "boolean" => Ok(ColumnType::Boolean),
164 "json" => Ok(ColumnType::Json),
165 "bytes" => Ok(ColumnType::Bytes),
166 "blob_ref" => Ok(ColumnType::BlobRef),
167 "crdt" => Ok(ColumnType::Crdt),
168 other => Err(format!("unknown column type {other:?}")),
169 }
170}
171
172fn parse_pattern_variable(pattern: &str) -> Result<String, String> {
174 let open = pattern
175 .find('{')
176 .ok_or_else(|| format!("scope pattern {pattern:?} has no {{variable}}"))?;
177 let close = pattern
178 .rfind('}')
179 .filter(|end| *end > open)
180 .ok_or_else(|| format!("scope pattern {pattern:?} has no closing brace"))?;
181 let variable = &pattern[open + 1..close];
182 if variable.is_empty() {
183 return Err(format!("scope pattern {pattern:?} has an empty variable"));
184 }
185 Ok(variable.to_owned())
186}
187
188pub fn compile_schema(ir: &SchemaIr) -> Result<ClientSchema, String> {
189 if ir.version < 1 {
194 return Err(
195 "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
196 .to_owned(),
197 );
198 }
199 let mut tables = Vec::with_capacity(ir.tables.len());
200 let mut schema_object_names = BTreeSet::new();
201 for table in &ir.tables {
202 if !schema_object_names.insert(table.name.clone()) {
203 return Err(format!("duplicate table or schema object {:?}", table.name));
204 }
205 }
206 for table in &ir.tables {
207 let mut wire_columns = Vec::with_capacity(table.columns.len());
211 let mut columns = Vec::with_capacity(table.columns.len());
212 let mut encrypted_columns = Vec::new();
213 for (index, column) in table.columns.iter().enumerate() {
214 let wire_ty = parse_column_type(&column.column_type)?;
215 wire_columns.push(Column {
216 name: column.name.clone(),
217 ty: wire_ty,
218 nullable: column.nullable,
219 });
220 if column.encrypted {
221 let declared_name = column.declared_type.clone().ok_or_else(|| {
222 format!(
223 "table {:?}: encrypted column {:?} has no declaredType (§5.11)",
224 table.name, column.name
225 )
226 })?;
227 let declared_ty = parse_column_type(&declared_name)?;
228 columns.push(Column {
229 name: column.name.clone(),
230 ty: declared_ty,
231 nullable: column.nullable,
232 });
233 encrypted_columns.push(EncryptedColumn {
234 index,
235 declared_type: declared_name,
236 });
237 } else {
238 columns.push(Column {
239 name: column.name.clone(),
240 ty: wire_ty,
241 nullable: column.nullable,
242 });
243 }
244 }
245 let pk_index = columns
246 .iter()
247 .position(|c| c.name == table.primary_key)
248 .ok_or_else(|| {
249 format!(
250 "table {:?}: primary key {:?} is not a column",
251 table.name, table.primary_key
252 )
253 })?;
254 if !matches!(
258 columns[pk_index].ty,
259 ColumnType::String | ColumnType::Integer | ColumnType::Boolean | ColumnType::Json
260 ) {
261 return Err(format!(
262 "table {:?}: primary key {:?} has an ineligible column type (§2.4); a primary key must be string, integer, boolean, or json",
263 table.name, table.primary_key
264 ));
265 }
266 let mut scope_variables = Vec::with_capacity(table.scopes.len());
267 for scope in &table.scopes {
268 let variable = parse_pattern_variable(&scope.pattern)?;
269 let column = scope.column.clone().unwrap_or_else(|| variable.clone());
270 if !columns.iter().any(|c| {
271 c.name == column && matches!(c.ty, ColumnType::String | ColumnType::Integer)
272 }) {
273 return Err(format!(
274 "table {:?}: scope column {column:?} must be a string or integer column (§3.1)",
275 table.name
276 ));
277 }
278 let open = scope.pattern.find('{').expect("validated scope pattern");
279 let prefix = scope.pattern[..open]
280 .strip_suffix(':')
281 .unwrap_or(&scope.pattern[..open])
282 .to_owned();
283 scope_variables.push(ScopeVariable {
284 variable,
285 column,
286 prefix,
287 });
288 }
289 let mut indexes = Vec::with_capacity(table.indexes.len());
290 for index in &table.indexes {
291 if !schema_object_names.insert(index.name.clone()) {
292 return Err(format!(
293 "table {:?}: index {:?} conflicts with another schema object",
294 table.name, index.name
295 ));
296 }
297 for col in &index.columns {
298 if !columns.iter().any(|c| &c.name == col) {
299 return Err(format!(
300 "table {:?}: index {:?} names unknown column {col:?}",
301 table.name, index.name
302 ));
303 }
304 }
305 indexes.push(IndexSchema {
306 name: index.name.clone(),
307 columns: index.columns.clone(),
308 unique: index.unique,
309 });
310 }
311 let mut fts_indexes = Vec::with_capacity(table.fts_indexes.len());
312 for index in &table.fts_indexes {
313 if !schema_object_names.insert(index.name.clone()) {
314 return Err(format!(
315 "table {:?}: FTS projection {:?} conflicts with another schema object",
316 table.name, index.name
317 ));
318 }
319 if index.columns.is_empty() || index.columns.len() > 32 {
320 return Err(format!(
321 "table {:?}: FTS projection {:?} needs between 1 and 32 columns",
322 table.name, index.name
323 ));
324 }
325 let mut seen_columns = BTreeSet::new();
326 for column_name in &index.columns {
327 if !seen_columns.insert(column_name) {
328 return Err(format!(
329 "table {:?}: FTS projection {:?} repeats column {:?}",
330 table.name, index.name, column_name
331 ));
332 }
333 let Some((_column_index, column)) = columns
334 .iter()
335 .enumerate()
336 .find(|(_, candidate)| &candidate.name == column_name)
337 else {
338 return Err(format!(
339 "table {:?}: FTS projection {:?} names unknown column {:?}",
340 table.name, index.name, column_name
341 ));
342 };
343 if !matches!(column.ty, ColumnType::String) {
344 return Err(format!(
345 "table {:?}: FTS projection {:?} column {:?} must have string type",
346 table.name, index.name, column_name
347 ));
348 }
349 }
350 if !matches!(
351 index.tokenize.as_str(),
352 "unicode61"
353 | "unicode61 remove_diacritics 0"
354 | "unicode61 remove_diacritics 1"
355 | "unicode61 remove_diacritics 2"
356 | "porter unicode61"
357 | "trigram"
358 ) {
359 return Err(format!(
360 "table {:?}: FTS projection {:?} tokenizer {:?} is not allowlisted",
361 table.name, index.name, index.tokenize
362 ));
363 }
364 fts_indexes.push(FtsIndexSchema {
365 name: index.name.clone(),
366 columns: index.columns.clone(),
367 tokenize: index.tokenize.clone(),
368 });
369 }
370 tables.push(TableSchema {
371 name: table.name.clone(),
372 columns,
373 wire_columns,
374 primary_key: table.primary_key.clone(),
375 pk_index,
376 scope_variables,
377 indexes,
378 fts_indexes,
379 encrypted_columns,
380 });
381 }
382 Ok(ClientSchema {
383 version: ir.version,
384 tables,
385 })
386}
387
388pub fn parse_schema_json(json: &serde_json::Value) -> Result<ClientSchema, String> {
389 let version = json
395 .get("version")
396 .or_else(|| json.get("schemaVersion"))
397 .and_then(serde_json::Value::as_i64);
398 if !version.is_some_and(|version| (1..=i32::MAX as i64).contains(&version)) {
399 return Err(
400 "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
401 .to_owned(),
402 );
403 }
404 let ir: SchemaIr =
405 serde_json::from_value(json.clone()).map_err(|e| format!("bad schema IR: {e}"))?;
406 compile_schema(&ir)
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use serde_json::json;
413
414 fn schema_with_fts(column: serde_json::Value, tokenize: &str) -> serde_json::Value {
415 json!({
416 "version": 1,
417 "tables": [{
418 "name": "docs",
419 "primaryKey": "id",
420 "columns": [
421 { "name": "id", "type": "string", "nullable": false },
422 { "name": "org_id", "type": "string", "nullable": false },
423 column
424 ],
425 "scopes": [{ "pattern": "org:{org_id}" }],
426 "ftsIndexes": [{
427 "name": "docs_fts",
428 "columns": ["body"],
429 "tokenize": tokenize
430 }]
431 }]
432 })
433 }
434
435 #[test]
436 fn accepts_the_schema_version_alias_and_refuses_conflicting_spellings() {
437 let schema = json!({
438 "schemaVersion": 2,
439 "tables": [{
440 "name": "tasks",
441 "primaryKey": "id",
442 "columns": [{ "name": "id", "type": "string", "nullable": false }],
443 "scopes": []
444 }]
445 });
446 let compiled = parse_schema_json(&schema).expect("the server IR alias compiles");
447 assert_eq!(compiled.version, 2);
448 let mut both = schema.clone();
451 both["version"] = json!(3);
452 let error = parse_schema_json(&both).expect_err("duplicate version spellings");
453 assert!(error.starts_with("bad schema IR:"), "{error}");
454 let mut out_of_range = schema.clone();
456 out_of_range["schemaVersion"] = json!(0);
457 let error = parse_schema_json(&out_of_range).expect_err("out-of-range alias");
458 assert!(error.starts_with("sync.invalid_request:"), "{error}");
459 }
460
461 #[test]
462 fn compiles_an_allowlisted_string_fts_projection() {
463 let schema = parse_schema_json(&schema_with_fts(
464 json!({ "name": "body", "type": "string", "nullable": false }),
465 "unicode61 remove_diacritics 2",
466 ))
467 .expect("valid FTS schema");
468 assert_eq!(schema.tables[0].fts_indexes[0].name, "docs_fts");
469 }
470
471 #[test]
472 fn accepts_encrypted_declared_strings_and_rejects_other_fts_definitions() {
473 let encrypted = schema_with_fts(
474 json!({
475 "name": "body", "type": "bytes", "nullable": false,
476 "encrypted": true, "declaredType": "string"
477 }),
478 "unicode61",
479 );
480 parse_schema_json(&encrypted).expect("local plaintext FTS accepts encrypted strings");
481
482 let non_string = schema_with_fts(
483 json!({ "name": "body", "type": "integer", "nullable": false }),
484 "unicode61",
485 );
486 assert!(parse_schema_json(&non_string)
487 .expect_err("non-string FTS must fail")
488 .contains("must have string type"));
489
490 let tokenizer = schema_with_fts(
491 json!({ "name": "body", "type": "string", "nullable": false }),
492 "custom",
493 );
494 assert!(parse_schema_json(&tokenizer)
495 .expect_err("custom tokenizer must fail")
496 .contains("not allowlisted"));
497 }
498}