1use std::{fmt, sync::Arc};
2
3use sqlx::error::ErrorKind;
4
5mod classify;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum QueryErrorCategory {
9 Conflict,
10 Validation,
11 Forbidden,
12 Internal,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct FrameworkConstraintSpec {
17 category: QueryErrorCategory,
18 code: &'static str,
19 client_message: &'static str,
20}
21
22impl FrameworkConstraintSpec {
23 #[must_use]
24 pub const fn new(
25 category: QueryErrorCategory,
26 code: &'static str,
27 client_message: &'static str,
28 ) -> Self {
29 Self {
30 category,
31 code,
32 client_message,
33 }
34 }
35
36 #[must_use]
37 pub const fn category(&self) -> QueryErrorCategory {
38 self.category
39 }
40
41 #[must_use]
42 pub const fn code(&self) -> &'static str {
43 self.code
44 }
45
46 #[must_use]
47 pub const fn client_message(&self) -> &'static str {
48 self.client_message
49 }
50}
51
52#[derive(Debug, Clone)]
53pub struct QueryError {
54 category: QueryErrorCategory,
55 code: &'static str,
56 client_message: &'static str,
57 sqlstate: Option<String>,
58 constraint: Option<String>,
59 message: String,
60 source: Option<Arc<sqlx::Error>>,
61}
62
63impl QueryError {
64 #[must_use]
65 pub fn from_classified(
66 category: QueryErrorCategory,
67 code: &'static str,
68 client_message: &'static str,
69 internal_message: impl Into<String>,
70 ) -> Self {
71 Self {
72 category,
73 code,
74 client_message,
75 sqlstate: None,
76 constraint: None,
77 message: internal_message.into(),
78 source: None,
79 }
80 }
81
82 #[must_use]
83 pub fn from_sqlx_with_constraint_classifier<F>(
84 error: sqlx::Error,
85 context: Option<&str>,
86 classify_constraint: F,
87 ) -> Self
88 where
89 F: Fn(&str) -> Option<FrameworkConstraintSpec>,
90 {
91 let (sqlstate, constraint, spec, raw_message) = if let Some(db) = error.as_database_error()
92 {
93 let sqlstate = db.code().map(|code| code.into_owned());
94 let constraint = db.constraint().map(ToOwned::to_owned);
95 let spec = classify_query_error_with_constraint_classifier(
96 &db.kind(),
97 sqlstate.as_deref(),
98 constraint.as_deref(),
99 classify_constraint,
100 );
101 (sqlstate, constraint, spec, db.message().to_owned())
102 } else {
103 (
104 None,
105 None,
106 QueryErrorSpec::internal().into(),
107 error.to_string(),
108 )
109 };
110
111 let message = match context {
112 Some(ctx) => format!("{ctx}: {raw_message}"),
113 None => raw_message,
114 };
115
116 Self {
117 category: spec.category(),
118 code: spec.code(),
119 client_message: spec.client_message(),
120 sqlstate,
121 constraint,
122 message,
123 source: Some(Arc::new(error)),
124 }
125 }
126
127 pub(crate) fn from_sqlx(error: sqlx::Error, context: Option<&str>) -> Self {
128 Self::from_sqlx_with_constraint_classifier(error, context, |_| None)
129 }
130
131 #[must_use]
132 pub const fn category(&self) -> QueryErrorCategory {
133 self.category
134 }
135
136 #[must_use]
137 pub const fn code(&self) -> &'static str {
138 self.code
139 }
140
141 #[must_use]
142 pub const fn client_message(&self) -> &'static str {
143 self.client_message
144 }
145
146 #[must_use]
147 pub fn sqlstate(&self) -> Option<&str> {
148 self.sqlstate.as_deref()
149 }
150
151 #[must_use]
152 pub fn constraint(&self) -> Option<&str> {
153 self.constraint.as_deref()
154 }
155
156 #[must_use]
157 pub fn internal_message(&self) -> &str {
158 &self.message
159 }
160
161 #[must_use]
162 pub fn source_arc(&self) -> Option<Arc<sqlx::Error>> {
163 self.source.clone()
164 }
165
166 #[must_use]
167 pub fn reclassified_with_constraint_classifier<F>(mut self, classify_constraint: F) -> Self
168 where
169 F: Fn(&str) -> Option<FrameworkConstraintSpec>,
170 {
171 let Some(spec) = self.constraint.as_deref().and_then(classify_constraint) else {
172 return self;
173 };
174
175 self.category = spec.category();
176 self.code = spec.code();
177 self.client_message = spec.client_message();
178 self
179 }
180}
181
182impl fmt::Display for QueryError {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 write!(f, "{}", self.message)
185 }
186}
187
188impl std::error::Error for QueryError {
189 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
190 self.source
191 .as_deref()
192 .map(|source| source as &(dyn std::error::Error + 'static))
193 }
194}
195
196#[derive(Debug, Clone, Copy)]
197struct QueryErrorSpec {
198 category: QueryErrorCategory,
199 code: &'static str,
200 client_message: &'static str,
201}
202
203impl QueryErrorSpec {
204 const fn conflict(code: &'static str, client_message: &'static str) -> Self {
205 Self {
206 category: QueryErrorCategory::Conflict,
207 code,
208 client_message,
209 }
210 }
211
212 const fn validation(code: &'static str, client_message: &'static str) -> Self {
213 Self {
214 category: QueryErrorCategory::Validation,
215 code,
216 client_message,
217 }
218 }
219
220 const fn forbidden(code: &'static str, client_message: &'static str) -> Self {
221 Self {
222 category: QueryErrorCategory::Forbidden,
223 code,
224 client_message,
225 }
226 }
227
228 const fn internal() -> Self {
229 Self {
230 category: QueryErrorCategory::Internal,
231 code: "db.query_failed",
232 client_message: "Database operation failed.",
233 }
234 }
235}
236
237impl From<QueryErrorSpec> for FrameworkConstraintSpec {
238 fn from(spec: QueryErrorSpec) -> Self {
239 Self::new(spec.category, spec.code, spec.client_message)
240 }
241}
242
243#[must_use]
244pub fn classify_query_error(
245 kind: &ErrorKind,
246 sqlstate: Option<&str>,
247 constraint: Option<&str>,
248) -> FrameworkConstraintSpec {
249 classify_query_error_with_constraint_classifier(kind, sqlstate, constraint, |_| None)
250}
251
252#[must_use]
253pub fn classify_query_error_with_constraint_classifier<F>(
254 kind: &ErrorKind,
255 sqlstate: Option<&str>,
256 constraint: Option<&str>,
257 classify_constraint: F,
258) -> FrameworkConstraintSpec
259where
260 F: Fn(&str) -> Option<FrameworkConstraintSpec>,
261{
262 if let Some(spec) = constraint.and_then(classify_constraint) {
263 return spec;
264 }
265
266 classify_database_error(kind, sqlstate, constraint).into()
267}
268
269fn classify_database_error(
270 kind: &ErrorKind,
271 sqlstate: Option<&str>,
272 constraint: Option<&str>,
273) -> QueryErrorSpec {
274 if let Some(spec) = constraint.and_then(classify_constraint) {
275 return spec;
276 }
277
278 match (kind, sqlstate) {
279 (ErrorKind::UniqueViolation, _) | (_, Some("23505")) => {
280 QueryErrorSpec::conflict("db.unique_violation", "Resource already exists.")
281 }
282 (ErrorKind::ForeignKeyViolation, _) | (_, Some("23503")) => QueryErrorSpec::validation(
283 "db.related_resource_missing",
284 "Related resource does not exist.",
285 ),
286 (_, Some("23001")) => QueryErrorSpec::validation(
287 "db.related_resource_still_referenced",
288 "Related resource is still referenced and cannot be deleted.",
289 ),
290 (ErrorKind::CheckViolation, _) | (_, Some("23514")) => QueryErrorSpec::validation(
291 "db.business_rule_violation",
292 "Request violates a business rule.",
293 ),
294 (ErrorKind::NotNullViolation, _) | (_, Some("23502")) => {
295 QueryErrorSpec::validation("db.required_field_missing", "Required data is missing.")
296 }
297 (_, Some("42501")) => {
298 QueryErrorSpec::forbidden("db.permission_denied", "Operation is not allowed.")
299 }
300 _ => QueryErrorSpec::internal(),
301 }
302}
303
304fn classify_constraint(constraint: &str) -> Option<QueryErrorSpec> {
305 classify::classify_constraint(constraint)
306}
307
308#[must_use]
309pub fn classify_framework_constraint(constraint: &str) -> Option<FrameworkConstraintSpec> {
310 classify_constraint(constraint).map(FrameworkConstraintSpec::from)
311}
312
313#[must_use]
314pub fn has_framework_constraint_classifier(constraint: &str) -> bool {
315 classify_framework_constraint(constraint).is_some()
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn classifies_job_idempotency_constraint() {
324 let spec = classify_database_error(
325 &ErrorKind::UniqueViolation,
326 Some("23505"),
327 Some("uq_job_queue_type_idempotency_org"),
328 );
329 assert_eq!(spec.category, QueryErrorCategory::Conflict);
330 assert_eq!(spec.code, "job.already_enqueued");
331 }
332
333 #[test]
334 fn classifies_global_job_idempotency_constraint() {
335 let spec = classify_database_error(
336 &ErrorKind::UniqueViolation,
337 Some("23505"),
338 Some("uq_job_queue_type_idempotency_global"),
339 );
340 assert_eq!(spec.category, QueryErrorCategory::Conflict);
341 assert_eq!(spec.code, "job.already_enqueued");
342 }
343
344 #[test]
345 fn classifies_workflow_idempotency_constraint() {
346 let spec = classify_database_error(
347 &ErrorKind::UniqueViolation,
348 Some("23505"),
349 Some("uq_workflow_runs_type_idempotency_org"),
350 );
351 assert_eq!(spec.category, QueryErrorCategory::Conflict);
352 assert_eq!(spec.code, "workflow.already_enqueued");
353 }
354
355 #[test]
356 fn classifies_global_workflow_idempotency_constraint() {
357 let spec = classify_database_error(
358 &ErrorKind::UniqueViolation,
359 Some("23505"),
360 Some("uq_workflow_runs_type_idempotency_global"),
361 );
362 assert_eq!(spec.category, QueryErrorCategory::Conflict);
363 assert_eq!(spec.code, "workflow.already_enqueued");
364 }
365
366 #[test]
367 fn classifies_job_definition_fk_constraint() {
368 let spec = classify_database_error(
369 &ErrorKind::ForeignKeyViolation,
370 Some("23503"),
371 Some("fk_job_queue_job_type"),
372 );
373 assert_eq!(spec.category, QueryErrorCategory::Validation);
374 assert_eq!(spec.code, "job.definition_not_found");
375 }
376
377 #[test]
378 fn classifies_job_runtime_config_definition_fk_constraint() {
379 let spec = classify_database_error(
380 &ErrorKind::ForeignKeyViolation,
381 Some("23503"),
382 Some("fk_job_runtime_configs_job_type"),
383 );
384 assert_eq!(spec.category, QueryErrorCategory::Validation);
385 assert_eq!(spec.code, "job.definition_not_found");
386 }
387
388 #[test]
389 fn classifies_job_organization_fk_constraint() {
390 let spec = classify_database_error(
391 &ErrorKind::ForeignKeyViolation,
392 Some("23503"),
393 Some("fk_job_queue_organization"),
394 );
395 assert_eq!(spec.category, QueryErrorCategory::Validation);
396 assert_eq!(spec.code, "job.organization_not_found");
397 }
398
399 #[test]
400 fn classifies_workflow_linkage_symmetry_constraint() {
401 let spec = classify_database_error(
402 &ErrorKind::CheckViolation,
403 Some("23514"),
404 Some("os_workflow_job_linkage_symmetry"),
405 );
406 assert_eq!(spec.category, QueryErrorCategory::Validation);
407 assert_eq!(spec.code, "workflow.linkage_symmetry_violation");
408 }
409
410 #[test]
411 fn classifies_workflow_linkage_symmetry_trigger_table_constraint() {
412 let spec = classify_database_error(
413 &ErrorKind::CheckViolation,
414 Some("23514"),
415 Some("os_workflow_job_linkage_symmetry_trigger_table"),
416 );
417 assert_eq!(spec.category, QueryErrorCategory::Validation);
418 assert_eq!(spec.code, "workflow.linkage_symmetry_trigger_table_invalid");
419 }
420
421 #[test]
422 fn classifies_external_gate_downgrade_blocked_constraint() {
423 let spec = classify_database_error(
424 &ErrorKind::CheckViolation,
425 Some("23514"),
426 Some("os_workflow_external_gate_downgrade_waiting_runs_exist"),
427 );
428 assert_eq!(spec.category, QueryErrorCategory::Validation);
429 assert_eq!(spec.code, "workflow.external_gate_downgrade_blocked");
430 }
431
432 #[test]
433 fn custom_constraint_classifier_takes_precedence() {
434 let spec = classify_query_error_with_constraint_classifier(
435 &ErrorKind::UniqueViolation,
436 Some("23505"),
437 Some("os_custom_override"),
438 |constraint| {
439 (constraint == "os_custom_override").then_some(FrameworkConstraintSpec::new(
440 QueryErrorCategory::Forbidden,
441 "custom.override",
442 "Custom override wins.",
443 ))
444 },
445 );
446 assert_eq!(spec.category(), QueryErrorCategory::Forbidden);
447 assert_eq!(spec.code(), "custom.override");
448 assert_eq!(spec.client_message(), "Custom override wins.");
449 }
450
451 #[test]
452 fn classifies_permission_denied() {
453 let spec = classify_database_error(&ErrorKind::Other, Some("42501"), None);
454 assert_eq!(spec.category, QueryErrorCategory::Forbidden);
455 assert_eq!(spec.code, "db.permission_denied");
456 }
457
458 #[test]
459 fn falls_back_to_internal_for_unmapped_errors() {
460 let spec = classify_database_error(&ErrorKind::Other, Some("99999"), Some("not_mapped"));
461 assert_eq!(spec.category, QueryErrorCategory::Internal);
462 assert_eq!(spec.code, "db.query_failed");
463 }
464}