1use serde::{Deserialize, Serialize};
2use shaperail_core::{DatabaseEngine, FieldSchema, FieldType, ResourceDefinition, ShaperailError};
3use sqlx::postgres::PgRow;
4use sqlx::{PgPool, Row};
5
6use super::filter::FilterSet;
7use super::pagination::{decode_cursor, encode_cursor, PageRequest};
8use super::search::SearchParam;
9use super::sort::SortParam;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ResourceRow(pub serde_json::Value);
14
15pub struct ResourceQuery<'a> {
29 pub resource: &'a ResourceDefinition,
30 pub pool: &'a PgPool,
31}
32
33impl<'a> ResourceQuery<'a> {
34 pub fn new(resource: &'a ResourceDefinition, pool: &'a PgPool) -> Self {
35 Self { resource, pool }
36 }
37
38 fn table(&self) -> &str {
40 &self.resource.resource
41 }
42
43 fn columns(&self) -> Vec<&str> {
45 self.resource.schema.keys().map(|k| k.as_str()).collect()
46 }
47
48 fn primary_key(&self) -> &str {
50 self.resource
51 .schema
52 .iter()
53 .find(|(_, fs)| fs.primary)
54 .map(|(name, _)| name.as_str())
55 .unwrap_or("id")
56 }
57
58 fn select_columns(&self) -> String {
60 self.columns()
61 .iter()
62 .map(|c| format!("\"{c}\""))
63 .collect::<Vec<_>>()
64 .join(", ")
65 }
66
67 fn row_to_json(&self, row: &PgRow) -> Result<serde_json::Value, ShaperailError> {
69 let mut obj = serde_json::Map::new();
70 for (name, field) in &self.resource.schema {
71 let value = extract_column_value(row, name, field)?;
72 obj.insert(name.clone(), value);
73 }
74 Ok(serde_json::Value::Object(obj))
75 }
76
77 fn has_soft_delete(&self) -> bool {
79 self.resource
80 .endpoints
81 .as_ref()
82 .map(|eps| eps.values().any(|ep| ep.soft_delete))
83 .unwrap_or(false)
84 }
85
86 pub async fn find_by_id(&self, id: &uuid::Uuid) -> Result<ResourceRow, ShaperailError> {
90 let pk = self.primary_key();
91 let soft_delete_clause = if self.has_soft_delete() {
92 " AND \"deleted_at\" IS NULL"
93 } else {
94 ""
95 };
96 let sql = format!(
97 "SELECT {} FROM \"{}\" WHERE \"{}\" = $1{soft_delete_clause}",
98 self.select_columns(),
99 self.table(),
100 pk,
101 );
102
103 let row = sqlx::query(&sql)
104 .bind(id)
105 .fetch_optional(self.pool)
106 .await?
107 .ok_or(ShaperailError::NotFound)?;
108
109 let json = self.row_to_json(&row)?;
110 Ok(ResourceRow(json))
111 }
112
113 pub async fn find_all(
117 &self,
118 filters: &FilterSet,
119 search: Option<&SearchParam>,
120 sort: &SortParam,
121 page: &PageRequest,
122 ) -> Result<(Vec<ResourceRow>, serde_json::Value), ShaperailError> {
123 let mut sql = format!("SELECT {} FROM \"{}\"", self.select_columns(), self.table());
124 let mut has_where = false;
125 let mut param_offset: usize = 1;
126 let mut bind_values: Vec<BindValue> = Vec::new();
127
128 if self.has_soft_delete() {
130 sql.push_str(" WHERE \"deleted_at\" IS NULL");
131 has_where = true;
132 }
133
134 if !filters.is_empty() {
136 param_offset = filters.apply_to_sql(&mut sql, has_where, param_offset);
137 has_where = true;
138 for f in &filters.filters {
139 bind_values.push(self.coerce_filter_value(&f.field, &f.value));
140 }
141 }
142
143 if let Some(sp) = search {
145 param_offset = sp.apply_to_sql(&mut sql, has_where, param_offset);
146 has_where = true;
147 bind_values.push(BindValue::Text(sp.term.clone()));
148 }
149
150 match page {
152 PageRequest::Cursor { after, limit } => {
153 let decoded_cursor = if let Some(cursor_str) = after {
154 let id_str = decode_cursor(cursor_str)?;
155 let id = uuid::Uuid::parse_str(&id_str).map_err(|_| {
156 ShaperailError::Validation(vec![shaperail_core::FieldError {
157 field: "cursor".to_string(),
158 message: "Invalid cursor value".to_string(),
159 code: "invalid_cursor".to_string(),
160 }])
161 })?;
162 Some(id)
163 } else {
164 None
165 };
166
167 if decoded_cursor.is_some() {
168 if has_where {
169 sql.push_str(" AND ");
170 } else {
171 sql.push_str(" WHERE ");
172 }
173 sql.push_str(&format!("\"id\" > ${param_offset}"));
174 bind_values.push(BindValue::Uuid(decoded_cursor.unwrap_or_default()));
175 }
176
177 if sort.is_empty() {
179 sql.push_str(" ORDER BY \"id\" ASC");
180 } else {
181 sort.apply_to_sql(&mut sql);
182 }
183 sql.push_str(&format!(" LIMIT {}", limit + 1));
184
185 let rows = self.execute_query(&sql, &bind_values).await?;
186
187 let has_more = rows.len() as i64 > *limit;
188 let result_rows: Vec<ResourceRow> =
189 rows.into_iter().take(*limit as usize).collect();
190
191 let cursor = if has_more {
192 result_rows
193 .last()
194 .and_then(|r| r.0.get("id"))
195 .and_then(|v| v.as_str())
196 .map(encode_cursor)
197 } else {
198 None
199 };
200
201 let meta = serde_json::json!({
202 "cursor": cursor,
203 "has_more": has_more,
204 });
205 Ok((result_rows, meta))
206 }
207 PageRequest::Offset { offset, limit } => {
208 let mut count_sql = format!("SELECT COUNT(*) FROM \"{}\"", self.table());
210 let mut count_has_where = false;
211 let mut count_offset: usize = 1;
212 let mut count_binds: Vec<BindValue> = Vec::new();
213
214 if self.has_soft_delete() {
216 count_sql.push_str(" WHERE \"deleted_at\" IS NULL");
217 count_has_where = true;
218 }
219
220 if !filters.is_empty() {
221 count_offset =
222 filters.apply_to_sql(&mut count_sql, count_has_where, count_offset);
223 count_has_where = true;
224 for f in &filters.filters {
225 count_binds.push(self.coerce_filter_value(&f.field, &f.value));
226 }
227 }
228 if let Some(sp) = search {
229 sp.apply_to_sql(&mut count_sql, count_has_where, count_offset);
230 count_binds.push(BindValue::Text(sp.term.clone()));
231 }
232
233 let total = self.execute_count(&count_sql, &count_binds).await?;
234
235 if !sort.is_empty() {
237 sort.apply_to_sql(&mut sql);
238 }
239 sql.push_str(&format!(" LIMIT {limit} OFFSET {offset}"));
240
241 let rows = self.execute_query(&sql, &bind_values).await?;
242
243 let meta = serde_json::json!({
244 "offset": offset,
245 "limit": limit,
246 "total": total,
247 });
248 Ok((rows, meta))
249 }
250 }
251 }
252
253 pub async fn insert(
255 &self,
256 data: &serde_json::Map<String, serde_json::Value>,
257 ) -> Result<ResourceRow, ShaperailError> {
258 let mut columns = Vec::new();
259 let mut placeholders = Vec::new();
260 let mut bind_values = Vec::new();
261 let mut idx = 1usize;
262
263 for (name, field) in &self.resource.schema {
265 if field.generated {
266 match field.field_type {
267 FieldType::Uuid => {
268 columns.push(format!("\"{name}\""));
269 placeholders.push(format!("${idx}"));
270 bind_values.push(BindValue::Uuid(uuid::Uuid::new_v4()));
271 idx += 1;
272 }
273 FieldType::Timestamp => {
274 columns.push(format!("\"{name}\""));
275 placeholders.push(format!("${idx}"));
276 bind_values.push(BindValue::Timestamp(chrono::Utc::now()));
277 idx += 1;
278 }
279 _ => {}
280 }
281 continue;
282 }
283
284 if let Some(value) = data.get(name) {
285 columns.push(format!("\"{name}\""));
286 placeholders.push(format!("${idx}"));
287 bind_values.push(json_to_bind(value, field));
288 idx += 1;
289 } else if let Some(default) = &field.default {
290 columns.push(format!("\"{name}\""));
291 placeholders.push(format!("${idx}"));
292 bind_values.push(json_to_bind(default, field));
293 idx += 1;
294 }
295 }
296
297 let sql = format!(
298 "INSERT INTO \"{}\" ({}) VALUES ({}) RETURNING {}",
299 self.table(),
300 columns.join(", "),
301 placeholders.join(", "),
302 self.select_columns(),
303 );
304
305 let rows = self.execute_query(&sql, &bind_values).await?;
306 rows.into_iter()
307 .next()
308 .ok_or_else(|| ShaperailError::Internal("Insert returned no rows".to_string()))
309 }
310
311 pub async fn update_by_id(
313 &self,
314 id: &uuid::Uuid,
315 data: &serde_json::Map<String, serde_json::Value>,
316 ) -> Result<ResourceRow, ShaperailError> {
317 let mut set_clauses = Vec::new();
318 let mut bind_values = Vec::new();
319 let mut idx = 1usize;
320
321 for (name, value) in data {
322 if let Some(field) = self.resource.schema.get(name) {
323 if field.primary || field.generated {
324 continue;
325 }
326 set_clauses.push(format!("\"{name}\" = ${idx}"));
327 bind_values.push(json_to_bind(value, field));
328 idx += 1;
329 }
330 }
331
332 if let Some(field) = self.resource.schema.get("updated_at") {
334 if field.generated && field.field_type == FieldType::Timestamp {
335 set_clauses.push(format!("\"updated_at\" = ${idx}"));
336 bind_values.push(BindValue::Timestamp(chrono::Utc::now()));
337 idx += 1;
338 }
339 }
340
341 if set_clauses.is_empty() {
342 return Err(ShaperailError::Validation(vec![
343 shaperail_core::FieldError {
344 field: "body".to_string(),
345 message: "No valid fields to update".to_string(),
346 code: "empty_update".to_string(),
347 },
348 ]));
349 }
350
351 let pk = self.primary_key();
352 let soft_delete_clause = if self.has_soft_delete() {
353 " AND \"deleted_at\" IS NULL"
354 } else {
355 ""
356 };
357 let sql = format!(
358 "UPDATE \"{}\" SET {} WHERE \"{}\" = ${}{soft_delete_clause} RETURNING {}",
359 self.table(),
360 set_clauses.join(", "),
361 pk,
362 idx,
363 self.select_columns(),
364 );
365 bind_values.push(BindValue::Uuid(*id));
366
367 let rows = self.execute_query(&sql, &bind_values).await?;
368 rows.into_iter().next().ok_or(ShaperailError::NotFound)
369 }
370
371 pub async fn soft_delete_by_id(&self, id: &uuid::Uuid) -> Result<ResourceRow, ShaperailError> {
373 let pk = self.primary_key();
374 let sql = format!(
375 "UPDATE \"{}\" SET \"deleted_at\" = $1 WHERE \"{}\" = $2 AND \"deleted_at\" IS NULL RETURNING {}",
376 self.table(),
377 pk,
378 self.select_columns(),
379 );
380
381 let bind_values = vec![
382 BindValue::Timestamp(chrono::Utc::now()),
383 BindValue::Uuid(*id),
384 ];
385
386 let rows = self.execute_query(&sql, &bind_values).await?;
387 rows.into_iter().next().ok_or(ShaperailError::NotFound)
388 }
389
390 pub async fn hard_delete_by_id(&self, id: &uuid::Uuid) -> Result<ResourceRow, ShaperailError> {
392 let pk = self.primary_key();
393 let sql = format!(
394 "DELETE FROM \"{}\" WHERE \"{}\" = $1 RETURNING {}",
395 self.table(),
396 pk,
397 self.select_columns(),
398 );
399
400 let bind_values = vec![BindValue::Uuid(*id)];
401 let rows = self.execute_query(&sql, &bind_values).await?;
402 rows.into_iter().next().ok_or(ShaperailError::NotFound)
403 }
404
405 fn coerce_filter_value(&self, field_name: &str, value: &str) -> BindValue {
409 if let Some(field) = self.resource.schema.get(field_name) {
410 match field.field_type {
411 FieldType::Uuid => {
412 if let Ok(u) = uuid::Uuid::parse_str(value) {
413 return BindValue::Uuid(u);
414 }
415 }
416 FieldType::Integer => {
417 if let Ok(n) = value.parse::<i32>() {
418 return BindValue::Int(n);
419 }
420 }
421 FieldType::Bigint => {
422 if let Ok(n) = value.parse::<i64>() {
423 return BindValue::Bigint(n);
424 }
425 }
426 FieldType::Number => {
427 if let Ok(n) = value.parse::<f64>() {
428 return BindValue::Float(n);
429 }
430 }
431 FieldType::Boolean => {
432 if let Ok(b) = value.parse::<bool>() {
433 return BindValue::Bool(b);
434 }
435 }
436 _ => {}
437 }
438 }
439 BindValue::Text(value.to_string())
440 }
441
442 async fn execute_query(
443 &self,
444 sql: &str,
445 binds: &[BindValue],
446 ) -> Result<Vec<ResourceRow>, ShaperailError> {
447 let span = crate::observability::telemetry::db_span("query", self.table(), sql);
448 let _enter = span.enter();
449 let start = std::time::Instant::now();
450
451 let mut query = sqlx::query(sql);
454 for bind in binds {
455 query = match bind {
456 BindValue::Text(v) => query.bind(v),
457 BindValue::Int(v) => query.bind(v),
458 BindValue::Bigint(v) => query.bind(v),
459 BindValue::Float(v) => query.bind(v),
460 BindValue::Bool(v) => query.bind(v),
461 BindValue::Uuid(v) => query.bind(v),
462 BindValue::Timestamp(v) => query.bind(v),
463 BindValue::Date(v) => query.bind(v),
464 BindValue::Json(v) => query.bind(v),
465 BindValue::Null => query.bind(None::<String>),
466 };
467 }
468
469 let pg_rows = query.fetch_all(self.pool).await?;
470 let duration_ms = start.elapsed().as_millis() as u64;
471 log_slow_query(sql, duration_ms);
472
473 let mut results = Vec::with_capacity(pg_rows.len());
474 for row in &pg_rows {
475 results.push(ResourceRow(self.row_to_json(row)?));
476 }
477 Ok(results)
478 }
479
480 async fn execute_count(&self, sql: &str, binds: &[BindValue]) -> Result<i64, ShaperailError> {
481 let span = crate::observability::telemetry::db_span("count", self.table(), sql);
482 let _enter = span.enter();
483 let start = std::time::Instant::now();
484
485 let mut query = sqlx::query_scalar::<_, i64>(sql);
486 for bind in binds {
487 query = match bind {
488 BindValue::Text(v) => query.bind(v),
489 BindValue::Int(v) => query.bind(v),
490 BindValue::Bigint(v) => query.bind(v),
491 BindValue::Float(v) => query.bind(v),
492 BindValue::Bool(v) => query.bind(v),
493 BindValue::Uuid(v) => query.bind(v),
494 BindValue::Timestamp(v) => query.bind(v),
495 BindValue::Date(v) => query.bind(v),
496 BindValue::Json(v) => query.bind(v),
497 BindValue::Null => query.bind(None::<String>),
498 };
499 }
500 let count = query.fetch_one(self.pool).await?;
501 let duration_ms = start.elapsed().as_millis() as u64;
502 log_slow_query(sql, duration_ms);
503
504 Ok(count)
505 }
506}
507
508#[derive(Debug, Clone)]
510enum BindValue {
511 Text(String),
512 Int(i32),
513 Bigint(i64),
514 Float(f64),
515 Bool(bool),
516 Uuid(uuid::Uuid),
517 Timestamp(chrono::DateTime<chrono::Utc>),
518 Date(chrono::NaiveDate),
519 Json(serde_json::Value),
520 Null,
521}
522
523fn json_to_bind(value: &serde_json::Value, field: &FieldSchema) -> BindValue {
525 if value.is_null() {
526 return BindValue::Null;
527 }
528 match field.field_type {
529 FieldType::Uuid => {
530 if let Some(s) = value.as_str() {
531 if let Ok(u) = uuid::Uuid::parse_str(s) {
532 return BindValue::Uuid(u);
533 }
534 }
535 BindValue::Text(value.to_string().trim_matches('"').to_string())
536 }
537 FieldType::String | FieldType::Enum | FieldType::File => {
538 BindValue::Text(value.as_str().unwrap_or(&value.to_string()).to_string())
539 }
540 FieldType::Integer => BindValue::Int(value.as_i64().unwrap_or(0) as i32),
541 FieldType::Bigint => BindValue::Bigint(value.as_i64().unwrap_or(0)),
542 FieldType::Number => BindValue::Float(value.as_f64().unwrap_or(0.0)),
543 FieldType::Boolean => BindValue::Bool(value.as_bool().unwrap_or(false)),
544 FieldType::Timestamp => {
545 if let Some(s) = value.as_str() {
546 if let Ok(dt) = s.parse::<chrono::DateTime<chrono::Utc>>() {
547 return BindValue::Timestamp(dt);
548 }
549 }
550 BindValue::Timestamp(chrono::Utc::now())
551 }
552 FieldType::Date => {
553 if let Some(s) = value.as_str() {
554 if let Ok(d) = s.parse::<chrono::NaiveDate>() {
555 return BindValue::Date(d);
556 }
557 }
558 BindValue::Date(chrono::Utc::now().date_naive())
559 }
560 FieldType::Json | FieldType::Array => BindValue::Json(value.clone()),
561 }
562}
563
564fn extract_column_value(
566 row: &PgRow,
567 name: &str,
568 field: &FieldSchema,
569) -> Result<serde_json::Value, ShaperailError> {
570 let map_err = |e: sqlx::Error| ShaperailError::Internal(format!("Column '{name}' error: {e}"));
572
573 match field.field_type {
574 FieldType::Uuid => {
575 let v: Option<uuid::Uuid> = row.try_get(name).map_err(map_err)?;
576 Ok(v.map(|u| serde_json::Value::String(u.to_string()))
577 .unwrap_or(serde_json::Value::Null))
578 }
579 FieldType::String | FieldType::Enum | FieldType::File => {
580 let v: Option<String> = row.try_get(name).map_err(map_err)?;
581 Ok(v.map(serde_json::Value::String)
582 .unwrap_or(serde_json::Value::Null))
583 }
584 FieldType::Integer => {
585 let v: Option<i32> = row.try_get(name).map_err(map_err)?;
586 Ok(v.map(|n| serde_json::Value::Number(n.into()))
587 .unwrap_or(serde_json::Value::Null))
588 }
589 FieldType::Bigint => {
590 let v: Option<i64> = row.try_get(name).map_err(map_err)?;
591 Ok(v.map(|n| serde_json::Value::Number(n.into()))
592 .unwrap_or(serde_json::Value::Null))
593 }
594 FieldType::Number => {
595 let v: Option<f64> = row.try_get(name).map_err(map_err)?;
596 Ok(
597 v.and_then(|n| serde_json::Number::from_f64(n).map(serde_json::Value::Number))
598 .unwrap_or(serde_json::Value::Null),
599 )
600 }
601 FieldType::Boolean => {
602 let v: Option<bool> = row.try_get(name).map_err(map_err)?;
603 Ok(v.map(serde_json::Value::Bool)
604 .unwrap_or(serde_json::Value::Null))
605 }
606 FieldType::Timestamp => {
607 let v: Option<chrono::DateTime<chrono::Utc>> = row.try_get(name).map_err(map_err)?;
608 Ok(v.map(|dt| serde_json::Value::String(dt.to_rfc3339()))
609 .unwrap_or(serde_json::Value::Null))
610 }
611 FieldType::Date => {
612 let v: Option<chrono::NaiveDate> = row.try_get(name).map_err(map_err)?;
613 Ok(v.map(|d| serde_json::Value::String(d.to_string()))
614 .unwrap_or(serde_json::Value::Null))
615 }
616 FieldType::Json | FieldType::Array => {
617 let v: Option<serde_json::Value> = row.try_get(name).map_err(map_err)?;
618 Ok(v.unwrap_or(serde_json::Value::Null))
619 }
620 }
621}
622
623fn log_slow_query(sql: &str, duration_ms: u64) {
627 let threshold: u64 = std::env::var("SHAPERAIL_SLOW_QUERY_MS")
628 .ok()
629 .and_then(|v| v.parse().ok())
630 .unwrap_or(100);
631
632 if duration_ms >= threshold {
633 tracing::warn!(
634 duration_ms = duration_ms,
635 sql = %sql,
636 threshold_ms = threshold,
637 "Slow query detected"
638 );
639 }
640}
641
642pub fn build_create_table_sql_for_engine(
646 engine: DatabaseEngine,
647 resource: &ResourceDefinition,
648) -> String {
649 match engine {
650 DatabaseEngine::Postgres => build_create_table_sql_postgres(resource),
651 DatabaseEngine::MySQL => build_create_table_sql_mysql(resource),
652 DatabaseEngine::SQLite => build_create_table_sql_sqlite(resource),
653 DatabaseEngine::MongoDB => {
654 String::new()
656 }
657 }
658}
659
660pub fn build_create_table_sql(resource: &ResourceDefinition) -> String {
664 build_create_table_sql_postgres(resource)
665}
666
667fn build_create_table_sql_postgres(resource: &ResourceDefinition) -> String {
668 let mut columns = Vec::new();
669 let mut constraints = Vec::new();
670 let has_soft_delete = resource
671 .endpoints
672 .as_ref()
673 .map(|eps| eps.values().any(|ep| ep.soft_delete))
674 .unwrap_or(false);
675
676 for (name, field) in &resource.schema {
677 let mut col = format!(
678 "\"{}\" {}",
679 name,
680 field_type_to_sql(&field.field_type, field)
681 );
682
683 if field.primary {
684 col.push_str(" PRIMARY KEY");
685 }
686 if field.required && !field.primary && !field.nullable {
687 col.push_str(" NOT NULL");
688 }
689 if field.unique && !field.primary {
690 col.push_str(" UNIQUE");
691 }
692 if let Some(default) = &field.default {
693 col.push_str(&format!(" DEFAULT {}", sql_default_value(default, field)));
694 }
695 if field.field_type == FieldType::Uuid && field.generated {
696 col.push_str(" DEFAULT gen_random_uuid()");
697 }
698 if field.field_type == FieldType::Timestamp && field.generated {
699 col.push_str(" DEFAULT NOW()");
700 }
701 if field.field_type == FieldType::Date && field.generated {
702 col.push_str(" DEFAULT CURRENT_DATE");
703 }
704
705 if field.field_type == FieldType::Enum {
707 if let Some(values) = &field.values {
708 let vals = values
709 .iter()
710 .map(|v| format!("'{v}'"))
711 .collect::<Vec<_>>()
712 .join(", ");
713 constraints.push(format!(
714 "CONSTRAINT \"chk_{table}_{name}\" CHECK (\"{name}\" IN ({vals}))",
715 table = resource.resource,
716 ));
717 }
718 }
719
720 if let Some(reference) = &field.reference {
722 if let Some((ref_table, ref_col)) = reference.split_once('.') {
723 constraints.push(format!(
724 "CONSTRAINT \"fk_{table}_{name}\" FOREIGN KEY (\"{name}\") REFERENCES \"{ref_table}\"(\"{ref_col}\")",
725 table = resource.resource,
726 ));
727 }
728 }
729
730 columns.push(col);
731 }
732
733 if has_soft_delete && !resource.schema.contains_key("deleted_at") {
734 columns.push("\"deleted_at\" TIMESTAMPTZ".to_string());
735 }
736
737 let mut sql = format!(
738 "CREATE TABLE IF NOT EXISTS \"{}\" (\n {}",
739 resource.resource,
740 columns.join(",\n ")
741 );
742
743 if !constraints.is_empty() {
744 sql.push_str(",\n ");
745 sql.push_str(&constraints.join(",\n "));
746 }
747 sql.push_str("\n)");
748
749 if let Some(indexes) = &resource.indexes {
751 for (i, idx) in indexes.iter().enumerate() {
752 let idx_cols = idx
753 .fields
754 .iter()
755 .map(|f| format!("\"{f}\""))
756 .collect::<Vec<_>>()
757 .join(", ");
758 let unique = if idx.unique { "UNIQUE " } else { "" };
759 let order = idx
760 .order
761 .as_deref()
762 .map(|o| format!(" {}", o.to_uppercase()))
763 .unwrap_or_default();
764 sql.push_str(&format!(
765 ";\nCREATE {unique}INDEX IF NOT EXISTS \"idx_{}_{i}\" ON \"{}\" ({idx_cols}{order})",
766 resource.resource, resource.resource,
767 ));
768 }
769 }
770
771 sql
772}
773
774fn build_create_table_sql_mysql(resource: &ResourceDefinition) -> String {
775 let q = |s: &str| format!("`{s}`");
776 let mut columns = Vec::new();
777 let mut constraints = Vec::new();
778 let has_soft_delete = resource
779 .endpoints
780 .as_ref()
781 .map(|eps| eps.values().any(|ep| ep.soft_delete))
782 .unwrap_or(false);
783
784 for (name, field) in &resource.schema {
785 let mut col = format!(
786 "{} {}",
787 q(name),
788 field_type_to_sql_mysql(&field.field_type, field)
789 );
790 if field.primary {
791 col.push_str(" PRIMARY KEY");
792 }
793 if field.required && !field.primary && !field.nullable {
794 col.push_str(" NOT NULL");
795 }
796 if field.unique && !field.primary {
797 col.push_str(" UNIQUE");
798 }
799 if let Some(default) = &field.default {
800 col.push_str(&format!(" DEFAULT {}", sql_default_value(default, field)));
801 }
802 if field.field_type == FieldType::Uuid && field.generated {
803 col.push_str(" DEFAULT (UUID())");
804 }
805 if field.field_type == FieldType::Timestamp && field.generated {
806 col.push_str(" DEFAULT (CURRENT_TIMESTAMP)");
807 }
808 if field.field_type == FieldType::Date && field.generated {
809 col.push_str(" DEFAULT (CURDATE())");
810 }
811 if field.field_type == FieldType::Enum {
812 if let Some(values) = &field.values {
813 let vals = values
814 .iter()
815 .map(|v| format!("'{v}'"))
816 .collect::<Vec<_>>()
817 .join(", ");
818 constraints.push(format!(
819 "CONSTRAINT chk_{}_{} CHECK ({} IN ({vals}))",
820 resource.resource,
821 name,
822 q(name),
823 ));
824 }
825 }
826 if let Some(reference) = &field.reference {
827 if let Some((ref_table, ref_col)) = reference.split_once('.') {
828 constraints.push(format!(
829 "CONSTRAINT fk_{}_{} FOREIGN KEY ({}) REFERENCES {}({})",
830 resource.resource,
831 name,
832 q(name),
833 q(ref_table),
834 q(ref_col),
835 ));
836 }
837 }
838 columns.push(col);
839 }
840 if has_soft_delete && !resource.schema.contains_key("deleted_at") {
841 columns.push(format!("{} DATETIME", q("deleted_at")));
842 }
843 let mut sql = format!(
844 "CREATE TABLE IF NOT EXISTS {} (\n {}",
845 q(&resource.resource),
846 columns.join(",\n ")
847 );
848 if !constraints.is_empty() {
849 sql.push_str(",\n ");
850 sql.push_str(&constraints.join(",\n "));
851 }
852 sql.push_str("\n)");
853 if let Some(indexes) = &resource.indexes {
854 for (i, idx) in indexes.iter().enumerate() {
855 let idx_cols = idx
856 .fields
857 .iter()
858 .map(|f| q(f))
859 .collect::<Vec<_>>()
860 .join(", ");
861 let unique = if idx.unique { "UNIQUE " } else { "" };
862 let order = idx
863 .order
864 .as_deref()
865 .map(|o| format!(" {}", o.to_uppercase()))
866 .unwrap_or_default();
867 sql.push_str(&format!(
868 ";\nCREATE {unique}INDEX idx_{resource}_{i} ON {tbl} ({idx_cols}{order})",
869 unique = unique,
870 resource = resource.resource,
871 i = i,
872 tbl = q(&resource.resource),
873 idx_cols = idx_cols,
874 order = order,
875 ));
876 }
877 }
878 sql
879}
880
881fn build_create_table_sql_sqlite(resource: &ResourceDefinition) -> String {
882 let q = |s: &str| format!("\"{s}\"");
883 let mut columns = Vec::new();
884 let mut constraints = Vec::new();
885 let has_soft_delete = resource
886 .endpoints
887 .as_ref()
888 .map(|eps| eps.values().any(|ep| ep.soft_delete))
889 .unwrap_or(false);
890
891 for (name, field) in &resource.schema {
892 let mut col = format!(
893 "{} {}",
894 q(name),
895 field_type_to_sql_sqlite(&field.field_type, field)
896 );
897 if field.primary {
898 col.push_str(" PRIMARY KEY");
899 }
900 if field.required && !field.primary && !field.nullable {
901 col.push_str(" NOT NULL");
902 }
903 if field.unique && !field.primary {
904 col.push_str(" UNIQUE");
905 }
906 if let Some(default) = &field.default {
907 col.push_str(&format!(" DEFAULT {}", sql_default_value(default, field)));
908 }
909 if field.field_type == FieldType::Uuid && field.generated {
910 col.push_str(" DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(6))))");
911 }
912 if field.field_type == FieldType::Timestamp && field.generated {
913 col.push_str(" DEFAULT (datetime('now'))");
914 }
915 if field.field_type == FieldType::Date && field.generated {
916 col.push_str(" DEFAULT (date('now'))");
917 }
918 if field.field_type == FieldType::Enum {
919 if let Some(values) = &field.values {
920 let vals = values
921 .iter()
922 .map(|v| format!("'{v}'"))
923 .collect::<Vec<_>>()
924 .join(", ");
925 constraints.push(format!(
926 "CONSTRAINT chk_{}_{} CHECK ({} IN ({vals}))",
927 resource.resource,
928 name,
929 q(name),
930 ));
931 }
932 }
933 if let Some(reference) = &field.reference {
934 if let Some((ref_table, ref_col)) = reference.split_once('.') {
935 constraints.push(format!(
936 "CONSTRAINT fk_{}_{} FOREIGN KEY ({}) REFERENCES {}({})",
937 resource.resource,
938 name,
939 q(name),
940 q(ref_table),
941 q(ref_col),
942 ));
943 }
944 }
945 columns.push(col);
946 }
947 if has_soft_delete && !resource.schema.contains_key("deleted_at") {
948 columns.push(format!("{} TEXT", q("deleted_at")));
949 }
950 let mut sql = format!(
951 "CREATE TABLE IF NOT EXISTS {} (\n {}",
952 q(&resource.resource),
953 columns.join(",\n ")
954 );
955 if !constraints.is_empty() {
956 sql.push_str(",\n ");
957 sql.push_str(&constraints.join(",\n "));
958 }
959 sql.push_str("\n)");
960 if let Some(indexes) = &resource.indexes {
961 for (i, idx) in indexes.iter().enumerate() {
962 let idx_cols = idx
963 .fields
964 .iter()
965 .map(|f| q(f))
966 .collect::<Vec<_>>()
967 .join(", ");
968 let unique = if idx.unique { "UNIQUE " } else { "" };
969 let order = idx
970 .order
971 .as_deref()
972 .map(|o| format!(" {}", o.to_uppercase()))
973 .unwrap_or_default();
974 sql.push_str(&format!(
975 ";\nCREATE {unique}INDEX IF NOT EXISTS idx_{resource}_{i} ON {tbl} ({idx_cols}{order})",
976 unique = unique,
977 resource = resource.resource,
978 i = i,
979 tbl = q(&resource.resource),
980 idx_cols = idx_cols,
981 order = order,
982 ));
983 }
984 }
985 sql
986}
987
988fn field_type_to_sql(ft: &FieldType, field: &FieldSchema) -> String {
990 match ft {
991 FieldType::Uuid => "UUID".to_string(),
992 FieldType::String => {
993 if let Some(max) = &field.max {
994 if let Some(n) = max.as_u64() {
995 return format!("VARCHAR({n})");
996 }
997 }
998 "TEXT".to_string()
999 }
1000 FieldType::Integer => "INTEGER".to_string(),
1001 FieldType::Bigint => "BIGINT".to_string(),
1002 FieldType::Number => "NUMERIC".to_string(),
1003 FieldType::Boolean => "BOOLEAN".to_string(),
1004 FieldType::Timestamp => "TIMESTAMPTZ".to_string(),
1005 FieldType::Date => "DATE".to_string(),
1006 FieldType::Enum => "TEXT".to_string(),
1007 FieldType::Json => "JSONB".to_string(),
1008 FieldType::Array => {
1009 if let Some(items) = &field.items {
1010 let item_sql = match items.as_str() {
1011 "string" => "TEXT",
1012 "integer" => "INTEGER",
1013 "uuid" => "UUID",
1014 _ => "TEXT",
1015 };
1016 format!("{item_sql}[]")
1017 } else {
1018 "TEXT[]".to_string()
1019 }
1020 }
1021 FieldType::File => "TEXT".to_string(),
1022 }
1023}
1024
1025fn field_type_to_sql_mysql(ft: &FieldType, field: &FieldSchema) -> String {
1026 match ft {
1027 FieldType::Uuid => "CHAR(36)".to_string(),
1028 FieldType::String => {
1029 if let Some(max) = &field.max {
1030 if let Some(n) = max.as_u64() {
1031 return format!("VARCHAR({n})");
1032 }
1033 }
1034 "TEXT".to_string()
1035 }
1036 FieldType::Integer => "INTEGER".to_string(),
1037 FieldType::Bigint => "BIGINT".to_string(),
1038 FieldType::Number => "DECIMAL(65,30)".to_string(),
1039 FieldType::Boolean => "BOOLEAN".to_string(),
1040 FieldType::Timestamp => "DATETIME(6)".to_string(),
1041 FieldType::Date => "DATE".to_string(),
1042 FieldType::Enum => "VARCHAR(255)".to_string(),
1043 FieldType::Json => "JSON".to_string(),
1044 FieldType::Array => "JSON".to_string(),
1045 FieldType::File => "TEXT".to_string(),
1046 }
1047}
1048
1049fn field_type_to_sql_sqlite(ft: &FieldType, field: &FieldSchema) -> String {
1050 match ft {
1051 FieldType::Uuid => "TEXT".to_string(),
1052 FieldType::String => {
1053 if let Some(max) = &field.max {
1054 if let Some(n) = max.as_u64() {
1055 return format!("VARCHAR({n})");
1056 }
1057 }
1058 "TEXT".to_string()
1059 }
1060 FieldType::Integer => "INTEGER".to_string(),
1061 FieldType::Bigint => "INTEGER".to_string(),
1062 FieldType::Number => "REAL".to_string(),
1063 FieldType::Boolean => "INTEGER".to_string(),
1064 FieldType::Timestamp => "TEXT".to_string(),
1065 FieldType::Date => "TEXT".to_string(),
1066 FieldType::Enum => "TEXT".to_string(),
1067 FieldType::Json => "TEXT".to_string(),
1068 FieldType::Array => "TEXT".to_string(),
1069 FieldType::File => "TEXT".to_string(),
1070 }
1071}
1072
1073fn sql_default_value(value: &serde_json::Value, _field: &FieldSchema) -> String {
1075 match value {
1076 serde_json::Value::String(s) => format!("'{s}'"),
1077 serde_json::Value::Number(n) => n.to_string(),
1078 serde_json::Value::Bool(b) => b.to_string().to_uppercase(),
1079 serde_json::Value::Null => "NULL".to_string(),
1080 other => format!("'{}'", other),
1081 }
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086 use super::*;
1087 use indexmap::IndexMap;
1088 use shaperail_core::IndexSpec;
1089
1090 fn test_resource() -> ResourceDefinition {
1091 let mut schema = IndexMap::new();
1092 schema.insert(
1093 "id".to_string(),
1094 FieldSchema {
1095 field_type: FieldType::Uuid,
1096 primary: true,
1097 generated: true,
1098 required: false,
1099 unique: false,
1100 nullable: false,
1101 reference: None,
1102 min: None,
1103 max: None,
1104 format: None,
1105 values: None,
1106 default: None,
1107 sensitive: false,
1108 search: false,
1109 items: None,
1110 },
1111 );
1112 schema.insert(
1113 "email".to_string(),
1114 FieldSchema {
1115 field_type: FieldType::String,
1116 primary: false,
1117 generated: false,
1118 required: true,
1119 unique: true,
1120 nullable: false,
1121 reference: None,
1122 min: None,
1123 max: Some(serde_json::json!(255)),
1124 format: Some("email".to_string()),
1125 values: None,
1126 default: None,
1127 sensitive: false,
1128 search: true,
1129 items: None,
1130 },
1131 );
1132 schema.insert(
1133 "name".to_string(),
1134 FieldSchema {
1135 field_type: FieldType::String,
1136 primary: false,
1137 generated: false,
1138 required: true,
1139 unique: false,
1140 nullable: false,
1141 reference: None,
1142 min: Some(serde_json::json!(1)),
1143 max: Some(serde_json::json!(200)),
1144 format: None,
1145 values: None,
1146 default: None,
1147 sensitive: false,
1148 search: true,
1149 items: None,
1150 },
1151 );
1152 schema.insert(
1153 "role".to_string(),
1154 FieldSchema {
1155 field_type: FieldType::Enum,
1156 primary: false,
1157 generated: false,
1158 required: true,
1159 unique: false,
1160 nullable: false,
1161 reference: None,
1162 min: None,
1163 max: None,
1164 format: None,
1165 values: Some(vec![
1166 "admin".to_string(),
1167 "member".to_string(),
1168 "viewer".to_string(),
1169 ]),
1170 default: Some(serde_json::json!("member")),
1171 sensitive: false,
1172 search: false,
1173 items: None,
1174 },
1175 );
1176 schema.insert(
1177 "org_id".to_string(),
1178 FieldSchema {
1179 field_type: FieldType::Uuid,
1180 primary: false,
1181 generated: false,
1182 required: true,
1183 unique: false,
1184 nullable: false,
1185 reference: Some("organizations.id".to_string()),
1186 min: None,
1187 max: None,
1188 format: None,
1189 values: None,
1190 default: None,
1191 sensitive: false,
1192 search: false,
1193 items: None,
1194 },
1195 );
1196 schema.insert(
1197 "created_at".to_string(),
1198 FieldSchema {
1199 field_type: FieldType::Timestamp,
1200 primary: false,
1201 generated: true,
1202 required: false,
1203 unique: false,
1204 nullable: false,
1205 reference: None,
1206 min: None,
1207 max: None,
1208 format: None,
1209 values: None,
1210 default: None,
1211 sensitive: false,
1212 search: false,
1213 items: None,
1214 },
1215 );
1216 schema.insert(
1217 "updated_at".to_string(),
1218 FieldSchema {
1219 field_type: FieldType::Timestamp,
1220 primary: false,
1221 generated: true,
1222 required: false,
1223 unique: false,
1224 nullable: false,
1225 reference: None,
1226 min: None,
1227 max: None,
1228 format: None,
1229 values: None,
1230 default: None,
1231 sensitive: false,
1232 search: false,
1233 items: None,
1234 },
1235 );
1236
1237 ResourceDefinition {
1238 resource: "users".to_string(),
1239 version: 1,
1240 db: None,
1241 tenant_key: None,
1242 schema,
1243 endpoints: None,
1244 relations: None,
1245 indexes: Some(vec![
1246 IndexSpec {
1247 fields: vec!["org_id".to_string(), "role".to_string()],
1248 unique: false,
1249 order: None,
1250 },
1251 IndexSpec {
1252 fields: vec!["created_at".to_string()],
1253 unique: false,
1254 order: Some("desc".to_string()),
1255 },
1256 ]),
1257 }
1258 }
1259
1260 #[test]
1261 fn create_table_sql_basic() {
1262 let resource = test_resource();
1263 let sql = build_create_table_sql(&resource);
1264
1265 assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"users\""));
1266 assert!(sql.contains("\"id\" UUID PRIMARY KEY DEFAULT gen_random_uuid()"));
1267 assert!(sql.contains("\"email\" VARCHAR(255) NOT NULL UNIQUE"));
1268 assert!(sql.contains("\"name\" VARCHAR(200) NOT NULL"));
1269 assert!(sql.contains("\"role\" TEXT NOT NULL DEFAULT 'member'"));
1270 assert!(sql.contains("\"org_id\" UUID NOT NULL"));
1271 assert!(sql.contains("\"created_at\" TIMESTAMPTZ DEFAULT NOW()"));
1272 assert!(sql.contains("\"updated_at\" TIMESTAMPTZ DEFAULT NOW()"));
1273 }
1274
1275 #[test]
1276 fn create_table_sql_constraints() {
1277 let resource = test_resource();
1278 let sql = build_create_table_sql(&resource);
1279
1280 assert!(sql.contains("CONSTRAINT \"chk_users_role\" CHECK"));
1281 assert!(sql.contains("'admin', 'member', 'viewer'"));
1282 assert!(sql.contains("CONSTRAINT \"fk_users_org_id\" FOREIGN KEY"));
1283 assert!(sql.contains("REFERENCES \"organizations\"(\"id\")"));
1284 }
1285
1286 #[test]
1287 fn create_table_sql_indexes() {
1288 let resource = test_resource();
1289 let sql = build_create_table_sql(&resource);
1290
1291 assert!(sql.contains(
1292 "CREATE INDEX IF NOT EXISTS \"idx_users_0\" ON \"users\" (\"org_id\", \"role\")"
1293 ));
1294 assert!(sql.contains(
1295 "CREATE INDEX IF NOT EXISTS \"idx_users_1\" ON \"users\" (\"created_at\" DESC)"
1296 ));
1297 }
1298
1299 #[test]
1300 fn create_table_sql_for_engine_mysql() {
1301 let resource = test_resource();
1302 let sql = build_create_table_sql_for_engine(DatabaseEngine::MySQL, &resource);
1303 assert!(sql.contains("CREATE TABLE IF NOT EXISTS `users`"));
1304 assert!(sql.contains("CHAR(36)"));
1305 assert!(sql.contains("DEFAULT (UUID())"));
1306 assert!(sql.contains("DEFAULT (CURRENT_TIMESTAMP)"));
1307 }
1308
1309 #[test]
1310 fn create_table_sql_for_engine_sqlite() {
1311 let resource = test_resource();
1312 let sql = build_create_table_sql_for_engine(DatabaseEngine::SQLite, &resource);
1313 assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"users\""));
1314 assert!(sql.contains("DEFAULT (datetime('now'))"));
1315 }
1316
1317 #[test]
1318 fn create_table_sql_adds_deleted_at_for_soft_delete() {
1319 let mut resource = test_resource();
1320 resource.endpoints = Some(indexmap::IndexMap::from([(
1321 "delete".to_string(),
1322 shaperail_core::EndpointSpec {
1323 method: shaperail_core::HttpMethod::Delete,
1324 path: "/users/:id".to_string(),
1325 auth: None,
1326 input: None,
1327 filters: None,
1328 search: None,
1329 pagination: None,
1330 sort: None,
1331 cache: None,
1332 controller: None,
1333 events: None,
1334 jobs: None,
1335 upload: None,
1336 soft_delete: true,
1337 },
1338 )]));
1339
1340 let sql = build_create_table_sql(&resource);
1341 assert!(sql.contains("\"deleted_at\" TIMESTAMPTZ"));
1342 }
1343
1344 #[test]
1345 fn field_type_to_sql_mapping() {
1346 let default_field = FieldSchema {
1347 field_type: FieldType::String,
1348 primary: false,
1349 generated: false,
1350 required: false,
1351 unique: false,
1352 nullable: false,
1353 reference: None,
1354 min: None,
1355 max: None,
1356 format: None,
1357 values: None,
1358 default: None,
1359 sensitive: false,
1360 search: false,
1361 items: None,
1362 };
1363
1364 assert_eq!(field_type_to_sql(&FieldType::Uuid, &default_field), "UUID");
1365 assert_eq!(
1366 field_type_to_sql(&FieldType::String, &default_field),
1367 "TEXT"
1368 );
1369 assert_eq!(
1370 field_type_to_sql(&FieldType::Integer, &default_field),
1371 "INTEGER"
1372 );
1373 assert_eq!(
1374 field_type_to_sql(&FieldType::Bigint, &default_field),
1375 "BIGINT"
1376 );
1377 assert_eq!(
1378 field_type_to_sql(&FieldType::Number, &default_field),
1379 "NUMERIC"
1380 );
1381 assert_eq!(
1382 field_type_to_sql(&FieldType::Boolean, &default_field),
1383 "BOOLEAN"
1384 );
1385 assert_eq!(
1386 field_type_to_sql(&FieldType::Timestamp, &default_field),
1387 "TIMESTAMPTZ"
1388 );
1389 assert_eq!(field_type_to_sql(&FieldType::Date, &default_field), "DATE");
1390 assert_eq!(field_type_to_sql(&FieldType::Enum, &default_field), "TEXT");
1391 assert_eq!(field_type_to_sql(&FieldType::Json, &default_field), "JSONB");
1392 assert_eq!(field_type_to_sql(&FieldType::File, &default_field), "TEXT");
1393 }
1394
1395 #[test]
1396 fn field_type_to_sql_varchar() {
1397 let field = FieldSchema {
1398 field_type: FieldType::String,
1399 primary: false,
1400 generated: false,
1401 required: false,
1402 unique: false,
1403 nullable: false,
1404 reference: None,
1405 min: None,
1406 max: Some(serde_json::json!(100)),
1407 format: None,
1408 values: None,
1409 default: None,
1410 sensitive: false,
1411 search: false,
1412 items: None,
1413 };
1414 assert_eq!(
1415 field_type_to_sql(&FieldType::String, &field),
1416 "VARCHAR(100)"
1417 );
1418 }
1419
1420 #[test]
1421 fn field_type_to_sql_array() {
1422 let field = FieldSchema {
1423 field_type: FieldType::Array,
1424 primary: false,
1425 generated: false,
1426 required: false,
1427 unique: false,
1428 nullable: false,
1429 reference: None,
1430 min: None,
1431 max: None,
1432 format: None,
1433 values: None,
1434 default: None,
1435 sensitive: false,
1436 search: false,
1437 items: Some("string".to_string()),
1438 };
1439 assert_eq!(field_type_to_sql(&FieldType::Array, &field), "TEXT[]");
1440 }
1441
1442 #[test]
1443 fn json_to_bind_types() {
1444 let str_field = FieldSchema {
1445 field_type: FieldType::String,
1446 primary: false,
1447 generated: false,
1448 required: false,
1449 unique: false,
1450 nullable: false,
1451 reference: None,
1452 min: None,
1453 max: None,
1454 format: None,
1455 values: None,
1456 default: None,
1457 sensitive: false,
1458 search: false,
1459 items: None,
1460 };
1461
1462 let bind = json_to_bind(&serde_json::json!("hello"), &str_field);
1463 assert!(matches!(bind, BindValue::Text(s) if s == "hello"));
1464
1465 let bind = json_to_bind(&serde_json::Value::Null, &str_field);
1466 assert!(matches!(bind, BindValue::Null));
1467 }
1468}