Skip to main content

teaql_runtime/
context.rs

1use std::any::{Any, TypeId};
2use std::collections::{BTreeMap, HashMap};
3use std::future::Future;
4
5use std::pin::Pin;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, Condvar, Mutex, OnceLock};
8use std::time::{Duration, Instant, SystemTime};
9
10use teaql_core::{EntityDescriptor, UpdateCommand, Value};
11use teaql_sql::{CompiledQuery, DatabaseKind};
12
13use crate::{
14    local_id_generator, CheckObjectStatus, CheckResult, CheckResults, CheckerRegistry,
15    ContextError, EntityDataServiceBehavior, EntityDataServiceBehaviorRegistry, EntityGraphBuilder,
16    EntityRegistry, GraphNode, InMemoryEntityGraphDecoderRegistry, InternalIdGenerator, Language,
17    MetadataStore, ObjectLocation, RawAuditEvent, RawAuditEventSink, RequestPolicy, RuntimeError,
18};
19use crate::{DataServiceError, EntityRoot};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ContextEntityRef {
23    pub entity_type: String,
24    pub id: u64,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ContextRootError {
29    pub expected_entity_type: String,
30    pub actual_root: Option<ContextEntityRef>,
31}
32
33impl std::fmt::Display for ContextRootError {
34    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match &self.actual_root {
36            None => write!(
37                formatter,
38                "active root {} is missing from UserContext",
39                self.expected_entity_type
40            ),
41            Some(actual) => write!(
42                formatter,
43                "active root type is {}, expected {}",
44                actual.entity_type, self.expected_entity_type
45            ),
46        }
47    }
48}
49
50impl std::error::Error for ContextRootError {}
51
52#[cfg(test)]
53mod active_root_tests {
54    use super::UserContext;
55
56    #[test]
57    fn active_root_is_typed_and_fails_closed() {
58        let context = UserContext::new().with_active_root("Tenant", 42);
59        assert_eq!(context.require_active_root("Tenant").unwrap().id, 42);
60        assert!(context.require_active_root("Organization").is_err());
61        assert!(UserContext::new().require_active_root("Tenant").is_err());
62    }
63}
64
65#[derive(Debug, Clone, PartialEq)]
66pub struct ContinuousPageCursor {
67    pub cursor_id: String,
68    pub query_key: String,
69    pub entity: String,
70    pub direction: teaql_core::SortDirection,
71    pub boundary: Value,
72    pub page_size: u64,
73    pub next_offset: u64,
74    pub expires_at: SystemTime,
75}
76
77#[async_trait::async_trait]
78pub trait ContinuousPageCursorStore: Send + Sync + 'static {
79    async fn get(
80        &self,
81        query_key: &str,
82        target_offset: u64,
83    ) -> Result<Option<ContinuousPageCursor>, String>;
84    async fn put(&self, cursor: ContinuousPageCursor) -> Result<(), String>;
85    async fn invalidate(&self, query_key: &str) -> Result<(), String>;
86}
87
88pub struct InMemoryContinuousPageCursorStore {
89    cursors: Mutex<HashMap<String, ContinuousPageCursor>>,
90    max_entries: usize,
91}
92
93impl Default for InMemoryContinuousPageCursorStore {
94    fn default() -> Self {
95        Self {
96            cursors: Mutex::new(HashMap::new()),
97            max_entries: 4096,
98        }
99    }
100}
101
102#[async_trait::async_trait]
103impl ContinuousPageCursorStore for InMemoryContinuousPageCursorStore {
104    async fn get(
105        &self,
106        query_key: &str,
107        target_offset: u64,
108    ) -> Result<Option<ContinuousPageCursor>, String> {
109        let key = format!("{query_key}:{target_offset}");
110        let mut cursors = self.cursors.lock().map_err(|e| e.to_string())?;
111        if cursors
112            .get(&key)
113            .is_some_and(|cursor| cursor.expires_at <= SystemTime::now())
114        {
115            cursors.remove(&key);
116        }
117        Ok(cursors.get(&key).cloned())
118    }
119
120    async fn put(&self, cursor: ContinuousPageCursor) -> Result<(), String> {
121        let key = format!("{}:{}", cursor.query_key, cursor.next_offset);
122        let mut cursors = self.cursors.lock().map_err(|e| e.to_string())?;
123        if cursors.len() >= self.max_entries {
124            if let Some(expired_or_oldest) = cursors
125                .iter()
126                .min_by_key(|(_, value)| value.expires_at)
127                .map(|(key, _)| key.clone())
128            {
129                cursors.remove(&expired_or_oldest);
130            }
131        }
132        cursors.insert(key, cursor);
133        Ok(())
134    }
135
136    async fn invalidate(&self, query_key: &str) -> Result<(), String> {
137        let prefix = format!("{query_key}:");
138        self.cursors
139            .lock()
140            .map_err(|e| e.to_string())?
141            .retain(|key, _| !key.starts_with(&prefix));
142        Ok(())
143    }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum SqlLogOperation {
148    Select,
149    Insert,
150    Update,
151    Delete,
152    Recover,
153}
154
155impl SqlLogOperation {
156    pub fn is_select(self) -> bool {
157        matches!(self, Self::Select)
158    }
159
160    pub fn is_mutation(self) -> bool {
161        !self.is_select()
162    }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
166pub struct SqlLogOptions {
167    pub select: bool,
168    pub mutation: bool,
169}
170
171impl SqlLogOptions {
172    pub fn disabled() -> Self {
173        Self {
174            select: false,
175            mutation: false,
176        }
177    }
178
179    pub fn select_only() -> Self {
180        Self {
181            select: true,
182            mutation: false,
183        }
184    }
185
186    pub fn mutation_only() -> Self {
187        Self {
188            select: false,
189            mutation: true,
190        }
191    }
192
193    pub fn all() -> Self {
194        Self {
195            select: true,
196            mutation: true,
197        }
198    }
199
200    pub fn enabled_for(self, operation: SqlLogOperation) -> bool {
201        match operation.is_select() {
202            true => self.select,
203            false => self.mutation,
204        }
205    }
206}
207
208#[derive(Debug, Clone, PartialEq)]
209pub struct SqlLogEntry {
210    pub operation: SqlLogOperation,
211    pub sql: String,
212    pub params: Vec<Value>,
213    pub debug_sql: String,
214    pub pretty_sql: String,
215    pub started_at: SystemTime,
216    pub ended_at: SystemTime,
217    pub elapsed: Duration,
218    pub result_count: Option<usize>,
219    pub result_type: Option<String>,
220    pub affected_rows: Option<u64>,
221    pub result_summary: String,
222}
223
224#[derive(Debug, Clone, PartialEq)]
225pub struct UnifiedLogEntry {
226    pub timestamp: SystemTime,
227    pub user_identifier: Option<String>,
228    pub trace_chain: Vec<teaql_core::TraceNode>,
229    pub payload: LogPayload,
230}
231
232#[derive(Debug, Clone, PartialEq)]
233pub enum LogPayload {
234    Sql(SqlLogEntry),
235    Info(InfoLogEntry),
236}
237
238#[derive(Debug, Clone, PartialEq)]
239pub struct InfoLogEntry {
240    pub message: String,
241}
242
243#[derive(Clone, Default)]
244pub struct UnifiedLogBuffer {
245    pub entries: std::sync::Arc<Mutex<Vec<UnifiedLogEntry>>>,
246}
247
248pub trait SchemaProvider: Send + Sync {
249    fn ensure_schema<'a>(
250        &'a self,
251        context: &'a UserContext,
252    ) -> Pin<Box<dyn Future<Output = Result<(), RuntimeError>> + Send + 'a>>;
253}
254
255pub struct UserContext {
256    active_root: Option<ContextEntityRef>,
257    pub(crate) metadata: Option<Box<dyn MetadataStore>>,
258    pub(crate) entity_registry: Option<Box<dyn EntityRegistry>>,
259    pub(crate) entity_graph_decoders: InMemoryEntityGraphDecoderRegistry,
260    pub(crate) entity_data_service_behavior_registry:
261        Option<Box<dyn EntityDataServiceBehaviorRegistry>>,
262    pub(crate) request_policy: Option<Box<dyn RequestPolicy>>,
263    pub(crate) checker_registry: Option<Box<dyn CheckerRegistry>>,
264    pub(crate) event_sink: Option<Box<dyn RawAuditEventSink>>,
265    pub(crate) custom_event_sink: Option<Box<dyn crate::SafeAuditEventSink>>,
266    pub(crate) internal_id_generator: Option<Box<dyn InternalIdGenerator>>,
267    schema_provider: Option<Box<dyn SchemaProvider>>,
268    language: Language,
269    i18n_catalog: Arc<crate::I18nCatalog>,
270    typed_resources: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
271    named_resources: BTreeMap<String, Box<dyn Any + Send + Sync>>,
272    locals: BTreeMap<String, Value>,
273    pub(crate) initial_graphs: Vec<GraphNode>,
274    pub(crate) root_graphs: Vec<GraphNode>,
275    entity_root: EntityRoot,
276    sql_log_options: SqlLogOptions,
277    sql_log_entries: Mutex<Vec<SqlLogEntry>>,
278    user_identifier: Option<String>,
279    timezone: Option<String>,
280    trace_id: String,
281    continuous_page_cursor_store: std::sync::Arc<dyn ContinuousPageCursorStore>,
282    continuous_page_observation: Mutex<(String, Option<String>)>,
283    local_lock_owner: u64,
284    remote_lock_owner: String,
285    runtime_telemetry: Arc<dyn crate::RuntimeTelemetry>,
286}
287
288#[derive(Clone, Copy)]
289struct LocalLockEntry {
290    owner: u64,
291    expires_at: Option<Instant>,
292}
293
294#[derive(Default)]
295struct ProcessLocalLocks {
296    entries: Mutex<HashMap<String, LocalLockEntry>>,
297    changed: Condvar,
298}
299
300static PROCESS_LOCAL_LOCKS: OnceLock<ProcessLocalLocks> = OnceLock::new();
301static NEXT_LOCAL_LOCK_OWNER: AtomicU64 = AtomicU64::new(1);
302
303impl Default for UserContext {
304    fn default() -> Self {
305        let pid = std::process::id();
306        let thread_id_str = format!("{:?}", std::thread::current().id());
307        let numeric_thread_id = thread_id_str
308            .strip_prefix("ThreadId(")
309            .and_then(|s| s.strip_suffix(")"))
310            .unwrap_or(&thread_id_str);
311        let os_user = std::env::var("USER")
312            .or_else(|_| std::env::var("USERNAME"))
313            .unwrap_or_else(|_| "main".to_owned());
314        let user_id = format!("{os_user}@pid-{pid}.tid-{numeric_thread_id}");
315        let owner_sequence = NEXT_LOCAL_LOCK_OWNER.fetch_add(1, Ordering::Relaxed);
316        Self {
317            active_root: None,
318            metadata: None,
319            entity_registry: None,
320            entity_graph_decoders: InMemoryEntityGraphDecoderRegistry::default(),
321            entity_data_service_behavior_registry: None,
322            request_policy: None,
323            checker_registry: None,
324            event_sink: None,
325            custom_event_sink: None,
326            internal_id_generator: None,
327            schema_provider: None,
328            language: Language::default(),
329            i18n_catalog: crate::I18nCatalog::builtin().clone(),
330            typed_resources: HashMap::new(),
331            named_resources: BTreeMap::new(),
332            locals: BTreeMap::new(),
333            initial_graphs: Vec::new(),
334            root_graphs: Vec::new(),
335            entity_root: EntityRoot::default(),
336            sql_log_options: SqlLogOptions::all(),
337            sql_log_entries: Mutex::new(Vec::new()),
338            user_identifier: Some(user_id),
339            timezone: Some("UTC".to_owned()),
340            trace_id: format!(
341                "req-{pid}-{numeric_thread_id}-{:x}",
342                std::time::SystemTime::now()
343                    .duration_since(std::time::UNIX_EPOCH)
344                    .unwrap_or_default()
345                    .as_micros()
346            ),
347            continuous_page_cursor_store: std::sync::Arc::new(
348                InMemoryContinuousPageCursorStore::default(),
349            ),
350            continuous_page_observation: Mutex::new(("DISABLED".to_owned(), None)),
351            local_lock_owner: owner_sequence,
352            remote_lock_owner: format!(
353                "teaql:{pid}:{owner_sequence}:{}",
354                SystemTime::now()
355                    .duration_since(SystemTime::UNIX_EPOCH)
356                    .unwrap_or_default()
357                    .as_nanos()
358            ),
359            runtime_telemetry: Arc::new(crate::NoopRuntimeTelemetry),
360        }
361    }
362}
363
364#[async_trait::async_trait]
365pub trait DataStore: Send + Sync + 'static {
366    async fn get(&self, key: &str) -> Option<Value>;
367    async fn put(&self, key: &str, value: Value, timeout_seconds: Option<u64>);
368    async fn remove(&self, key: &str);
369}
370
371/// Provider-neutral distributed lock boundary.
372///
373/// Implementations must associate an acquired lock with `owner_token` and
374/// release it only while that token still owns the key. A zero timeout is one
375/// non-blocking attempt; a zero expiry means no automatic lease expiry.
376#[async_trait::async_trait]
377pub trait RemoteLockProvider: Send + Sync + 'static {
378    async fn try_remote_lock(
379        &self,
380        key: &str,
381        owner_token: &str,
382        timeout_millis: u64,
383        expire_millis: u64,
384    ) -> bool;
385
386    async fn unlock_remote(&self, key: &str, owner_token: &str) -> bool;
387}
388
389#[derive(Default)]
390pub struct InMemoryDataStore {
391    cache: std::sync::RwLock<HashMap<String, (Value, Option<std::time::Instant>)>>,
392}
393
394#[async_trait::async_trait]
395impl DataStore for InMemoryDataStore {
396    async fn get(&self, key: &str) -> Option<Value> {
397        let lock = self.cache.read().unwrap();
398        if let Some((val, expires_at)) = lock.get(key) {
399            if let Some(exp) = expires_at {
400                if std::time::Instant::now() > *exp {
401                    return None;
402                }
403            }
404            return Some(val.clone());
405        }
406        None
407    }
408
409    async fn put(&self, key: &str, value: Value, timeout_seconds: Option<u64>) {
410        let mut lock = self.cache.write().unwrap();
411        let expires_at = timeout_seconds
412            .map(|secs| std::time::Instant::now() + std::time::Duration::from_secs(secs));
413        lock.insert(key.to_string(), (value, expires_at));
414    }
415
416    async fn remove(&self, key: &str) {
417        let mut lock = self.cache.write().unwrap();
418        lock.remove(key);
419    }
420}
421
422impl UserContext {
423    pub fn new() -> Self {
424        Self::default()
425    }
426
427    pub fn with_active_root(mut self, entity_type: impl Into<String>, id: u64) -> Self {
428        let entity_type = entity_type.into();
429        assert!(
430            !entity_type.trim().is_empty(),
431            "active root entity type is required"
432        );
433        assert!(id > 0, "active root id must be positive");
434        self.active_root = Some(ContextEntityRef { entity_type, id });
435        self
436    }
437
438    pub fn require_active_root(
439        &self,
440        expected_entity_type: &str,
441    ) -> Result<&ContextEntityRef, ContextRootError> {
442        match &self.active_root {
443            Some(root) if root.entity_type == expected_entity_type => Ok(root),
444            actual_root => Err(ContextRootError {
445                expected_entity_type: expected_entity_type.to_owned(),
446                actual_root: actual_root.clone(),
447            }),
448        }
449    }
450
451    pub fn with_runtime_telemetry(mut self, telemetry: Arc<dyn crate::RuntimeTelemetry>) -> Self {
452        self.runtime_telemetry = telemetry;
453        self
454    }
455
456    pub fn set_runtime_telemetry(&mut self, telemetry: Arc<dyn crate::RuntimeTelemetry>) {
457        self.runtime_telemetry = telemetry;
458    }
459
460    pub fn runtime_telemetry(&self) -> &Arc<dyn crate::RuntimeTelemetry> {
461        &self.runtime_telemetry
462    }
463
464    pub(crate) fn runtime_telemetry_is_noop(&self) -> bool {
465        self.runtime_telemetry.is_noop()
466    }
467
468    pub fn start_runtime_operation(
469        &self,
470        operation: crate::RuntimeOperation,
471    ) -> crate::FailOpenRuntimeTelemetryScope {
472        crate::start_runtime_operation(&self.runtime_telemetry, operation)
473    }
474
475    pub fn try_local_lock(&self, key: &str, timeout_millis: u64, expire_millis: u64) -> bool {
476        let locks = PROCESS_LOCAL_LOCKS.get_or_init(ProcessLocalLocks::default);
477        let deadline = Instant::now() + Duration::from_millis(timeout_millis);
478        let mut entries = locks.entries.lock().expect("local lock state poisoned");
479        loop {
480            let now = Instant::now();
481            match entries.get(key).copied() {
482                None => {
483                    entries.insert(
484                        key.to_owned(),
485                        LocalLockEntry {
486                            owner: self.local_lock_owner,
487                            expires_at: (expire_millis > 0)
488                                .then(|| now + Duration::from_millis(expire_millis)),
489                        },
490                    );
491                    return true;
492                }
493                Some(current)
494                    if current.owner == self.local_lock_owner
495                        || current.expires_at.is_some_and(|expiry| now >= expiry) =>
496                {
497                    entries.insert(
498                        key.to_owned(),
499                        LocalLockEntry {
500                            owner: self.local_lock_owner,
501                            expires_at: (expire_millis > 0)
502                                .then(|| now + Duration::from_millis(expire_millis)),
503                        },
504                    );
505                    return true;
506                }
507                Some(current) => {
508                    if timeout_millis == 0 || now >= deadline {
509                        return false;
510                    }
511                    let wake_after = current
512                        .expires_at
513                        .map(|expiry| expiry.saturating_duration_since(now))
514                        .unwrap_or_else(|| deadline.saturating_duration_since(now))
515                        .min(deadline.saturating_duration_since(now));
516                    let waited = locks
517                        .changed
518                        .wait_timeout(entries, wake_after)
519                        .expect("local lock state poisoned");
520                    entries = waited.0;
521                }
522            }
523        }
524    }
525
526    pub fn unlock_local(&self, key: &str) {
527        let locks = PROCESS_LOCAL_LOCKS.get_or_init(ProcessLocalLocks::default);
528        let mut entries = locks.entries.lock().expect("local lock state poisoned");
529        if entries
530            .get(key)
531            .is_some_and(|entry| entry.owner == self.local_lock_owner)
532        {
533            entries.remove(key);
534            locks.changed.notify_all();
535        }
536    }
537
538    /// Attempts to acquire a provider-backed distributed lock.
539    ///
540    /// A missing provider remains a no-op success, matching the optional
541    /// Remote Lock boundary in the other TeaQL runtimes. Install an
542    /// `Arc<dyn RemoteLockProvider>` resource to enable distributed exclusion.
543    pub async fn try_remote_lock(
544        &self,
545        key: &str,
546        timeout_millis: u64,
547        expire_millis: u64,
548    ) -> bool {
549        match self.get_resource::<Arc<dyn RemoteLockProvider>>() {
550            Some(provider) => {
551                provider
552                    .try_remote_lock(key, &self.remote_lock_owner, timeout_millis, expire_millis)
553                    .await
554            }
555            None => true,
556        }
557    }
558
559    /// Releases a distributed lock only when this context still owns it.
560    pub async fn unlock_remote(&self, key: &str) -> bool {
561        match self.get_resource::<Arc<dyn RemoteLockProvider>>() {
562            Some(provider) => provider.unlock_remote(key, &self.remote_lock_owner).await,
563            None => true,
564        }
565    }
566
567    pub fn user_identifier(&self) -> Option<&str> {
568        self.user_identifier.as_deref()
569    }
570
571    pub fn set_user_identifier(&mut self, user_identifier: impl Into<String>) {
572        self.user_identifier = Some(user_identifier.into());
573    }
574
575    pub fn set_continuous_page_cursor_store(
576        &mut self,
577        store: std::sync::Arc<dyn ContinuousPageCursorStore>,
578    ) {
579        self.continuous_page_cursor_store = store;
580    }
581
582    pub fn continuous_page_plan(&self) -> Option<String> {
583        self.continuous_page_observation
584            .lock()
585            .ok()
586            .map(|value| value.0.clone())
587    }
588
589    pub fn continuous_page_cursor_id(&self) -> Option<String> {
590        self.continuous_page_observation
591            .lock()
592            .ok()
593            .and_then(|value| value.1.clone())
594    }
595
596    pub(crate) fn observe_continuous_page(
597        &self,
598        plan: impl Into<String>,
599        cursor_id: Option<String>,
600    ) {
601        if let Ok(mut observation) = self.continuous_page_observation.lock() {
602            *observation = (plan.into(), cursor_id);
603        }
604    }
605
606    pub(crate) fn continuous_page_cursor_store(&self) -> &dyn ContinuousPageCursorStore {
607        self.continuous_page_cursor_store.as_ref()
608    }
609
610    pub fn with_user_identifier(mut self, user_identifier: impl Into<String>) -> Self {
611        self.user_identifier = Some(user_identifier.into());
612        self
613    }
614
615    pub fn set_user_identifier_option(&mut self, user_identifier: Option<String>) {
616        self.user_identifier = user_identifier;
617    }
618
619    pub fn with_user_identifier_option(mut self, user_identifier: Option<String>) -> Self {
620        self.user_identifier = user_identifier;
621        self
622    }
623
624    pub fn timezone(&self) -> Option<&str> {
625        self.timezone.as_deref()
626    }
627
628    pub fn set_timezone(&mut self, timezone: impl Into<String>) {
629        self.timezone = Some(timezone.into());
630    }
631
632    pub fn with_timezone(mut self, timezone: impl Into<String>) -> Self {
633        self.timezone = Some(timezone.into());
634        self
635    }
636
637    pub fn trace_id(&self) -> &str {
638        &self.trace_id
639    }
640
641    pub fn set_trace_id(&mut self, trace_id: impl Into<String>) {
642        self.trace_id = trace_id.into();
643    }
644
645    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
646        self.trace_id = trace_id.into();
647        self
648    }
649
650    pub fn with_module(mut self, module: crate::RuntimeModule) -> Self {
651        module.apply_to(&mut self);
652        self
653    }
654
655    pub fn entity_root(&self) -> EntityRoot {
656        self.entity_root.clone()
657    }
658
659    pub fn initial_graphs(&self) -> &[GraphNode] {
660        &self.initial_graphs
661    }
662
663    pub fn set_initial_graphs(&mut self, graphs: Vec<GraphNode>) {
664        self.initial_graphs = graphs;
665    }
666
667    pub fn root_graphs(&self) -> &[GraphNode] {
668        &self.root_graphs
669    }
670
671    pub fn set_root_graphs(&mut self, graphs: Vec<GraphNode>) {
672        self.root_graphs = graphs;
673    }
674
675    pub fn with_metadata(mut self, metadata: impl MetadataStore + 'static) -> Self {
676        self.metadata = Some(Box::new(metadata));
677        self
678    }
679
680    pub fn set_metadata(&mut self, metadata: impl MetadataStore + 'static) {
681        self.metadata = Some(Box::new(metadata));
682    }
683
684    pub fn with_entity_registry(mut self, registry: impl EntityRegistry + 'static) -> Self {
685        self.entity_registry = Some(Box::new(registry));
686        self
687    }
688
689    pub fn set_entity_registry(&mut self, registry: impl EntityRegistry + 'static) {
690        self.entity_registry = Some(Box::new(registry));
691    }
692
693    pub fn set_entity_graph_decoder_registry(
694        &mut self,
695        registry: InMemoryEntityGraphDecoderRegistry,
696    ) {
697        self.entity_graph_decoders = registry;
698    }
699
700    pub(crate) fn has_entity_graph_decoder(&self, entity: &str) -> bool {
701        self.entity_graph_decoders.contains(entity)
702    }
703
704    pub(crate) fn decode_compact_entity_into_graph(
705        &self,
706        entity: &str,
707        row: teaql_core::CompactRow,
708        root: &EntityRoot,
709        graph: &mut EntityGraphBuilder,
710    ) -> Result<(), teaql_core::EntityError> {
711        self.entity_graph_decoders
712            .decode_compact(entity, row, root, graph)
713    }
714
715    pub(crate) fn decode_compact_entity_list_into_graph(
716        &self,
717        entity: &str,
718        rows: Vec<teaql_core::CompactRow>,
719        root: &EntityRoot,
720        graph: &mut EntityGraphBuilder,
721        owner_entity: &str,
722        owner_id: u64,
723        relation: &str,
724    ) -> Result<(), teaql_core::EntityError> {
725        self.entity_graph_decoders.decode_compact_list(
726            entity,
727            rows,
728            root,
729            graph,
730            owner_entity,
731            owner_id,
732            relation,
733        )
734    }
735
736    pub(crate) fn decode_compact_entity_option_into_graph(
737        &self,
738        entity: &str,
739        rows: Vec<teaql_core::CompactRow>,
740        root: &EntityRoot,
741        graph: &mut EntityGraphBuilder,
742        owner_entity: &str,
743        owner_id: u64,
744        relation: &str,
745    ) -> Result<(), teaql_core::EntityError> {
746        self.entity_graph_decoders.decode_compact_option(
747            entity,
748            rows,
749            root,
750            graph,
751            owner_entity,
752            owner_id,
753            relation,
754        )
755    }
756
757    pub fn with_entity_data_service_behavior_registry(
758        mut self,
759        registry: impl EntityDataServiceBehaviorRegistry + 'static,
760    ) -> Self {
761        self.entity_data_service_behavior_registry = Some(Box::new(registry));
762        self
763    }
764
765    pub fn set_entity_data_service_behavior_registry(
766        &mut self,
767        registry: impl EntityDataServiceBehaviorRegistry + 'static,
768    ) {
769        self.entity_data_service_behavior_registry = Some(Box::new(registry));
770    }
771
772    pub fn with_request_policy(mut self, policy: impl RequestPolicy + 'static) -> Self {
773        self.request_policy = Some(Box::new(policy));
774        self
775    }
776
777    pub fn set_request_policy(&mut self, policy: impl RequestPolicy + 'static) {
778        self.request_policy = Some(Box::new(policy));
779    }
780
781    pub fn clear_request_policy(&mut self) {
782        self.request_policy = None;
783    }
784
785    pub fn with_checker_registry(mut self, registry: impl CheckerRegistry + 'static) -> Self {
786        self.checker_registry = Some(Box::new(registry));
787        self
788    }
789
790    pub fn set_checker_registry(&mut self, registry: impl CheckerRegistry + 'static) {
791        self.checker_registry = Some(Box::new(registry));
792    }
793
794    pub(crate) fn with_event_sink(mut self, sink: impl RawAuditEventSink + 'static) -> Self {
795        self.event_sink = Some(Box::new(sink));
796        self
797    }
798
799    pub(crate) fn set_event_sink(&mut self, sink: impl RawAuditEventSink + 'static) {
800        self.event_sink = Some(Box::new(sink));
801    }
802
803    pub fn with_custom_event_sink(
804        mut self,
805        sink: impl crate::SafeAuditEventSink + 'static,
806    ) -> Self {
807        self.custom_event_sink = Some(Box::new(sink));
808        self
809    }
810
811    pub fn set_custom_event_sink(&mut self, sink: impl crate::SafeAuditEventSink + 'static) {
812        self.custom_event_sink = Some(Box::new(sink));
813    }
814
815    pub fn with_internal_id_generator(
816        mut self,
817        generator: impl InternalIdGenerator + 'static,
818    ) -> Self {
819        self.internal_id_generator = Some(Box::new(generator));
820        self
821    }
822
823    pub fn set_internal_id_generator(&mut self, generator: impl InternalIdGenerator + 'static) {
824        self.internal_id_generator = Some(Box::new(generator));
825    }
826
827    pub fn with_schema_provider(mut self, provider: impl SchemaProvider + 'static) -> Self {
828        self.schema_provider = Some(Box::new(provider));
829        self
830    }
831
832    pub fn set_schema_provider(&mut self, provider: impl SchemaProvider + 'static) {
833        self.schema_provider = Some(Box::new(provider));
834    }
835
836    pub async fn ensure_schema(&self) -> Result<(), RuntimeError> {
837        let provider = self
838            .schema_provider
839            .as_ref()
840            .ok_or_else(|| RuntimeError::Schema("missing schema provider".to_owned()))?;
841        provider.ensure_schema(self).await
842    }
843
844    pub fn with_language(mut self, language: Language) -> Self {
845        self.language = language;
846        self
847    }
848
849    pub fn set_language(&mut self, language: Language) {
850        self.language = language;
851    }
852
853    pub fn with_i18n_catalog(mut self, catalog: Arc<crate::I18nCatalog>) -> Self {
854        self.i18n_catalog = catalog;
855        self
856    }
857
858    pub fn set_i18n_catalog(&mut self, catalog: Arc<crate::I18nCatalog>) {
859        self.i18n_catalog = catalog;
860    }
861
862    pub fn with_sql_log_options(mut self, options: SqlLogOptions) -> Self {
863        self.sql_log_options = options;
864        self
865    }
866
867    pub fn set_sql_log_options(&mut self, options: SqlLogOptions) {
868        self.sql_log_options = options;
869    }
870
871    pub fn enable_select_sql_log(&mut self) {
872        self.sql_log_options.select = true;
873    }
874
875    pub fn enable_mutation_sql_log(&mut self) {
876        self.sql_log_options.mutation = true;
877    }
878
879    pub fn enable_all_sql_log(&mut self) {
880        self.sql_log_options = SqlLogOptions::all();
881    }
882
883    pub fn disable_sql_log(&mut self) {
884        self.sql_log_options = SqlLogOptions::disabled();
885        self.clear_sql_logs();
886    }
887
888    pub fn sql_log_options(&self) -> SqlLogOptions {
889        self.sql_log_options
890    }
891
892    pub fn sql_logs(&self) -> Vec<SqlLogEntry> {
893        self.sql_log_entries
894            .lock()
895            .map(|entries| entries.clone())
896            .unwrap_or_default()
897    }
898
899    pub fn clear_sql_logs(&self) {
900        if let Ok(mut entries) = self.sql_log_entries.lock() {
901            entries.clear();
902        }
903    }
904
905    pub(crate) fn record_sql_log(
906        &self,
907        operation: SqlLogOperation,
908        query: &CompiledQuery,
909        database_kind: DatabaseKind,
910        started_at: SystemTime,
911        ended_at: SystemTime,
912        elapsed: Duration,
913        result_count: Option<usize>,
914        result_type: Option<String>,
915        affected_rows: Option<u64>,
916        trace_chain: Vec<teaql_core::TraceNode>,
917    ) {
918        if !self.sql_log_options.enabled_for(operation) {
919            return;
920        }
921        let debug_sql = query.debug_sql(database_kind);
922        let result_summary = sql_result_summary(
923            operation,
924            result_count,
925            result_type.as_deref(),
926            affected_rows,
927            &debug_sql,
928        );
929
930        let sql_log_entry = SqlLogEntry {
931            operation,
932            sql: query.sql.clone(),
933            params: query.params.clone(),
934            pretty_sql: pretty_sql(&debug_sql),
935            debug_sql: debug_sql.clone(),
936            started_at,
937            ended_at,
938            elapsed,
939            result_summary: result_summary.clone(),
940            result_count,
941            result_type,
942            affected_rows,
943        };
944
945        if let Ok(mut entries) = self.sql_log_entries.lock() {
946            // Keep sql_log_entries backwards-compatible for now if needed,
947            // wait, we modified SqlLogEntry. We can just push it directly since we removed comment.
948            // Wait, we need to push a cloned SqlLogEntry since it doesn't have comment.
949            entries.push(sql_log_entry.clone());
950        }
951
952        if let Some(buf) = self.get_resource::<UnifiedLogBuffer>() {
953            if let Ok(mut entries) = buf.entries.lock() {
954                entries.push(UnifiedLogEntry {
955                    timestamp: started_at,
956                    user_identifier: self.user_identifier.clone(),
957                    trace_chain: trace_chain.clone(),
958                    payload: LogPayload::Sql(sql_log_entry.clone()),
959                });
960            }
961        }
962
963        crate::log_formatter::LogManager::write_sql_log(&trace_chain, &sql_log_entry);
964    }
965
966    pub(crate) fn record_metadata_log(&self, metadata: &teaql_data_service::ExecutionMetadata) {
967        let operation = match metadata.operation {
968            teaql_data_service::DataServiceOperation::Query => SqlLogOperation::Select,
969            teaql_data_service::DataServiceOperation::Insert => SqlLogOperation::Insert,
970            teaql_data_service::DataServiceOperation::Update => SqlLogOperation::Update,
971            teaql_data_service::DataServiceOperation::Delete => SqlLogOperation::Delete,
972            teaql_data_service::DataServiceOperation::Recover => SqlLogOperation::Update,
973            teaql_data_service::DataServiceOperation::Batch => SqlLogOperation::Update,
974            teaql_data_service::DataServiceOperation::Schema => SqlLogOperation::Update,
975        };
976        if !self.sql_log_options.enabled_for(operation) {
977            return;
978        }
979        if let Some(debug_sql) = &metadata.debug_query {
980            let sql_log_entry = SqlLogEntry {
981                operation,
982                sql: metadata.parameterized_query.clone().unwrap_or_default(),
983                params: metadata.params.clone(),
984                pretty_sql: pretty_sql(debug_sql),
985                debug_sql: debug_sql.clone(),
986                started_at: metadata.started_at,
987                ended_at: metadata.ended_at,
988                elapsed: metadata
989                    .ended_at
990                    .duration_since(metadata.started_at)
991                    .unwrap_or_default(),
992                result_count: metadata.result_count,
993                result_type: None, // Not directly available
994                affected_rows: metadata.affected_rows,
995                result_summary: String::new(), // We can synthesize this if needed, or leave it empty/basic
996            };
997
998            // synthesize a summary for the log
999            let mut summary = String::new();
1000            if let Some(c) = metadata.result_count {
1001                summary = format!("{} rows returned", c);
1002            } else if let Some(a) = metadata.affected_rows {
1003                summary = format!("{} rows affected", a);
1004            }
1005
1006            let mut final_entry = sql_log_entry;
1007            final_entry.result_summary = summary;
1008
1009            if let Ok(mut entries) = self.sql_log_entries.lock() {
1010                entries.push(final_entry.clone());
1011            }
1012
1013            if let Some(buf) = self.get_resource::<UnifiedLogBuffer>() {
1014                if let Ok(mut entries) = buf.entries.lock() {
1015                    entries.push(UnifiedLogEntry {
1016                        timestamp: metadata.started_at,
1017                        user_identifier: self.user_identifier.clone(),
1018                        trace_chain: metadata.trace_chain.clone(),
1019                        payload: LogPayload::Sql(final_entry.clone()),
1020                    });
1021                }
1022            }
1023
1024            crate::log_formatter::LogManager::write_sql_log(&metadata.trace_chain, &final_entry);
1025        }
1026    }
1027
1028    pub fn language(&self) -> Language {
1029        self.language
1030    }
1031
1032    pub fn set_language_code(&mut self, code: &str) -> Result<(), RuntimeError> {
1033        let Some(language) = Language::from_code(code) else {
1034            return Err(RuntimeError::UnsupportedLocale(code.to_owned()));
1035        };
1036        self.language = language;
1037        Ok(())
1038    }
1039
1040    pub fn set_locale_code(&mut self, code: &str) -> Result<(), RuntimeError> {
1041        self.set_language_code(code)
1042    }
1043
1044    pub fn generate_id(&self, entity: &str) -> Result<Option<u64>, RuntimeError> {
1045        self.internal_id_generator
1046            .as_ref()
1047            .map(|generator| generator.generate_id(entity))
1048            .transpose()
1049    }
1050
1051    pub fn next_id(&self, entity: &str) -> Result<u64, RuntimeError> {
1052        match self.generate_id(entity)? {
1053            Some(id) => Ok(id),
1054            None => local_id_generator().generate_id(entity),
1055        }
1056    }
1057
1058    pub fn entity(&self, name: &str) -> Option<&EntityDescriptor> {
1059        self.metadata
1060            .as_ref()
1061            .and_then(|metadata| metadata.entity(name))
1062    }
1063
1064    pub fn all_entities(&self) -> Vec<&EntityDescriptor> {
1065        self.metadata
1066            .as_ref()
1067            .map(|metadata| metadata.all_entities())
1068            .unwrap_or_default()
1069    }
1070
1071    pub fn require_entity(&self, name: &str) -> Result<&EntityDescriptor, RuntimeError> {
1072        self.entity(name)
1073            .ok_or_else(|| RuntimeError::MissingEntity(name.to_owned()))
1074    }
1075
1076    pub fn insert_resource<T>(&mut self, resource: T)
1077    where
1078        T: Send + Sync + 'static,
1079    {
1080        self.typed_resources
1081            .insert(TypeId::of::<T>(), Box::new(resource));
1082    }
1083
1084    pub fn get_resource<T>(&self) -> Option<&T>
1085    where
1086        T: Send + Sync + 'static,
1087    {
1088        self.typed_resources
1089            .get(&TypeId::of::<T>())
1090            .and_then(|value| value.downcast_ref::<T>())
1091    }
1092
1093    pub fn require_resource<T>(&self) -> Result<&T, ContextError>
1094    where
1095        T: Send + Sync + 'static,
1096    {
1097        self.get_resource::<T>()
1098            .ok_or(ContextError::MissingTypedResource(
1099                std::any::type_name::<T>(),
1100            ))
1101    }
1102
1103    pub fn insert_named_resource<T>(&mut self, name: impl Into<String>, resource: T)
1104    where
1105        T: Send + Sync + 'static,
1106    {
1107        self.named_resources.insert(name.into(), Box::new(resource));
1108    }
1109
1110    pub fn get_named_resource<T>(&self, name: &str) -> Option<&T>
1111    where
1112        T: Send + Sync + 'static,
1113    {
1114        self.named_resources
1115            .get(name)
1116            .and_then(|value| value.downcast_ref::<T>())
1117    }
1118
1119    pub fn require_named_resource<T>(&self, name: &str) -> Result<&T, ContextError>
1120    where
1121        T: Send + Sync + 'static,
1122    {
1123        self.get_named_resource::<T>(name)
1124            .ok_or_else(|| ContextError::MissingResource(name.to_owned()))
1125    }
1126
1127    pub fn put_local(&mut self, key: impl Into<String>, value: impl Into<Value>) {
1128        self.locals.insert(key.into(), value.into());
1129    }
1130
1131    pub fn local(&self, key: &str) -> Option<&Value> {
1132        self.locals.get(key)
1133    }
1134
1135    pub fn remove_local(&mut self, key: &str) -> Option<Value> {
1136        self.locals.remove(key)
1137    }
1138
1139    pub fn has_entity_data_service(&self, entity: &str) -> bool {
1140        let in_registry = self
1141            .entity_registry
1142            .as_ref()
1143            .map(|registry| registry.contains(entity))
1144            .unwrap_or(false);
1145        in_registry || self.entity(entity).is_some()
1146    }
1147
1148    pub fn entity_data_service_behavior(
1149        &self,
1150        entity: &str,
1151    ) -> Option<std::sync::Arc<dyn EntityDataServiceBehavior>> {
1152        self.entity_data_service_behavior_registry
1153            .as_ref()
1154            .and_then(|registry| registry.behavior(entity))
1155    }
1156
1157    pub fn has_checker(&self, entity: &str) -> bool {
1158        self.checker_registry
1159            .as_ref()
1160            .and_then(|registry| registry.checker(entity))
1161            .is_some()
1162    }
1163
1164    pub fn check_and_fix_values(
1165        &self,
1166        entity: &str,
1167        values: &mut crate::EntityValues,
1168    ) -> Result<(), RuntimeError> {
1169        self.check_and_fix_values_at(entity, values, &ObjectLocation::root())
1170    }
1171
1172    pub fn check_and_fix_values_at(
1173        &self,
1174        entity: &str,
1175        values: &mut crate::EntityValues,
1176        location: &ObjectLocation,
1177    ) -> Result<(), RuntimeError> {
1178        let status = CheckObjectStatus::from_values(values);
1179        let checker = self
1180            .checker_registry
1181            .as_ref()
1182            .and_then(|registry| registry.checker(entity));
1183        let mut results = CheckResults::new();
1184        if let Some(checker) = checker {
1185            checker.check_and_fix(self, values, location, &mut results);
1186        }
1187
1188        // Keep runtime validation aligned with the schema generated from the
1189        // same metadata. Custom checkers get the first chance to supply or fix
1190        // a value; afterwards every NOT NULL property must be present on a
1191        // create, and an update must not explicitly clear one.
1192        if let Some(descriptor) = self
1193            .metadata
1194            .as_ref()
1195            .and_then(|metadata| metadata.entity(entity))
1196        {
1197            for property in descriptor
1198                .properties
1199                .iter()
1200                .filter(|property| !property.nullable)
1201            {
1202                let missing = !values.contains_key(&property.name);
1203                let null = matches!(values.get(&property.name), Some(Value::Null));
1204                let property_location = location.clone().member(&property.name);
1205                let already_reported = results.iter().any(|result| {
1206                    result.rule == crate::CheckRule::Required
1207                        && result.location == property_location
1208                });
1209                if ((status.is_create() && missing) || null) && !already_reported {
1210                    results.push(CheckResult::required(property_location));
1211                }
1212            }
1213        }
1214        if results.is_empty() {
1215            return Ok(());
1216        }
1217        self.translate_check_results(&mut results);
1218        Err(RuntimeError::Check(results))
1219    }
1220
1221    pub fn translate_check_results(&self, results: &mut CheckResults) {
1222        for result in results {
1223            result.message = Some(
1224                self.i18n_catalog
1225                    .translate_check_result(self.language, result),
1226            );
1227        }
1228    }
1229
1230    pub fn send_event(&self, event: RawAuditEvent) -> Result<(), RuntimeError> {
1231        let scope = self.start_runtime_operation(
1232            crate::RuntimeOperation::new("audit", format!("{}.event", event.entity))
1233                .attribute("teaql.entity.type", event.entity.clone()),
1234        );
1235        let result = self.send_event_inner(event);
1236        match &result {
1237            Ok(()) => scope.success(std::collections::BTreeMap::new()),
1238            Err(_) => scope.failure("audit_error"),
1239        }
1240        result
1241    }
1242
1243    fn send_event_inner(&self, event: RawAuditEvent) -> Result<(), RuntimeError> {
1244        if let Some(sink) = self.event_sink.as_ref() {
1245            sink.on_event(self, &event)?;
1246        }
1247        if let Some(sink) = self.custom_event_sink.as_ref() {
1248            let (mask_fields, max_len) = self
1249                .metadata
1250                .as_ref()
1251                .and_then(|metadata| metadata.entity(&event.entity))
1252                .map(|desc| (desc.audit_mask_fields.clone(), desc.audit_value_max_len))
1253                .unwrap_or_else(|| (vec![], None));
1254
1255            let safe_event = event.build_safe_event(&mask_fields, max_len);
1256            sink.on_safe_event(self, &safe_event)?;
1257        }
1258
1259        crate::log_formatter::LogManager::write_audit_log(&event);
1260
1261        Ok(())
1262    }
1263
1264    pub(crate) async fn commit_changes_internal<E>(&self) -> Result<(), DataServiceError<E::Error>>
1265    where
1266        E: teaql_data_service::MutationExecutor + Send + Sync + 'static,
1267    {
1268        let executor = self.require_resource::<E>().map_err(|err| {
1269            DataServiceError::Runtime(RuntimeError::Graph(format!(
1270                "cannot commit changes without executor: {err}"
1271            )))
1272        })?;
1273        let change_set = self.entity_root.current_change_set();
1274
1275        for (key, changes) in change_set.changes() {
1276            if changes.is_empty() {
1277                continue;
1278            }
1279            let _entity = self
1280                .require_entity(&key.entity)
1281                .map_err(DataServiceError::Runtime)?;
1282            let mut command = UpdateCommand::new(key.entity.as_ref(), key.id.clone());
1283            for (field, value) in changes {
1284                command = command.value(field.clone(), value.clone());
1285            }
1286            let request = teaql_data_service::MutationRequest::Update(command);
1287            executor
1288                .mutate(request)
1289                .await
1290                .map_err(DataServiceError::Executor)?;
1291        }
1292
1293        self.entity_root.clear_current_change_set();
1294        Ok(())
1295    }
1296
1297    pub async fn get_in_store(&self, key: &str) -> Option<Value> {
1298        let store = self.get_resource::<Box<dyn DataStore>>()?;
1299        store.get(key).await
1300    }
1301
1302    pub async fn put_in_store(
1303        &self,
1304        key: &str,
1305        value: impl Into<Value>,
1306        timeout_seconds: Option<u64>,
1307    ) {
1308        if let Some(store) = self.get_resource::<Box<dyn DataStore>>() {
1309            store.put(key, value.into(), timeout_seconds).await;
1310        }
1311    }
1312
1313    pub async fn clear_in_store(&self, key: &str) {
1314        if let Some(store) = self.get_resource::<Box<dyn DataStore>>() {
1315            store.remove(key).await;
1316        }
1317    }
1318}
1319
1320fn extract_id_from_sql(sql: &str) -> Option<String> {
1321    let sql_lower = sql.to_lowercase();
1322    let where_idx = sql_lower.find("where")?;
1323    let where_clause = &sql_lower[where_idx + 5..];
1324
1325    let bytes = where_clause.as_bytes();
1326    let mut i = 0;
1327    while i < bytes.len() {
1328        if i + 1 < bytes.len() && &bytes[i..i + 2] == b"id" {
1329            // Check boundary before
1330            let prev_ok = i == 0 || {
1331                let prev_char = bytes[i - 1] as char;
1332                !prev_char.is_ascii_alphanumeric() && prev_char != '_' && prev_char != '.'
1333            };
1334            // Check boundary after
1335            let next_ok = i + 2 == bytes.len() || {
1336                let next_char = bytes[i + 2] as char;
1337                !next_char.is_ascii_alphanumeric() && next_char != '_'
1338            };
1339
1340            if prev_ok && next_ok {
1341                // Found the standalone "id" word!
1342                // Now look for "=" after it
1343                let mut j = i + 2;
1344                while j < bytes.len() && (bytes[j] as char).is_whitespace() {
1345                    j += 1;
1346                }
1347                if j < bytes.len() && bytes[j] == b'=' {
1348                    j += 1;
1349                    while j < bytes.len() && (bytes[j] as char).is_whitespace() {
1350                        j += 1;
1351                    }
1352                    // Now extract the value
1353                    let mut val_str = String::new();
1354                    if j < bytes.len() && bytes[j] == b'\'' {
1355                        j += 1; // consume single quote
1356                        while j < bytes.len() && bytes[j] != b'\'' {
1357                            val_str.push(bytes[j] as char);
1358                            j += 1;
1359                        }
1360                        return Some(val_str);
1361                    }
1362                    // No else needed — falls through to unquoted parsing
1363                    while j < bytes.len() {
1364                        let c = bytes[j] as char;
1365                        if !c.is_ascii_alphanumeric() && c != '_' && c != '-' {
1366                            break;
1367                        }
1368                        val_str.push(c);
1369                        j += 1;
1370                    }
1371                    if !val_str.is_empty() {
1372                        return Some(val_str);
1373                    }
1374                }
1375            }
1376        }
1377        i += 1;
1378    }
1379    None
1380}
1381
1382fn sql_result_summary(
1383    operation: SqlLogOperation,
1384    result_count: Option<usize>,
1385    result_type: Option<&str>,
1386    affected_rows: Option<u64>,
1387    debug_sql: &str,
1388) -> String {
1389    match operation {
1390        SqlLogOperation::Select => {
1391            let count = result_count.unwrap_or(0);
1392            match count {
1393                0 => "MISS".to_owned(),
1394                1 => match result_type {
1395                    Some(result_type) => extract_id_from_sql(debug_sql)
1396                        .map(|id| format!("{result_type}({id})"))
1397                        .unwrap_or_else(|| result_type.to_owned()),
1398                    None => "row".to_owned(),
1399                },
1400                _ => match result_type {
1401                    Some(result_type) => format!("{count}*{result_type}"),
1402                    None => format!("{count}*rows"),
1403                },
1404            }
1405        }
1406        _ => {
1407            let affected = affected_rows.unwrap_or(0);
1408            format!("{affected} UPDATED")
1409        }
1410    }
1411}
1412
1413fn pretty_sql(sql: &str) -> String {
1414    let mut pretty = sql.to_owned();
1415    for keyword in [
1416        " FROM ",
1417        " WHERE ",
1418        " GROUP BY ",
1419        " HAVING ",
1420        " ORDER BY ",
1421        " LIMIT ",
1422        " OFFSET ",
1423        " RETURNING ",
1424    ] {
1425        pretty = pretty.replace(keyword, &format!("\n{}", keyword.trim_start()));
1426    }
1427    pretty.replace(" AND ", "\n  AND ")
1428}
1429
1430#[cfg(test)]
1431mod sql_log_option_tests {
1432    use super::*;
1433
1434    #[test]
1435    fn disabled_sql_log_rejects_executor_metadata_before_recording() {
1436        let mut context = UserContext::default();
1437        context.disable_sql_log();
1438        let now = SystemTime::now();
1439        context.record_metadata_log(&teaql_data_service::ExecutionMetadata {
1440            backend: "sql".to_owned(),
1441            operation: teaql_data_service::DataServiceOperation::Query,
1442            started_at: now,
1443            ended_at: now,
1444            affected_rows: None,
1445            result_count: Some(1),
1446            trace_chain: Vec::new(),
1447            comment: Some("disabled log test".to_owned()),
1448            backend_request_id: None,
1449            parameterized_query: Some("SELECT id FROM sample WHERE id = $1".to_owned()),
1450            params: vec![Value::I64(1)],
1451            debug_query: Some("SELECT id FROM sample WHERE id = 1".to_owned()),
1452        });
1453        assert!(context.sql_logs().is_empty());
1454    }
1455}