issuecraft_redb/
lib.rs

1use std::{fmt::Display, path::PathBuf};
2
3use async_trait::async_trait;
4use facet::Facet;
5use facet_pretty::FacetPretty;
6use facet_value::{Value, from_value};
7use issuecraft_core::{CommentInfo, IssueInfo, IssueStatus, Priority, ProjectInfo};
8use issuecraft_ql::{
9    CloseStatement, CommentId, CommentStatement, EntityType, ExecutionEngine, ExecutionResult,
10    FieldUpdate, IdHelper, IqlError, IqlQuery, IssueId, ProjectId, ReopenStatement,
11    SelectStatement, UpdateStatement, UserId,
12};
13use nanoid::nanoid;
14use redb::{
15    DatabaseError, ReadableDatabase, ReadableTable, TableDefinition, TableHandle,
16    backends::InMemoryBackend,
17};
18
19const REDB_DEFAULT_USER: &str = "redb_local";
20
21const TABLE_META: TableDefinition<&str, String> = TableDefinition::new("meta");
22const TABLE_PROJECTS: TableDefinition<&str, String> = TableDefinition::new("projects");
23const TABLE_ISSUES: TableDefinition<&str, String> = TableDefinition::new("issues");
24const TABLE_COMMENTS: TableDefinition<&str, String> = TableDefinition::new("comments");
25
26pub struct Database {
27    db: redb::Database,
28}
29
30pub enum DatabaseType {
31    InMemory,
32    File(PathBuf),
33}
34
35#[derive(Facet)]
36struct Entry<K, V> {
37    pub key: K,
38    pub value: V,
39}
40
41fn get_table<'a>(kind: EntityType) -> TableDefinition<'a, &'a str, String> {
42    match kind {
43        EntityType::Users => TABLE_META,
44        EntityType::Projects => TABLE_PROJECTS,
45        EntityType::Issues => TABLE_ISSUES,
46        EntityType::Comments => TABLE_COMMENTS,
47    }
48}
49
50impl Database {
51    pub fn new(typ: &DatabaseType) -> Result<Self, DatabaseError> {
52        match typ {
53            DatabaseType::InMemory => {
54                let db = redb::Database::builder().create_with_backend(InMemoryBackend::new())?;
55                Ok(Self { db })
56            }
57            DatabaseType::File(path) => {
58                let db = redb::Database::create(path)?;
59                Ok(Self { db })
60            }
61        }
62    }
63
64    fn table_exists(&self, table_name: &str) -> Result<bool, IqlError> {
65        let read_txn = self.db.begin_read().map_err(to_iql_error)?;
66        Ok(read_txn
67            .list_tables()
68            .map_err(to_iql_error)?
69            .any(|table| table.name() == table_name))
70    }
71
72    fn exists(&self, kind: EntityType, key: &str) -> Result<bool, IqlError> {
73        let read_txn = self.db.begin_read().map_err(to_iql_error)?;
74        {
75            let table_definition = get_table(kind);
76            if !self.table_exists(table_definition.name())? {
77                return Ok(false);
78            }
79            let table = read_txn
80                .open_table(table_definition)
81                .map_err(to_iql_error)?;
82            Ok(table
83                .iter()
84                .map_err(to_iql_error)?
85                .any(|entry| match entry {
86                    Ok(e) => e.0.value() == key,
87                    Err(_) => false,
88                }))
89        }
90    }
91
92    fn get_next_issue_id(&self, project: &str) -> Result<u32, IqlError> {
93        if !self.table_exists(TABLE_ISSUES.name())? {
94            return Ok(1);
95        }
96        let read_txn = self.db.begin_read().map_err(to_iql_error)?;
97        let min = format!("{project}#");
98        let max = format!("{project}#{}", u32::MAX);
99        let next = read_txn
100            .open_table(TABLE_ISSUES)
101            .map_err(to_iql_error)?
102            .range(min.as_str()..max.as_str())
103            .map_err(to_iql_error)?
104            .count()
105            + 1;
106        Ok(next as u32)
107    }
108
109    fn update<'a, S: Facet<'a>>(
110        &mut self,
111        kind: EntityType,
112        id: &str,
113        updates: &[FieldUpdate],
114    ) -> Result<(), IqlError> {
115        let mut item_info: Value = self.get(kind, id)?;
116        for update in updates {
117            update.apply_to::<S>(&mut item_info)?;
118        }
119        self.set(kind, id, &item_info)?;
120        Ok(())
121    }
122
123    fn set<V: Facet<'static>>(
124        &mut self,
125        kind: EntityType,
126        id: &str,
127        info: &V,
128    ) -> Result<(), IqlError> {
129        let write_txn = self.db.begin_write().map_err(to_iql_error)?;
130        {
131            let table_definition = get_table(kind);
132            let mut table = write_txn
133                .open_table(table_definition)
134                .map_err(to_iql_error)?;
135            let info_str = facet_json::to_string(info).map_err(to_iql_error)?;
136            table.insert(id, &info_str).map_err(to_iql_error)?;
137        }
138        write_txn.commit().map_err(to_iql_error)
139    }
140
141    fn get_all<K: IdHelper, V: Facet<'static>>(
142        &self,
143        SelectStatement {
144            columns: _,
145            from,
146            filter,
147            order_by,
148            limit,
149            offset,
150        }: &SelectStatement,
151    ) -> Result<Vec<Entry<K, V>>, IqlError> {
152        let read_txn = self.db.begin_read().map_err(to_iql_error)?;
153        {
154            let table_definition = get_table(*from);
155            if !read_txn
156                .list_tables()
157                .unwrap()
158                .any(|table| table.name() == table_definition.name())
159            {
160                return Ok(vec![]);
161            }
162            let table = read_txn
163                .open_table(table_definition)
164                .map_err(to_iql_error)?;
165            let mut values = table
166                .iter()
167                .map_err(to_iql_error)?
168                .map(|entry| {
169                    entry.map_err(to_iql_error).map(|entry| {
170                        facet_json::from_str::<Value>(&entry.1.value())
171                            .map(|v| (K::id_from_str(entry.0.value()), v))
172                    })
173                })
174                .skip(offset.unwrap_or(0) as usize)
175                .take(limit.unwrap_or(u32::MAX) as usize)
176                .collect::<Result<Result<Vec<_>, _>, _>>()?
177                .map_err(to_iql_error)?;
178            if let Some(order_by) = order_by {
179                values.sort_by(|a, b| {
180                    let o1 = a.1.as_object().unwrap();
181                    let o2 = b.1.as_object().unwrap();
182                    match (
183                        o1.get(&order_by.field.clone()),
184                        o2.get(&order_by.field.to_owned()),
185                    ) {
186                        (None, None) => std::cmp::Ordering::Equal,
187                        (Some(_), None) => std::cmp::Ordering::Greater,
188                        (None, Some(_)) => std::cmp::Ordering::Less,
189                        (Some(v1), Some(v2)) => v1.partial_cmp(v2).unwrap(),
190                    }
191                });
192            }
193
194            values
195                .into_iter()
196                .filter(|(k, v)| match filter {
197                    None => true,
198                    Some(filter_expr) => filter_expr.matches(k.str_from_id(), v),
199                })
200                .map(|(k, v)| {
201                    from_value::<V>(v)
202                        .map_err(to_iql_error)
203                        .map(|v| Entry { key: k, value: v })
204                })
205                .collect::<Result<Vec<_>, _>>()
206        }
207    }
208
209    fn get<T: Facet<'static>>(&self, kind: EntityType, key: &str) -> Result<T, IqlError> {
210        let read_txn = self.db.begin_read().map_err(to_iql_error)?;
211        {
212            let table_definition = get_table(kind);
213            let table = read_txn
214                .open_table(table_definition)
215                .map_err(to_iql_error)?;
216            let info = table
217                .get(key)
218                .map_err(to_iql_error)?
219                .ok_or_else(|| IqlError::ItemNotFound {
220                    id: key.to_string(),
221                    kind: kind.kind(),
222                })?
223                .value();
224            facet_json::from_str(&info).map_err(to_iql_error)
225        }
226    }
227}
228
229fn stringify<'a, T: Facet<'a>>(value: &'a T) -> String {
230    let value: Value = facet_json::from_str(&facet_json::to_string(value).unwrap()).unwrap();
231    format!("{}", value.pretty())
232}
233
234fn to_iql_error<E: Display>(err: E) -> IqlError {
235    IqlError::ImplementationSpecific(format!("{err}"))
236}
237
238#[async_trait]
239impl ExecutionEngine for Database {
240    async fn execute(&mut self, query: &IqlQuery) -> Result<ExecutionResult, IqlError> {
241        match query {
242            issuecraft_ql::IqlQuery::Select(select_statement) => {
243                let info = match select_statement.from {
244                    issuecraft_ql::EntityType::Users => return Err(IqlError::NotSupported),
245                    issuecraft_ql::EntityType::Projects => {
246                        stringify(&self.get_all::<ProjectId, ProjectInfo>(&select_statement)?)
247                    }
248                    issuecraft_ql::EntityType::Issues => {
249                        stringify(&self.get_all::<IssueId, IssueInfo>(&select_statement)?)
250                    }
251                    issuecraft_ql::EntityType::Comments => {
252                        stringify(&self.get_all::<CommentId, CommentInfo>(&select_statement)?)
253                    }
254                };
255                Ok(ExecutionResult::zero().with_info(&info))
256            }
257            issuecraft_ql::IqlQuery::Create(create_statement) => match create_statement {
258                issuecraft_ql::CreateStatement::User { .. } => Err(IqlError::NotSupported),
259                issuecraft_ql::CreateStatement::Project {
260                    project_id,
261                    name,
262                    description,
263                    owner: _,
264                } => {
265                    if self.exists(EntityType::Projects, &project_id)? {
266                        return Err(IqlError::ProjectAlreadyExists(project_id.clone()));
267                    }
268                    let project_info = ProjectInfo {
269                        owner: UserId(REDB_DEFAULT_USER.to_string()),
270                        description: description.clone(),
271                        display: name.clone(),
272                    };
273                    self.set(EntityType::Projects, &project_id, &project_info)?;
274                    Ok(ExecutionResult::one())
275                }
276                issuecraft_ql::CreateStatement::Issue {
277                    project,
278                    kind,
279                    title,
280                    description,
281                    priority,
282                    assignee,
283                } => {
284                    if !self.exists(EntityType::Projects, &project)? {
285                        return Err(IqlError::ItemNotFound {
286                            kind: EntityType::Projects.kind(),
287                            id: project.to_string(),
288                        });
289                    }
290                    let issue_number = self.get_next_issue_id(&project)?;
291                    let issue_info = IssueInfo {
292                        title: title.clone(),
293                        kind: kind.clone(),
294                        description: description.clone(),
295                        status: IssueStatus::Open,
296                        project: ProjectId(project.clone()),
297                        assignee: assignee
298                            .clone()
299                            .or(Some(UserId(REDB_DEFAULT_USER.to_string()))),
300                        priority: priority.clone().map(|p| match p {
301                            issuecraft_ql::Priority::Critical => Priority::Critical,
302                            issuecraft_ql::Priority::High => Priority::High,
303                            issuecraft_ql::Priority::Medium => Priority::Medium,
304                            issuecraft_ql::Priority::Low => Priority::Low,
305                        }),
306                    };
307                    self.set(
308                        EntityType::Issues,
309                        &format!("{project}#{issue_number}"),
310                        &issue_info,
311                    )?;
312
313                    Ok(ExecutionResult::one())
314                }
315            },
316            issuecraft_ql::IqlQuery::Update(UpdateStatement { entity, updates }) => match entity {
317                issuecraft_ql::UpdateTarget::User(_) => Err(IqlError::NotSupported),
318                issuecraft_ql::UpdateTarget::Project(ProjectId(id)) => {
319                    self.update::<ProjectInfo>(EntityType::Projects, &id, updates)?;
320                    Ok(ExecutionResult::one())
321                }
322                issuecraft_ql::UpdateTarget::Issue(IssueId(id)) => {
323                    self.update::<IssueInfo>(EntityType::Issues, &id, updates)?;
324                    Ok(ExecutionResult::one())
325                }
326                issuecraft_ql::UpdateTarget::Comment(CommentId(id)) => {
327                    self.update::<CommentInfo>(EntityType::Comments, &id, updates)?;
328                    Ok(ExecutionResult::one())
329                }
330            },
331            issuecraft_ql::IqlQuery::Delete(_) => Err(IqlError::NotSupported),
332            issuecraft_ql::IqlQuery::Assign(_) => Err(IqlError::NotSupported),
333            issuecraft_ql::IqlQuery::Close(CloseStatement { issue_id, reason }) => {
334                let issue_info: IssueInfo = self.get(EntityType::Issues, issue_id.str_from_id())?;
335                if let IssueStatus::Closed { reason } = issue_info.status {
336                    return Err(IqlError::IssueAlreadyClosed(
337                        issue_id.str_from_id().to_string(),
338                        reason,
339                    ));
340                }
341                self.set(
342                    EntityType::Issues,
343                    issue_id.str_from_id(),
344                    &IssueInfo {
345                        status: IssueStatus::Closed {
346                            reason: reason.clone().unwrap_or_default(),
347                        },
348                        ..issue_info
349                    },
350                )?;
351
352                Ok(ExecutionResult::one())
353            }
354            issuecraft_ql::IqlQuery::Reopen(ReopenStatement { issue_id }) => {
355                let issue_info: IssueInfo = self.get(EntityType::Issues, issue_id.str_from_id())?;
356                if !matches!(issue_info.status, IssueStatus::Closed { .. }) {
357                    return Ok(ExecutionResult::zero());
358                }
359                self.set(
360                    EntityType::Issues,
361                    issue_id.str_from_id(),
362                    &IssueInfo {
363                        status: IssueStatus::Open,
364                        ..issue_info
365                    },
366                )?;
367
368                Ok(ExecutionResult::one())
369            }
370            issuecraft_ql::IqlQuery::Comment(CommentStatement { issue_id, content }) => {
371                if !self.exists(EntityType::Issues, issue_id.str_from_id())? {
372                    return Err(IqlError::ItemNotFound {
373                        kind: EntityType::Issues.kind(),
374                        id: issue_id.str_from_id().to_string(),
375                    });
376                }
377                let comment_info = CommentInfo {
378                    issue: issue_id.clone(),
379                    author: UserId(REDB_DEFAULT_USER.to_string()),
380                    content: content.clone(),
381                    created_at: time::UtcDateTime::now(),
382                };
383                self.set(
384                    EntityType::Comments,
385                    &format!("C{}", nanoid!()),
386                    &comment_info,
387                )?;
388                Ok(ExecutionResult::one())
389            }
390        }
391    }
392}