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}