1use std::io::BufRead;
7use std::num::NonZeroU32;
8use std::path::Path;
9
10use crate::directories::{Directory, DirectoryWriter, FsDirectory};
11use crate::dsl::{Document, Schema, SchemaBuilder, parse_single_index};
12use crate::error::{Error, Result};
13use crate::index::{IndexConfig, IndexWriter};
14
15#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
17pub struct SchemaFieldConfig {
18 pub name: String,
20 #[serde(rename = "type")]
22 pub field_type: String,
23 #[serde(default = "default_true")]
25 pub indexed: bool,
26 #[serde(default = "default_true")]
28 pub stored: bool,
29 #[serde(default)]
31 pub dimension: usize,
32 #[serde(default, alias = "primary", skip_serializing_if = "std::ops::Not::not")]
34 pub primary_key: bool,
35 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
37 pub content_hash: bool,
38}
39
40fn default_true() -> bool {
41 true
42}
43
44#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
46pub struct SchemaConfig {
47 pub fields: Vec<SchemaFieldConfig>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub max_l1_phrase_terms: Option<NonZeroU32>,
52}
53
54impl SchemaConfig {
55 pub fn build(&self) -> Result<Schema> {
57 let mut builder = SchemaBuilder::default();
58 if let Some(limit) = self.max_l1_phrase_terms {
59 builder.set_max_l1_phrase_terms(limit);
60 }
61
62 if self.fields.iter().filter(|field| field.primary_key).count() > 1 {
63 return Err(Error::Schema("at most one primary key is allowed".into()));
64 }
65 for field in &self.fields {
66 if field.primary_key && field.field_type != "text" {
67 return Err(Error::Schema("primary key must be text".into()));
68 }
69 let id = match field.field_type.as_str() {
70 "text" => builder.add_text_field(&field.name, field.indexed, field.stored),
71 "u64" => builder.add_u64_field(&field.name, field.indexed, field.stored),
72 "i64" => builder.add_i64_field(&field.name, field.indexed, field.stored),
73 "f64" => builder.add_f64_field(&field.name, field.indexed, field.stored),
74 "bytes" => builder.add_bytes_field(&field.name, field.stored),
75 "json" => builder.add_json_field(&field.name, field.stored),
76 "sparse_vector" => {
77 builder.add_sparse_vector_field(&field.name, field.indexed, field.stored)
78 }
79 "dense_vector" => builder.add_dense_vector_field(
80 &field.name,
81 field.dimension,
82 field.indexed,
83 field.stored,
84 ),
85 other => return Err(Error::Schema(format!("Unknown field type: {}", other))),
86 };
87 if field.primary_key {
88 builder.set_primary_key(id);
89 }
90 if field.content_hash {
91 builder.set_content_hash(id);
92 }
93 }
94
95 let schema = builder.build();
96 schema.validate()?;
97 Ok(schema)
98 }
99}
100
101pub fn parse_schema(content: &str) -> Result<Schema> {
103 let trimmed = content.trim();
104
105 if trimmed.starts_with("index ") || trimmed.starts_with('#') {
107 let index_def = parse_single_index(content)
108 .map_err(|e| Error::Schema(format!("Failed to parse SDL: {}", e)))?;
109 Ok(index_def.to_schema())
110 } else {
111 let config: SchemaConfig = serde_json::from_str(content)
113 .map_err(|e| Error::Schema(format!("Failed to parse JSON schema: {}", e)))?;
114 config.build()
115 }
116}
117
118pub async fn create_index_at_path(
120 path: impl AsRef<Path>,
121 schema: Schema,
122 config: IndexConfig,
123) -> Result<IndexWriter<FsDirectory>> {
124 let path = path.as_ref();
125
126 std::fs::create_dir_all(path).map_err(|e| {
127 Error::Io(std::io::Error::new(
128 e.kind(),
129 format!("Failed to create index directory {:?}: {}", path, e),
130 ))
131 })?;
132
133 let dir = FsDirectory::new(path);
134 IndexWriter::create(dir, schema, config).await
135}
136
137pub async fn create_index_from_sdl(
139 path: impl AsRef<Path>,
140 sdl: &str,
141 config: IndexConfig,
142) -> Result<IndexWriter<FsDirectory>> {
143 let schema = parse_schema(sdl)?;
144 create_index_at_path(path, schema, config).await
145}
146
147#[derive(Debug, Clone, Default)]
149pub struct IndexingStats {
150 pub indexed: usize,
152 pub errors: usize,
154 pub elapsed_secs: f64,
156}
157
158impl IndexingStats {
159 pub fn docs_per_sec(&self) -> f64 {
161 if self.elapsed_secs > 0.0 {
162 self.indexed as f64 / self.elapsed_secs
163 } else {
164 0.0
165 }
166 }
167}
168
169pub async fn index_documents_from_reader<D, R>(
174 writer: &mut IndexWriter<D>,
175 reader: R,
176 progress_callback: Option<&dyn Fn(usize)>,
177) -> Result<IndexingStats>
178where
179 D: Directory + DirectoryWriter,
180 R: BufRead,
181{
182 let schema = writer.schema();
183 let mut stats = IndexingStats::default();
184 let start_time = std::time::Instant::now();
185
186 for line in reader.lines() {
187 let line = line.map_err(Error::Io)?;
188 if line.trim().is_empty() {
189 continue;
190 }
191
192 let json: serde_json::Value = match serde_json::from_str(&line) {
193 Ok(v) => v,
194 Err(_) => {
195 stats.errors += 1;
196 continue;
197 }
198 };
199
200 let doc = match Document::from_json(&json, &schema) {
201 Some(d) => d,
202 None => {
203 stats.errors += 1;
204 continue;
205 }
206 };
207
208 writer.add_document(doc)?;
209 stats.indexed += 1;
210
211 if let Some(callback) = progress_callback {
212 callback(stats.indexed);
213 }
214 }
215
216 writer.commit().await?;
217 stats.elapsed_secs = start_time.elapsed().as_secs_f64();
218
219 Ok(stats)
220}
221
222pub async fn index_json_document<D>(writer: &IndexWriter<D>, json: &serde_json::Value) -> Result<()>
224where
225 D: Directory + DirectoryWriter,
226{
227 let schema = writer.schema();
228 let doc = Document::from_json(json, &schema)
229 .ok_or_else(|| Error::Document("Failed to parse JSON document".to_string()))?;
230 writer.add_document(doc)?;
231 Ok(())
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::directories::RamDirectory;
238
239 #[test]
240 fn test_schema_config_json() {
241 let json = r#"{
242 "fields": [
243 {"name": "title", "type": "text", "indexed": true, "stored": true},
244 {"name": "body", "type": "text"},
245 {"name": "score", "type": "f64", "indexed": false}
246 ]
247 }"#;
248
249 let config: SchemaConfig = serde_json::from_str(json).unwrap();
250 assert_eq!(config.fields.len(), 3);
251
252 let schema = config.build().unwrap();
253 assert!(schema.get_field("title").is_some());
254 assert!(schema.get_field("body").is_some());
255 assert!(schema.get_field("score").is_some());
256 }
257
258 #[test]
259 fn test_parse_schema_json() {
260 let json = r#"{"fields": [{"name": "text", "type": "text"}]}"#;
261 let schema = parse_schema(json).unwrap();
262 assert!(schema.get_field("text").is_some());
263 }
264
265 #[test]
266 fn json_schema_configures_content_hash_and_rejects_invalid_combinations() {
267 let json = r#"{"fields":[{"name":"id","type":"text","primary_key":true},{"name":"digest","type":"bytes","stored":true,"content_hash":true}]}"#;
268 let schema = parse_schema(json).unwrap();
269 assert_eq!(schema.primary_field(), schema.get_field("id"));
270 assert_eq!(schema.content_hash_field(), schema.get_field("digest"));
271 let config: SchemaConfig = serde_json::from_str(json).unwrap();
272 assert_eq!(
273 parse_schema(&serde_json::to_string(&config).unwrap())
274 .unwrap()
275 .content_hash_field(),
276 schema.content_hash_field()
277 );
278 assert!(
279 parse_schema(&json.replace("\"primary_key\":true", "\"primary_key\":false")).is_err()
280 );
281 assert!(parse_schema(&json.replace("\"stored\":true", "\"stored\":false")).is_err());
282 }
283
284 #[test]
285 fn test_parse_schema_sdl() {
286 let sdl = r#"
287 index test {
288 field text: text [indexed, stored]
289 }
290 "#;
291 let schema = parse_schema(sdl).unwrap();
292 assert!(schema.get_field("text").is_some());
293 }
294
295 #[test]
296 fn creation_schemas_preserve_positive_phrase_limits_and_reject_invalid_values() {
297 assert_eq!(Schema::default().max_l1_phrase_terms(), 64);
298 assert_eq!(SchemaBuilder::default().build().max_l1_phrase_terms(), 64);
299 for value in [None, Some(1), Some(64), Some(65), Some(300), Some(u32::MAX)] {
300 let option = value
301 .map(|value| format!("max_l1_phrase_terms: {value}"))
302 .unwrap_or_default();
303 let mut json = serde_json::json!({"fields": [{"name": "body", "type": "text"}]});
304 if let Some(value) = value {
305 json["max_l1_phrase_terms"] = value.into();
306 }
307 for input in [
308 format!("index documents {{ {option} field body: text }}"),
309 json.to_string(),
310 ] {
311 let schema = parse_schema(&input).unwrap();
312 assert_eq!(schema.max_l1_phrase_terms(), value.unwrap_or(64) as usize);
313 let serialized = serde_json::to_value(&schema).unwrap();
314 assert_eq!(
315 serialized.get("max_l1_phrase_terms").is_some(),
316 value.is_some()
317 );
318 let restored: Schema = serde_json::from_value(serialized).unwrap();
319 assert_eq!(restored.max_l1_phrase_terms(), schema.max_l1_phrase_terms());
320 }
321 }
322 for invalid in ["0", "-1", "1.5", "4294967296", "\"64\"", "true"] {
323 for input in [
324 format!("index documents {{ max_l1_phrase_terms: {invalid} field body: text }}"),
325 format!(r#"{{"max_l1_phrase_terms": {invalid}, "fields": []}}"#),
326 ] {
327 assert!(parse_schema(&input).is_err(), "accepted {input}");
328 }
329 }
330 assert!(
331 parse_schema("index documents { max_l1_phrase_terms: 64 max_l1_phrase_terms: 256 }")
332 .is_err()
333 );
334 }
335
336 #[tokio::test]
337 async fn test_index_documents_from_reader() {
338 let mut builder = SchemaBuilder::default();
339 let _title = builder.add_text_field("title", true, true);
340 let schema = builder.build();
341
342 let dir = RamDirectory::new();
343 let config = IndexConfig::default();
344 let mut writer = IndexWriter::create(dir, schema, config).await.unwrap();
345
346 let jsonl = r#"{"title": "Doc 1"}
347{"title": "Doc 2"}
348{"title": "Doc 3"}"#;
349
350 let reader = std::io::Cursor::new(jsonl);
351 let stats = index_documents_from_reader(&mut writer, reader, None)
352 .await
353 .unwrap();
354
355 assert_eq!(stats.indexed, 3);
356 assert_eq!(stats.errors, 0);
357 }
358}