1use crate::application::entity::{
10 metadata_from_json, AppliedEntityMutation, CreateDocumentInput, CreateEdgeInput,
11 CreateEntityOutput, CreateKvInput, CreateNodeInput, CreateRowInput, CreateRowsBatchInput,
12 CreateVectorInput, DeleteEntityInput, PatchEntityOperation, PatchEntityOperationType,
13 RowUpdateColumnRule, RowUpdateContractPlan,
14};
15use crate::application::ports::{
16 build_row_update_contract_plan, entity_row_fields_snapshot,
17 normalize_row_update_assignment_with_plan, normalize_row_update_value_for_rule,
18 RuntimeEntityPort,
19};
20use crate::application::ttl_payload::has_internal_ttl_metadata;
21use crate::presentation::entity_json::storage_value_to_json;
22use crate::runtime::mvcc::current_connection_id;
23use crate::storage::query::ast::{BinOp, Expr, FieldRef, ReturningItem, UpdateTarget};
24use crate::storage::query::sql_lowering::{
25 effective_delete_filter, effective_insert_rows, effective_update_filter, fold_expr_to_value,
26};
27use crate::storage::query::unified::{
28 sys_key_collection, sys_key_created_at, sys_key_kind, sys_key_rid, sys_key_tenant,
29 sys_key_updated_at, UnifiedRecord, UnifiedResult,
30};
31use crate::storage::unified::MetadataValue;
32use crate::storage::Metadata;
33use std::collections::HashMap;
34use std::sync::Arc;
35
36use super::*;
37
38const UPDATE_APPLY_CHUNK_SIZE: usize = 2048;
39const TREE_CHILD_EDGE_LABEL: &str = "TREE_CHILD";
40const TREE_METADATA_PREFIX: &str = "red.tree.";
41
42#[derive(Clone)]
43struct CompiledUpdateAssignment {
44 column: String,
45 expr: Expr,
46 compound_op: Option<BinOp>,
47 metadata_key: Option<&'static str>,
48 row_rule: Option<RowUpdateColumnRule>,
49}
50
51struct CompiledUpdatePlan {
52 static_field_assignments: Vec<(String, Value)>,
53 static_metadata_assignments: Vec<(String, MetadataValue)>,
54 dynamic_assignments: Vec<CompiledUpdateAssignment>,
55 row_contract_plan: Option<RowUpdateContractPlan>,
56 row_modified_columns: Vec<String>,
57 row_touches_unique_columns: bool,
58}
59
60#[derive(Default)]
61struct MaterializedUpdateAssignments {
62 dynamic_field_assignments: Vec<(String, Value)>,
63 dynamic_metadata_assignments: Vec<(String, MetadataValue)>,
64}
65
66impl RedDBRuntime {
67 pub fn chain_tip_for_collection(
73 &self,
74 collection: &str,
75 ) -> Option<crate::runtime::blockchain_kind::ChainTipFull> {
76 let store = self.inner.db.store();
77 if !crate::runtime::blockchain_kind::is_chain(&store, collection) {
78 return None;
79 }
80 let mut cache = self.inner.chain_tip_cache.lock();
81 if let Some(existing) = cache.get(collection) {
82 return Some(existing.clone());
83 }
84 let scanned = crate::runtime::blockchain_kind::chain_tip_full(&store, collection)?;
85 cache.insert(collection.to_string(), scanned.clone());
86 Some(scanned)
87 }
88
89 pub fn verify_chain_for_collection(
96 &self,
97 collection: &str,
98 ) -> Option<crate::runtime::blockchain_kind::VerifyChainOutcome> {
99 let store = self.inner.db.store();
100 let outcome = crate::runtime::blockchain_kind::verify_chain_outcome(&store, collection)?;
101 if !outcome.ok {
102 crate::runtime::blockchain_kind::persist_integrity_flag(&store, collection, true);
103 self.inner
104 .chain_integrity_broken
105 .lock()
106 .insert(collection.to_string(), true);
107 }
108 Some(outcome)
109 }
110
111 pub fn clear_chain_integrity_flag(&self, collection: &str) -> bool {
115 let store = self.inner.db.store();
116 if !crate::runtime::blockchain_kind::is_chain(&store, collection) {
117 return false;
118 }
119 crate::runtime::blockchain_kind::persist_integrity_flag(&store, collection, false);
120 self.inner
121 .chain_integrity_broken
122 .lock()
123 .insert(collection.to_string(), false);
124 true
125 }
126
127 fn is_chain_integrity_broken(&self, collection: &str) -> bool {
131 {
132 let cache = self.inner.chain_integrity_broken.lock();
133 if let Some(v) = cache.get(collection) {
134 return *v;
135 }
136 }
137 let store = self.inner.db.store();
138 let persisted =
139 crate::runtime::blockchain_kind::is_integrity_broken_persisted(&store, collection)
140 .unwrap_or(false);
141 self.inner
142 .chain_integrity_broken
143 .lock()
144 .insert(collection.to_string(), persisted);
145 persisted
146 }
147
148 fn ensure_integrity_tombstones_loaded(&self) -> bool {
153 use std::sync::atomic::Ordering;
154 match self
155 .inner
156 .integrity_tombstones_state
157 .load(Ordering::Relaxed)
158 {
159 1 => return false,
160 2 => return true,
161 _ => {}
162 }
163 let mut guard = self.inner.integrity_tombstones.lock();
166 if self
167 .inner
168 .integrity_tombstones_state
169 .load(Ordering::Relaxed)
170 == 0
171 {
172 let ranges = crate::runtime::integrity_tombstone::load_ranges(&self.inner.db.store());
173 let present = !ranges.is_empty();
174 *guard = ranges;
175 self.inner
176 .integrity_tombstones_state
177 .store(if present { 2 } else { 1 }, Ordering::Relaxed);
178 }
179 self.inner
180 .integrity_tombstones_state
181 .load(Ordering::Relaxed)
182 == 2
183 }
184
185 pub fn record_integrity_tombstone(&self, table: &str, lo: u64, hi: u64) {
191 use std::sync::atomic::Ordering;
192 self.ensure_integrity_tombstones_loaded();
193 let mut guard = self.inner.integrity_tombstones.lock();
194 guard.push(crate::runtime::integrity_tombstone::TombstoneRange::new(
195 table.to_string(),
196 lo,
197 hi,
198 ));
199 crate::runtime::integrity_tombstone::persist_ranges(&self.inner.db.store(), &guard);
200 self.inner
201 .integrity_tombstones_state
202 .store(2, Ordering::Relaxed);
203 }
204
205 pub fn integrity_tombstone_ranges(
209 &self,
210 ) -> Vec<crate::runtime::integrity_tombstone::TombstoneRange> {
211 self.ensure_integrity_tombstones_loaded();
212 self.inner.integrity_tombstones.lock().clone()
213 }
214
215 pub fn filter_integrity_tombstoned(&self, result: &mut UnifiedResult) {
220 if !self.ensure_integrity_tombstones_loaded() {
221 return;
222 }
223 let guard = self.inner.integrity_tombstones.lock();
224 if guard.is_empty() {
225 return;
226 }
227 let before = result.records.len();
228 result.records.retain(|record| {
229 !crate::runtime::integrity_tombstone::record_tombstoned(&guard, record)
230 });
231 if result.records.len() != before {
232 result.pre_serialized_json = None;
233 }
234 }
235
236 fn maybe_inject_tenant_column(&self, query: &InsertQuery) -> RedDBResult<Option<InsertQuery>> {
250 let Some(tenant_col) = self.tenant_column(&query.table) else {
251 return Ok(None);
252 };
253 if query
255 .columns
256 .iter()
257 .any(|c| c.eq_ignore_ascii_case(&tenant_col))
258 {
259 return Ok(None);
260 }
261
262 if let Some(dot_pos) = tenant_col.find('.') {
268 let (root, tail) = tenant_col.split_at(dot_pos);
269 let tail = &tail[1..]; return self.inject_dotted_tenant(query, root, tail);
271 }
272
273 let Some(tenant_id) = crate::runtime::impl_core::current_tenant() else {
274 return Err(RedDBError::Query(format!(
275 "INSERT into tenant-scoped table '{}' requires an active tenant — \
276 run SET TENANT '<id>' first or name column '{}' explicitly",
277 query.table, tenant_col
278 )));
279 };
280
281 let mut augmented = query.clone();
282 augmented.columns.push(tenant_col);
283 let lit = Value::text(tenant_id.clone());
284 for row in augmented.values.iter_mut() {
285 row.push(lit.clone());
286 }
287 for row in augmented.value_exprs.iter_mut() {
288 row.push(crate::storage::query::ast::Expr::Literal {
289 value: lit.clone(),
290 span: crate::storage::query::ast::Span::synthetic(),
291 });
292 }
293 Ok(Some(augmented))
294 }
295
296 fn inject_dotted_tenant(
306 &self,
307 query: &InsertQuery,
308 root: &str,
309 tail: &str,
310 ) -> RedDBResult<Option<InsertQuery>> {
311 let active_tenant = crate::runtime::impl_core::current_tenant();
312 let mut augmented = query.clone();
313 let root_idx = augmented
314 .columns
315 .iter()
316 .position(|c| c.eq_ignore_ascii_case(root));
317
318 if let Some(idx) = root_idx {
319 for row in augmented.values.iter_mut() {
325 let Some(slot) = row.get_mut(idx) else {
326 continue;
327 };
328 if dotted_tail_already_set(slot, tail) {
329 continue;
330 }
331 let Some(tenant_id) = &active_tenant else {
332 return Err(RedDBError::Query(format!(
333 "INSERT into tenant-scoped table '{}' requires an active tenant — \
334 run SET TENANT '<id>' first or set '{}.{}' explicitly in each row",
335 query.table, root, tail
336 )));
337 };
338 *slot = merge_dotted_tenant(slot.clone(), tail, tenant_id)?;
339 }
340 for (row_idx, row) in augmented.value_exprs.iter_mut().enumerate() {
344 if let Some(slot) = row.get_mut(idx) {
345 let new_value = augmented
346 .values
347 .get(row_idx)
348 .and_then(|v| v.get(idx))
349 .cloned()
350 .unwrap_or(Value::Null);
351 *slot = crate::storage::query::ast::Expr::Literal {
352 value: new_value,
353 span: crate::storage::query::ast::Span::synthetic(),
354 };
355 }
356 }
357 } else {
358 let Some(tenant_id) = &active_tenant else {
362 return Err(RedDBError::Query(format!(
363 "INSERT into tenant-scoped table '{}' requires an active tenant — \
364 run SET TENANT '<id>' first or name path '{}.{}' explicitly",
365 query.table, root, tail
366 )));
367 };
368 augmented.columns.push(root.to_string());
370 let fresh = merge_dotted_tenant(Value::Null, tail, tenant_id)?;
371 for row in augmented.values.iter_mut() {
372 row.push(fresh.clone());
373 }
374 for row in augmented.value_exprs.iter_mut() {
375 row.push(crate::storage::query::ast::Expr::Literal {
376 value: fresh.clone(),
377 span: crate::storage::query::ast::Span::synthetic(),
378 });
379 }
380 }
381
382 Ok(Some(augmented))
383 }
384
385 fn delete_entities_batch(
388 &self,
389 collection: &str,
390 ids: &[EntityId],
391 ) -> RedDBResult<(u64, Vec<u64>)> {
392 if ids.is_empty() {
393 return Ok((0, vec![]));
394 }
395
396 let store = self.db().store();
397 let Some(manager) = store.get_collection(collection) else {
398 return Ok((0, vec![]));
399 };
400
401 let active_xid = self.current_xid();
402 let conn_id = crate::runtime::impl_core::current_connection_id();
403 let mut autocommit_xid = None;
404 let mut tombstoned_ids = Vec::new();
405 let mut tombstoned_entities = Vec::new();
406 let mut physical_delete_ids = Vec::new();
407 let table_row_resolver =
408 crate::runtime::table_row_mvcc_resolver::TableRowMvccReadResolver::current_statement();
409 let versioned_collection = self.vcs_is_versioned(collection).unwrap_or(false);
424
425 for &id in ids {
426 let Some(mut entity) = manager.get(id) else {
427 continue;
428 };
429 let is_versioned_graph = versioned_collection
430 && matches!(entity.data, EntityData::Node(_) | EntityData::Edge(_));
431 let is_versioned_vector =
432 versioned_collection && matches!(entity.data, EntityData::Vector(_));
433 if matches!(entity.data, EntityData::Row(_))
434 || is_versioned_graph
435 || is_versioned_vector
436 {
437 let previous_xmax = entity.xmax;
438 if matches!(entity.kind, crate::storage::EntityKind::TableRow { .. }) {
439 if table_row_resolver.resolve_candidate(&entity).is_none() {
440 continue;
441 }
442 } else if is_versioned_vector {
443 if table_row_resolver.resolve_read_candidate(&entity).is_none() {
450 continue;
451 }
452 } else if entity.xmax != 0 {
453 continue;
454 }
455
456 let xid = match active_xid {
457 Some(xid) => xid,
458 None => match autocommit_xid {
459 Some(xid) => xid,
460 None => {
461 let mgr = self.snapshot_manager();
462 let xid = mgr.begin();
463 autocommit_xid = Some(xid);
464 xid
465 }
466 },
467 };
468 entity.set_xmax(xid);
469 if manager.update(entity.clone()).is_ok() {
470 if active_xid.is_some() {
471 self.record_pending_tombstone(conn_id, collection, id, xid, previous_xmax);
472 }
473 tombstoned_entities.push(entity);
474 tombstoned_ids.push(id);
475 }
476 } else {
477 physical_delete_ids.push(id);
478 }
479 }
480
481 if let Some(xid) = autocommit_xid {
482 self.snapshot_manager().commit(xid);
483 }
484
485 let mut affected = tombstoned_ids.len() as u64;
486 let mut lsns = Vec::with_capacity(tombstoned_ids.len() + physical_delete_ids.len());
487 if active_xid.is_some() {
488 store
489 .persist_entities_to_pager(collection, &tombstoned_entities)
490 .map_err(|err| RedDBError::Internal(err.to_string()))?;
491 } else {
492 store
493 .persist_entities_to_pager(collection, &tombstoned_entities)
494 .map_err(|err| RedDBError::Internal(err.to_string()))?;
495 for id in &tombstoned_ids {
496 store.context_index().remove_entity(*id);
497 let lsn = self.cdc_emit(
498 crate::replication::cdc::ChangeOperation::Delete,
499 collection,
500 id.raw(),
501 "entity",
502 );
503 lsns.push(lsn);
504 }
505 }
506
507 let deleted_ids = store
508 .delete_batch(collection, &physical_delete_ids)
509 .map_err(|err| RedDBError::Internal(err.to_string()))?;
510 affected += deleted_ids.len() as u64;
511 for id in &deleted_ids {
512 store.context_index().remove_entity(*id);
513 let lsn = self.cdc_emit(
514 crate::replication::cdc::ChangeOperation::Delete,
515 collection,
516 id.raw(),
517 "entity",
518 );
519 lsns.push(lsn);
520 }
521
522 Ok((affected, lsns))
523 }
524
525 fn flush_update_chunk(&self, applied: &[AppliedEntityMutation]) -> RedDBResult<Vec<u64>> {
528 if applied.is_empty() {
529 return Ok(Vec::new());
530 }
531
532 let store = self.db().store();
533 if applied.iter().any(|item| item.context_index_dirty) {
534 store.context_index().index_entities(
535 &applied[0].collection,
536 applied
537 .iter()
538 .filter(|item| item.context_index_dirty)
539 .map(|item| &item.entity),
540 );
541 }
542
543 for item in applied {
544 self.refresh_update_secondary_indexes(item)?;
545 }
546
547 let mut lsns = Vec::with_capacity(applied.len());
548 for item in applied {
549 let lsn = self.cdc_emit_prebuilt(
550 crate::replication::cdc::ChangeOperation::Update,
551 &item.collection,
552 &item.entity,
553 update_cdc_item_kind(self, &item.collection, &item.entity),
554 item.metadata.as_ref(),
555 false,
556 );
557 lsns.push(lsn);
558 }
559 Ok(lsns)
560 }
561
562 fn persist_update_chunk(&self, applied: &[AppliedEntityMutation]) -> RedDBResult<()> {
563 self.persist_applied_entity_mutations(applied)
564 }
565
566 fn refresh_update_secondary_indexes(&self, applied: &AppliedEntityMutation) -> RedDBResult<()> {
567 if applied.pre_mutation_fields.is_empty() {
568 return Ok(());
569 }
570 let post = entity_row_fields_snapshot(&applied.entity);
571 if post.is_empty() {
572 return Ok(());
573 }
574
575 let indexed_cols = self
579 .index_store_ref()
580 .indexed_columns_set_with_parents(&applied.collection);
581 if indexed_cols.is_empty() {
582 return Ok(());
583 }
584
585 let pre = self
590 .index_store_ref()
591 .augment_body_derived_index_fields(&applied.pre_mutation_fields, &indexed_cols);
592 let post = self
593 .index_store_ref()
594 .augment_body_derived_index_fields(&post, &indexed_cols);
595
596 if let Some(old_version) = applied.replaced_entity.as_ref() {
597 let old_index_fields: Vec<(String, crate::storage::schema::Value)> = pre
598 .iter()
599 .filter(|(col, _)| indexed_cols.contains(col))
600 .cloned()
601 .collect();
602 let new_index_fields: Vec<(String, crate::storage::schema::Value)> = post
603 .iter()
604 .filter(|(col, _)| indexed_cols.contains(col))
605 .cloned()
606 .collect();
607 if !old_index_fields.is_empty() {
608 self.index_store_ref()
609 .index_entity_delete(&applied.collection, old_version.id, &old_index_fields)
610 .map_err(crate::RedDBError::Internal)?;
611 }
612 if !new_index_fields.is_empty() {
613 self.index_store_ref()
614 .index_entity_insert(&applied.collection, applied.entity.id, &new_index_fields)
615 .map_err(crate::RedDBError::Internal)?;
616 }
617 return Ok(());
618 }
619
620 let damage = crate::application::entity::row_damage_vector(&pre, &post);
621 if damage
622 .touched_columns()
623 .into_iter()
624 .any(|col| indexed_cols.contains(col))
625 {
626 self.index_store_ref()
627 .index_entity_update(&applied.collection, applied.id, &pre, &post)
628 .map_err(crate::RedDBError::Internal)?;
629 }
630 Ok(())
631 }
632
633 pub fn execute_insert(
638 &self,
639 raw_query: &str,
640 query: &InsertQuery,
641 ) -> RedDBResult<RuntimeQueryResult> {
642 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
643 crate::runtime::collection_contract::CollectionContractGate::check(
649 self,
650 &query.table,
651 crate::runtime::collection_contract::MutationKind::Insert,
652 )?;
653 let augmented_owned;
662 let query = match self.maybe_inject_tenant_column(query)? {
663 Some(new_q) => {
664 augmented_owned = new_q;
665 &augmented_owned
666 }
667 None => query,
668 };
669 self.check_insert_column_policy(query)?;
670 if let Some(ref embed_config) = query.auto_embed {
671 let provider = crate::ai::parse_provider(&embed_config.provider)?;
672 crate::runtime::ai::provider_gate::enforce(self, &provider)?;
676 if matches!(provider, crate::ai::AiProvider::Local) {
677 crate::runtime::ai::local_embedding::ensure_local_embedding_available()?;
678 let model_name = embed_config.model.as_deref().map(str::trim).unwrap_or("");
686 if model_name.is_empty() {
687 return Err(RedDBError::Query(
688 "AUTO EMBED with provider=local requires MODEL '<registered-model-name>'; \
689 the local provider does not have an implicit default model"
690 .to_string(),
691 ));
692 }
693 crate::runtime::ai::local_embedding::preflight_local_embedding(
694 &self.inner.db,
695 model_name,
696 )?;
697 }
698 }
699
700 let mut inserted_count: u64 = 0;
701 let effective_rows =
702 effective_insert_rows(query).map_err(|msg| RedDBError::Query(msg.to_string()))?;
703
704 let store = self.inner.db.store();
706 let _ = store.get_or_create_collection(&query.table);
707 let declared_model = self
708 .db()
709 .collection_contract_arc(&query.table)
710 .map(|contract| contract.declared_model);
711
712 let mut returning_snapshots: Option<Vec<Vec<(String, Value)>>> =
713 if query.returning.is_some() {
714 Some(Vec::with_capacity(effective_rows.len()))
715 } else {
716 None
717 };
718 let mut returning_result: Option<UnifiedResult> = None;
719
720 if matches!(query.entity_type, InsertEntityType::Row)
721 && !matches!(
722 declared_model,
723 Some(crate::catalog::CollectionModel::TimeSeries)
724 )
725 {
726 let chain_mode = crate::runtime::blockchain_kind::is_chain(&store, &query.table);
737 let _chain_lock_arc: Option<Arc<parking_lot::Mutex<()>>> = if chain_mode {
738 Some(self.inner.rmw_locks.lock_for(&query.table, "__chain__"))
739 } else {
740 None
741 };
742 let _chain_guard = _chain_lock_arc.as_ref().map(|m| m.lock());
743
744 if chain_mode && self.is_chain_integrity_broken(&query.table) {
747 return Err(RedDBError::InvalidOperation(format!(
748 "ChainIntegrityBroken: collection '{}' is locked until \
749 POST /collections/{}/clear-integrity-flag is called by an admin",
750 query.table, query.table
751 )));
752 }
753
754 let mut chain_tip_full: Option<crate::runtime::blockchain_kind::ChainTipFull> =
758 if chain_mode {
759 let mut cache = self.inner.chain_tip_cache.lock();
760 if let Some(existing) = cache.get(&query.table) {
761 Some(existing.clone())
762 } else if let Some(scanned) =
763 crate::runtime::blockchain_kind::chain_tip_full(&store, &query.table)
764 {
765 cache.insert(query.table.clone(), scanned.clone());
766 Some(scanned)
767 } else {
768 None
769 }
770 } else {
771 None
772 };
773
774 let mut rows = Vec::with_capacity(effective_rows.len());
775 for row_values in &effective_rows {
776 if row_values.len() != query.columns.len() {
777 return Err(RedDBError::Query(format!(
778 "INSERT column count ({}) does not match value count ({})",
779 query.columns.len(),
780 row_values.len()
781 )));
782 }
783 let (mut fields, mut metadata) =
784 split_insert_metadata(self, &query.columns, row_values)?;
785 if chain_mode {
786 use crate::runtime::blockchain_kind::{
787 chain_conflict_error, COL_BLOCK_HEIGHT, COL_HASH, COL_PREV_HASH,
788 COL_TIMESTAMP, RESERVED_COLUMNS,
789 };
790 let supplied_height = fields
791 .iter()
792 .find(|(k, _)| k == COL_BLOCK_HEIGHT)
793 .map(|(_, v)| v.clone());
794 let supplied_prev = fields
795 .iter()
796 .find(|(k, _)| k == COL_PREV_HASH)
797 .map(|(_, v)| v.clone());
798 let supplied_ts = fields
799 .iter()
800 .find(|(k, _)| k == COL_TIMESTAMP)
801 .map(|(_, v)| v.clone());
802 let supplied_hash = fields.iter().any(|(k, _)| k == COL_HASH);
803 let user_supplied_any = supplied_height.is_some()
804 || supplied_prev.is_some()
805 || supplied_ts.is_some()
806 || supplied_hash;
807
808 fields.retain(|(k, _)| !RESERVED_COLUMNS.contains(&k.as_str()));
809 let payload = crate::runtime::blockchain_kind::canonical_payload(&fields);
810
811 let (tip_prev_hash, tip_next_height) = match &chain_tip_full {
812 Some(t) => (t.hash, t.height + 1),
813 None => (crate::storage::blockchain::GENESIS_PREV_HASH, 0u64),
814 };
815 let server_now = crate::runtime::blockchain_kind::now_ms();
816
817 let (use_prev, use_height, use_ts) = if user_supplied_any {
818 if supplied_hash {
821 return Err(chain_conflict_error(
822 tip_next_height.saturating_sub(1),
823 tip_prev_hash,
824 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
825 server_now,
826 "hash column is engine-computed and cannot be supplied",
827 ));
828 }
829 let caller_prev = match &supplied_prev {
830 Some(Value::Blob(b)) if b.len() == 32 => {
831 let mut a = [0u8; 32];
832 a.copy_from_slice(b);
833 a
834 }
835 Some(Value::Text(s)) if s.len() == 64 => {
836 let mut a = [0u8; 32];
840 let mut ok = true;
841 for (i, slot) in a.iter_mut().enumerate() {
842 let pair = &s.as_ref()[i * 2..i * 2 + 2];
843 match u8::from_str_radix(pair, 16) {
844 Ok(byte) => *slot = byte,
845 Err(_) => {
846 ok = false;
847 break;
848 }
849 }
850 }
851 if !ok {
852 return Err(chain_conflict_error(
853 tip_next_height.saturating_sub(1),
854 tip_prev_hash,
855 chain_tip_full
856 .as_ref()
857 .map(|t| t.timestamp_ms)
858 .unwrap_or(0),
859 server_now,
860 "prev_hash is not valid hex",
861 ));
862 }
863 a
864 }
865 _ => {
866 return Err(chain_conflict_error(
867 tip_next_height.saturating_sub(1),
868 tip_prev_hash,
869 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
870 server_now,
871 "prev_hash missing or not a 32-byte Blob",
872 ));
873 }
874 };
875 if caller_prev != tip_prev_hash {
876 return Err(chain_conflict_error(
877 tip_next_height.saturating_sub(1),
878 tip_prev_hash,
879 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
880 server_now,
881 "prev_hash does not match current tip",
882 ));
883 }
884 let caller_height = match &supplied_height {
885 Some(Value::UnsignedInteger(v)) => *v,
886 Some(Value::Integer(v)) if *v >= 0 => *v as u64,
887 _ => {
888 return Err(chain_conflict_error(
889 tip_next_height.saturating_sub(1),
890 tip_prev_hash,
891 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
892 server_now,
893 "block_height missing or not an unsigned integer",
894 ));
895 }
896 };
897 if caller_height != tip_next_height {
898 return Err(chain_conflict_error(
899 tip_next_height.saturating_sub(1),
900 tip_prev_hash,
901 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
902 server_now,
903 "block_height does not match tip+1",
904 ));
905 }
906 let caller_ts = match &supplied_ts {
907 Some(Value::UnsignedInteger(v)) => *v,
908 Some(Value::Integer(v)) if *v >= 0 => *v as u64,
909 _ => {
910 return Err(chain_conflict_error(
911 tip_next_height.saturating_sub(1),
912 tip_prev_hash,
913 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
914 server_now,
915 "timestamp missing or not an unsigned integer",
916 ));
917 }
918 };
919 let drift = (caller_ts as i128) - (server_now as i128);
920 if drift.abs() > 60_000 {
921 return Err(chain_conflict_error(
922 tip_next_height.saturating_sub(1),
923 tip_prev_hash,
924 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
925 server_now,
926 "timestamp outside ±60s of server_time",
927 ));
928 }
929 (caller_prev, caller_height, caller_ts)
930 } else {
931 (tip_prev_hash, tip_next_height, server_now)
932 };
933
934 let (reserved, new_hash) =
935 crate::runtime::blockchain_kind::make_block_reserved_fields(
936 use_prev, use_height, use_ts, &payload,
937 );
938 fields.extend(reserved);
939 chain_tip_full = Some(crate::runtime::blockchain_kind::ChainTipFull {
940 height: use_height,
941 hash: new_hash,
942 timestamp_ms: use_ts,
943 });
944 }
945 if crate::runtime::signed_writes_kind::is_signed(&store, &query.table) {
957 let (pk_col, sig_col, residual) =
958 crate::runtime::signed_writes_kind::split_signature_fields(fields);
959 let payload = crate::runtime::blockchain_kind::canonical_payload(&residual);
960 let reg = crate::runtime::signed_writes_kind::registry(&store, &query.table);
961 crate::runtime::signed_writes_kind::verify_row(
962 ®,
963 pk_col.as_ref().map(|c| c.bytes.as_slice()),
964 sig_col.as_ref().map(|c| c.bytes.as_slice()),
965 &payload,
966 )
967 .map_err(crate::runtime::signed_writes_kind::map_error)?;
968 fields = residual;
969 if let Some(col) = pk_col {
974 fields.push((
975 crate::storage::signed_writes::RESERVED_SIGNER_PUBKEY_COL.to_string(),
976 col.raw_value,
977 ));
978 }
979 if let Some(col) = sig_col {
980 fields.push((
981 crate::storage::signed_writes::RESERVED_SIGNATURE_COL.to_string(),
982 col.raw_value,
983 ));
984 }
985 }
986 merge_with_clauses(
987 &mut metadata,
988 query.ttl_ms,
989 query.expires_at_ms,
990 &query.with_metadata,
991 );
992 if let Some(snaps) = returning_snapshots.as_mut() {
993 snaps.push(fields.clone());
994 }
995 rows.push(CreateRowInput {
996 collection: query.table.clone(),
997 fields,
998 metadata,
999 node_links: Vec::new(),
1000 vector_links: Vec::new(),
1001 });
1002 }
1003 let outputs = self.create_rows_batch(CreateRowsBatchInput {
1004 collection: query.table.clone(),
1005 rows,
1006 suppress_events: query.suppress_events,
1007 })?;
1008 inserted_count = outputs.len() as u64;
1009
1010 if chain_mode {
1014 if let Some(new_tip) = chain_tip_full.as_ref() {
1015 self.inner
1016 .chain_tip_cache
1017 .lock()
1018 .insert(query.table.clone(), new_tip.clone());
1019 }
1020 }
1021
1022 if let Some(spec) = self.inner.db.hypertables().get(&query.table) {
1030 let time_col = &spec.time_column;
1031 if let Some(idx) = query.columns.iter().position(|c| c == time_col) {
1033 for row in &effective_rows {
1034 if let Some(Value::Integer(n) | Value::BigInt(n)) = row.get(idx) {
1035 if *n >= 0 {
1036 let _ = self.inner.db.hypertables().route(&query.table, *n as u64);
1037 }
1038 } else if let Some(Value::UnsignedInteger(n)) = row.get(idx) {
1039 let _ = self.inner.db.hypertables().route(&query.table, *n);
1040 }
1041 }
1042 }
1043 }
1044
1045 if let (Some(items), Some(snaps)) =
1046 (query.returning.as_ref(), returning_snapshots.take())
1047 {
1048 let snaps = row_insert_returning_snapshots(&outputs, snaps);
1049 returning_result = Some(build_returning_result(items, &snaps, Some(&outputs)));
1050 }
1051 } else {
1052 let mut entity_outputs: Vec<crate::application::entity::CreateEntityOutput> =
1059 Vec::with_capacity(effective_rows.len());
1060 let mut returning_field_snaps: Vec<Vec<(String, Value)>> = if query.returning.is_some()
1061 {
1062 Vec::with_capacity(effective_rows.len())
1063 } else {
1064 Vec::new()
1065 };
1066 if matches!(
1067 query.entity_type,
1068 InsertEntityType::Node | InsertEntityType::Edge
1069 ) {
1070 enum PreparedGraphInsert {
1071 Node {
1072 fields: Vec<(String, Value)>,
1073 input: CreateNodeInput,
1074 },
1075 Edge {
1076 fields: Vec<(String, Value)>,
1077 input: CreateEdgeInput,
1078 },
1079 }
1080
1081 let mut prepared = Vec::with_capacity(effective_rows.len());
1082 for row_values in &effective_rows {
1083 if row_values.len() != query.columns.len() {
1084 return Err(RedDBError::Query(format!(
1085 "INSERT column count ({}) does not match value count ({})",
1086 query.columns.len(),
1087 row_values.len()
1088 )));
1089 }
1090
1091 match query.entity_type {
1092 InsertEntityType::Node => {
1093 let (node_values, mut metadata) =
1094 split_insert_metadata(self, &query.columns, row_values)?;
1095 merge_with_clauses(
1096 &mut metadata,
1097 query.ttl_ms,
1098 query.expires_at_ms,
1099 &query.with_metadata,
1100 );
1101 ensure_non_tree_reserved_metadata_entries(&metadata)?;
1102 apply_collection_default_ttl_metadata(
1103 self,
1104 &query.table,
1105 &mut metadata,
1106 );
1107 let (columns, values) = pairwise_columns_values(&node_values);
1108 let label = find_column_value_string(&columns, &values, "label")?;
1109 let node_type =
1110 find_column_value_opt_string(&columns, &values, "node_type");
1111 let properties = extract_remaining_properties(
1112 &columns,
1113 &values,
1114 &["label", "node_type"],
1115 );
1116 crate::reserved_fields::ensure_no_reserved_public_item_fields(
1117 properties.iter().map(|(key, _)| key.as_str()),
1118 &format!("node '{}'", query.table),
1119 )?;
1120 prepared.push(PreparedGraphInsert::Node {
1121 fields: node_values,
1122 input: CreateNodeInput {
1123 collection: query.table.clone(),
1124 label,
1125 node_type,
1126 properties,
1127 metadata,
1128 embeddings: Vec::new(),
1129 table_links: Vec::new(),
1130 node_links: Vec::new(),
1131 },
1132 });
1133 }
1134 InsertEntityType::Edge => {
1135 let (edge_values, mut metadata) =
1136 split_insert_metadata(self, &query.columns, row_values)?;
1137 merge_with_clauses(
1138 &mut metadata,
1139 query.ttl_ms,
1140 query.expires_at_ms,
1141 &query.with_metadata,
1142 );
1143 ensure_non_tree_reserved_metadata_entries(&metadata)?;
1144 apply_collection_default_ttl_metadata(
1145 self,
1146 &query.table,
1147 &mut metadata,
1148 );
1149 let (columns, values) = pairwise_columns_values(&edge_values);
1150 let label = find_column_value_string(&columns, &values, "label")?;
1151 ensure_non_tree_structural_edge_label(&label)?;
1152 let from_id = resolve_edge_endpoint_any(
1153 self.inner.db.store().as_ref(),
1154 &query.table,
1155 &columns,
1156 &values,
1157 &["from_rid", "from"],
1158 )?;
1159 let to_id = resolve_edge_endpoint_any(
1160 self.inner.db.store().as_ref(),
1161 &query.table,
1162 &columns,
1163 &values,
1164 &["to_rid", "to"],
1165 )?;
1166 let weight = find_column_value_f32_opt(&columns, &values, "weight");
1167 let properties = extract_remaining_properties(
1168 &columns,
1169 &values,
1170 &["label", "from_rid", "to_rid", "from", "to", "weight"],
1171 );
1172 crate::reserved_fields::ensure_no_reserved_public_item_fields(
1173 properties.iter().map(|(key, _)| key.as_str()),
1174 &format!("edge '{}'", query.table),
1175 )?;
1176 prepared.push(PreparedGraphInsert::Edge {
1177 fields: edge_values,
1178 input: CreateEdgeInput {
1179 collection: query.table.clone(),
1180 label,
1181 from: EntityId::new(from_id),
1182 to: EntityId::new(to_id),
1183 weight,
1184 properties,
1185 metadata,
1186 },
1187 });
1188 }
1189 _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
1190 }
1191 }
1192
1193 ensure_graph_insert_contract(self, &query.table)?;
1194 let mut batch = self.inner.db.batch();
1195 for item in prepared {
1196 match item {
1197 PreparedGraphInsert::Node { fields, input } => {
1198 if query.returning.is_some() {
1199 returning_field_snaps.push(fields);
1200 }
1201 let node_type = input.node_type.unwrap_or_else(|| input.label.clone());
1202 batch = batch.add_node_with_type(
1203 input.collection,
1204 input.label,
1205 node_type,
1206 input.properties.into_iter().collect(),
1207 input.metadata.into_iter().collect(),
1208 );
1209 }
1210 PreparedGraphInsert::Edge { fields, input } => {
1211 if query.returning.is_some() {
1212 returning_field_snaps.push(fields);
1213 }
1214 batch = batch.add_edge(
1215 input.collection,
1216 input.label,
1217 input.from,
1218 input.to,
1219 input.weight.unwrap_or(1.0),
1220 input.properties.into_iter().collect(),
1221 input.metadata.into_iter().collect(),
1222 );
1223 }
1224 }
1225 }
1226 let batch_result = batch
1227 .execute()
1228 .map_err(|err| RedDBError::Internal(format!("{err:?}")))?;
1229 let (ids, entity_kind) = match query.entity_type {
1230 InsertEntityType::Node => (batch_result.nodes, "graph_node"),
1231 InsertEntityType::Edge => (batch_result.edges, "graph_edge"),
1232 _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
1233 };
1234 for id in &ids {
1235 self.stamp_xmin_if_in_txn(&query.table, *id);
1236 }
1237 if query.returning.is_some() {
1238 returning_field_snaps = graph_insert_returning_snapshots(
1239 self.inner.db.store().as_ref(),
1240 &query.table,
1241 &ids,
1242 );
1243 }
1244 self.cdc_emit_insert_batch_no_cache_invalidate(&query.table, &ids, entity_kind);
1245 let store = self.inner.db.store();
1246 entity_outputs.extend(ids.iter().map(|id| {
1247 crate::application::entity::CreateEntityOutput {
1248 id: *id,
1249 entity: store.get(&query.table, *id),
1250 }
1251 }));
1252 inserted_count = ids.len() as u64;
1253 } else {
1254 for row_values in &effective_rows {
1255 if row_values.len() != query.columns.len() {
1256 return Err(RedDBError::Query(format!(
1257 "INSERT column count ({}) does not match value count ({})",
1258 query.columns.len(),
1259 row_values.len()
1260 )));
1261 }
1262
1263 match query.entity_type {
1264 InsertEntityType::Row => {
1265 if query.returning.is_some() {
1266 return Err(RedDBError::Query(
1267 "RETURNING is not yet supported for this INSERT path (TimeSeries)"
1268 .to_string(),
1269 ));
1270 }
1271 let (fields, mut metadata) =
1272 split_insert_metadata(self, &query.columns, row_values)?;
1273 merge_with_clauses(
1274 &mut metadata,
1275 query.ttl_ms,
1276 query.expires_at_ms,
1277 &query.with_metadata,
1278 );
1279 self.insert_timeseries_point(&query.table, fields, metadata)?;
1280 }
1281 InsertEntityType::Node | InsertEntityType::Edge => {
1282 unreachable!("NODE and EDGE are handled by the prepared graph path")
1283 }
1284 InsertEntityType::Vector => {
1285 let (vector_values, mut metadata) =
1286 split_insert_metadata(self, &query.columns, row_values)?;
1287 merge_with_clauses(
1288 &mut metadata,
1289 query.ttl_ms,
1290 query.expires_at_ms,
1291 &query.with_metadata,
1292 );
1293 let (columns, values) = pairwise_columns_values(&vector_values);
1294 let dense = find_column_value_vec_f32_any(
1295 &columns,
1296 &values,
1297 &["dense", "embedding"],
1298 )?;
1299 merge_vector_metadata_column(&mut metadata, &columns, &values)?;
1300 let content =
1301 find_column_value_opt_string(&columns, &values, "content");
1302 if query.returning.is_some() {
1303 returning_field_snaps.push(vector_values.clone());
1304 }
1305 let input = CreateVectorInput {
1306 collection: query.table.clone(),
1307 dense,
1308 content,
1309 metadata,
1310 link_row: None,
1311 link_node: None,
1312 };
1313 entity_outputs.push(self.create_vector(input)?);
1314 }
1315 InsertEntityType::Document => {
1316 let (document_values, mut metadata) =
1317 split_insert_metadata(self, &query.columns, row_values)?;
1318 merge_with_clauses(
1319 &mut metadata,
1320 query.ttl_ms,
1321 query.expires_at_ms,
1322 &query.with_metadata,
1323 );
1324 let (columns, values) = pairwise_columns_values(&document_values);
1325 let body = find_document_body_json(&columns, &values)?;
1326 let input = CreateDocumentInput {
1327 collection: query.table.clone(),
1328 body,
1329 metadata,
1330 node_links: Vec::new(),
1331 vector_links: Vec::new(),
1332 };
1333 let output = self.create_document(input)?;
1334 if query.returning.is_some() {
1335 let fields = output
1336 .entity
1337 .as_ref()
1338 .map(entity_row_fields_snapshot)
1339 .filter(|fields| !fields.is_empty())
1340 .unwrap_or(document_values);
1341 returning_field_snaps.push(fields);
1342 }
1343 entity_outputs.push(output);
1344 }
1345 InsertEntityType::Kv => {
1346 let (kv_values, mut metadata) =
1347 split_insert_metadata(self, &query.columns, row_values)?;
1348 merge_with_clauses(
1349 &mut metadata,
1350 query.ttl_ms,
1351 query.expires_at_ms,
1352 &query.with_metadata,
1353 );
1354 let (columns, values) = pairwise_columns_values(&kv_values);
1355 let key = find_column_value_string(&columns, &values, "key")?;
1356 let value = find_column_value(&columns, &values, "value")?;
1357 if query.returning.is_some() {
1358 returning_field_snaps.push(kv_values.clone());
1359 }
1360 let input = CreateKvInput {
1361 collection: query.table.clone(),
1362 key,
1363 value,
1364 metadata,
1365 };
1366 entity_outputs.push(self.create_kv(input)?);
1367 }
1368 }
1369
1370 inserted_count += 1;
1371 }
1372 }
1373
1374 if let Some(items) = query.returning.as_ref() {
1375 if !entity_outputs.is_empty() {
1376 returning_result = Some(build_returning_result(
1377 items,
1378 &returning_field_snaps,
1379 Some(&entity_outputs),
1380 ));
1381 }
1382 }
1383 }
1384
1385 if let Some(ref embed_config) = query.auto_embed {
1387 let store = self.inner.db.store();
1388 let provider = crate::ai::parse_provider(&embed_config.provider)?;
1389 let is_local_provider = matches!(provider, crate::ai::AiProvider::Local);
1390 let api_key = if is_local_provider {
1395 String::new()
1396 } else {
1397 crate::ai::resolve_api_key_from_runtime(&provider, None, self)?
1398 };
1399 let model = embed_config.model.clone().unwrap_or_else(|| {
1400 std::env::var("REDDB_OPENAI_EMBEDDING_MODEL")
1401 .ok()
1402 .unwrap_or_else(|| crate::ai::DEFAULT_OPENAI_EMBEDDING_MODEL.to_string())
1403 });
1404
1405 let manager = store
1407 .get_collection(&query.table)
1408 .ok_or_else(|| RedDBError::NotFound(query.table.clone()))?;
1409 let entities = manager.query_all(|_| true);
1410 let recent: Vec<_> = entities
1411 .into_iter()
1412 .rev()
1413 .take(effective_rows.len())
1414 .collect();
1415
1416 let entity_combos: Vec<(usize, String)> = recent
1418 .iter()
1419 .enumerate()
1420 .filter_map(|(i, entity)| {
1421 if let EntityData::Row(ref row) = entity.data {
1422 if let Some(ref named) = row.named {
1423 let texts: Vec<String> = embed_config
1424 .fields
1425 .iter()
1426 .filter_map(|field| match named.get(field) {
1427 Some(Value::Text(t)) if !t.is_empty() => Some(t.to_string()),
1428 _ => None,
1429 })
1430 .collect();
1431 if !texts.is_empty() {
1432 return Some((i, texts.join(" ")));
1433 }
1434 }
1435 }
1436 None
1437 })
1438 .collect();
1439
1440 if !entity_combos.is_empty() {
1441 let batch_texts: Vec<String> =
1443 entity_combos.iter().map(|(_, t)| t.clone()).collect();
1444
1445 let embeddings = if is_local_provider {
1455 let response = crate::runtime::ai::local_embedding::embed_local_with_db(
1456 &self.inner.db,
1457 &model,
1458 batch_texts,
1459 )?;
1460 response.embeddings
1461 } else {
1462 let batch_client =
1463 crate::runtime::ai::batch_client::AiBatchClient::from_runtime(self);
1464
1465 match tokio::runtime::Handle::try_current() {
1466 Ok(handle) => tokio::task::block_in_place(|| {
1467 handle.block_on(batch_client.embed_batch(
1468 &provider,
1469 &model,
1470 &api_key,
1471 batch_texts,
1472 ))
1473 }),
1474 Err(_) => {
1475 return Err(RedDBError::Query(
1476 "AUTO EMBED requires a Tokio runtime context".to_string(),
1477 ));
1478 }
1479 }
1480 .map_err(|e| RedDBError::Query(e.to_string()))?
1481 };
1482
1483 for ((_, combined), dense) in entity_combos.iter().zip(embeddings) {
1485 if dense.is_empty() {
1486 continue;
1487 }
1488 self.create_vector(CreateVectorInput {
1489 collection: query.table.clone(),
1490 dense,
1491 content: Some(combined.clone()),
1492 metadata: Vec::new(),
1493 link_row: None,
1494 link_node: None,
1495 })?;
1496 }
1497 }
1498 }
1499
1500 if inserted_count > 0 {
1501 self.note_table_write(&query.table);
1502 }
1503
1504 let mut result = RuntimeQueryResult::dml_result(
1505 raw_query.to_string(),
1506 inserted_count,
1507 "insert",
1508 "runtime-dml",
1509 );
1510 if let Some(returning) = returning_result {
1511 result.result = returning;
1512 }
1513 Ok(result)
1514 }
1515
1516 fn check_insert_column_policy(&self, query: &InsertQuery) -> RedDBResult<()> {
1517 let Some(auth_store) = self.inner.auth_store.read().clone() else {
1518 return Ok(());
1519 };
1520 if !auth_store.iam_authorization_enabled() {
1521 return Ok(());
1522 }
1523 let Some((username, role)) = crate::runtime::impl_core::current_auth_identity() else {
1524 return Ok(());
1525 };
1526
1527 let tenant = crate::runtime::impl_core::current_tenant();
1528 let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
1529 let request = crate::auth::ColumnAccessRequest {
1530 action: "insert".to_string(),
1531 schema: None,
1532 table: query.table.clone(),
1533 columns: query.columns.clone(),
1534 };
1535 let ctx = crate::auth::policies::EvalContext {
1536 principal_tenant: tenant.clone(),
1537 current_tenant: tenant,
1538 peer_ip: None,
1539 mfa_present: false,
1540 now_ms: crate::auth::now_ms(),
1541 principal_is_admin_role: role == crate::auth::Role::Admin,
1542 principal_is_platform_scoped: principal.tenant.is_none(),
1543 };
1544
1545 let outcome = auth_store.check_column_projection_authz(&principal, &request, &ctx);
1546 let table_allowed = matches!(
1547 outcome.table_decision,
1548 crate::auth::policies::Decision::Allow { .. }
1549 | crate::auth::policies::Decision::AdminBypass
1550 );
1551 if !table_allowed {
1552 return Err(RedDBError::Query(format!(
1553 "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1554 outcome.table_resource.kind, outcome.table_resource.name
1555 )));
1556 }
1557 if let Some(denied) = outcome.first_denied_column() {
1558 return Err(RedDBError::Query(format!(
1559 "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1560 denied.resource.kind, denied.resource.name
1561 )));
1562 }
1563
1564 Ok(())
1565 }
1566
1567 pub(crate) fn insert_timeseries_point(
1568 &self,
1569 collection: &str,
1570 fields: Vec<(String, Value)>,
1571 mut metadata: Vec<(String, MetadataValue)>,
1572 ) -> RedDBResult<EntityId> {
1573 apply_collection_default_ttl_metadata(self, collection, &mut metadata);
1574
1575 let (columns, values) = pairwise_columns_values(&fields);
1576 validate_timeseries_insert_columns(&columns)?;
1577
1578 let event_name_opt = find_column_value_opt_string(&columns, &values, "event_name");
1587 let payload_opt = find_column_value_opt_string(&columns, &values, "payload");
1588 if let Some(event_name) = event_name_opt.as_deref() {
1589 let store_for_schema = self.inner.db.store();
1590 if super::analytics_schema_registry::latest(store_for_schema.as_ref(), event_name)
1591 .is_some()
1592 {
1593 let payload_json = payload_opt.as_deref().unwrap_or("{}");
1594 super::analytics_schema_registry::validate(
1595 store_for_schema.as_ref(),
1596 event_name,
1597 payload_json,
1598 )
1599 .map_err(super::analytics_schema_registry::validation_error_to_reddb)?;
1600 }
1601 }
1602
1603 let metric = match find_column_value_opt_string(&columns, &values, "metric") {
1608 Some(m) => m,
1609 None => event_name_opt.clone().ok_or_else(|| {
1610 RedDBError::Query(
1611 "timeseries INSERT requires either `metric` or `event_name`".to_string(),
1612 )
1613 })?,
1614 };
1615 let value = match find_column_value_opt_string(&columns, &values, "value") {
1619 Some(s) => s.parse::<f64>().unwrap_or(1.0),
1620 None => columns
1621 .iter()
1622 .position(|c| c.eq_ignore_ascii_case("value"))
1623 .and_then(|i| match &values[i] {
1624 Value::Float(f) => Some(*f),
1625 Value::Integer(n) | Value::BigInt(n) => Some(*n as f64),
1626 Value::UnsignedInteger(n) => Some(*n as f64),
1627 _ => None,
1628 })
1629 .unwrap_or(1.0),
1630 };
1631 let timestamp_ns =
1632 find_timeseries_timestamp_ns(&columns, &values)?.unwrap_or_else(current_unix_ns);
1633 let mut tags = find_timeseries_tags(&columns, &values)?;
1634 if let Some(ref name) = event_name_opt {
1635 tags.entry("event_name".to_string())
1636 .or_insert_with(|| name.clone());
1637 }
1638 if let Some(ref payload) = payload_opt {
1639 tags.entry("payload".to_string())
1640 .or_insert_with(|| payload.clone());
1641 }
1642
1643 let mut entity = UnifiedEntity::new(
1644 EntityId::new(0),
1645 EntityKind::TimeSeriesPoint(Box::new(crate::storage::TimeSeriesPointKind {
1646 series: collection.to_string(),
1647 metric: metric.clone(),
1648 })),
1649 EntityData::TimeSeries(crate::storage::TimeSeriesData {
1650 metric,
1651 timestamp_ns,
1652 value,
1653 tags,
1654 }),
1655 );
1656 let writer_xid = match self.current_xid() {
1660 Some(xid) => xid,
1661 None => {
1662 let mgr = self.snapshot_manager();
1663 let xid = mgr.begin();
1664 mgr.commit(xid);
1665 xid
1666 }
1667 };
1668 entity.set_xmin(writer_xid);
1669
1670 let store = self.inner.db.store();
1671 let id = store
1672 .insert_auto(collection, entity)
1673 .map_err(|err| RedDBError::Internal(err.to_string()))?;
1674
1675 if !metadata.is_empty() {
1676 let _ = store.set_metadata(
1677 collection,
1678 id,
1679 Metadata::with_fields(metadata.into_iter().collect()),
1680 );
1681 }
1682
1683 self.cdc_emit(
1684 crate::replication::cdc::ChangeOperation::Insert,
1685 collection,
1686 id.raw(),
1687 "timeseries",
1688 );
1689
1690 Ok(id)
1691 }
1692
1693 pub fn execute_update(
1698 &self,
1699 raw_query: &str,
1700 query: &UpdateQuery,
1701 ) -> RedDBResult<RuntimeQueryResult> {
1702 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
1703 if crate::runtime::blockchain_kind::is_chain(self.inner.db.store().as_ref(), &query.table) {
1707 return Err(RedDBError::InvalidOperation(format!(
1708 "BlockchainCollectionImmutable: UPDATE not allowed on '{}'",
1709 query.table
1710 )));
1711 }
1712 if query.claim_limit.is_some() && self.is_queue_collection(&query.table) {
1719 return self.execute_queue_shaped_claim(raw_query, query);
1720 }
1721 crate::runtime::collection_contract::CollectionContractGate::check(
1727 self,
1728 &query.table,
1729 crate::runtime::collection_contract::MutationKind::Update,
1730 )?;
1731 ensure_update_target_contract(self, &query.table, query.target)?;
1732 ensure_kv_key_update_target_allowed(query)?;
1733 ensure_graph_identity_update_target_allowed(query)?;
1734
1735 let rls_gated = crate::runtime::impl_core::rls_is_enabled(self, &query.table);
1741 let augmented_query: UpdateQuery;
1742 let effective_query: &UpdateQuery = if rls_gated {
1743 let update_filter = crate::runtime::impl_core::rls_policy_filter(
1744 self,
1745 &query.table,
1746 crate::storage::query::ast::PolicyAction::Update,
1747 );
1748 let Some(mut policy) = update_filter else {
1749 let mut response = RuntimeQueryResult::dml_result(
1752 raw_query.to_string(),
1753 0,
1754 "update",
1755 "runtime-dml-rls",
1756 );
1757 if let Some(items) = query.returning.clone() {
1758 response.result = build_returning_result(&items, &[], None);
1759 }
1760 return Ok(response);
1761 };
1762 if query.claim_limit.is_some() {
1763 let read_filter = crate::runtime::impl_core::rls_policy_filter(
1764 self,
1765 &query.table,
1766 crate::storage::query::ast::PolicyAction::Select,
1767 );
1768 let Some(read_policy) = read_filter else {
1769 let mut response = RuntimeQueryResult::dml_result(
1770 raw_query.to_string(),
1771 0,
1772 "update",
1773 "runtime-dml-rls",
1774 );
1775 if let Some(items) = query.returning.clone() {
1776 response.result = build_returning_result(&items, &[], None);
1777 }
1778 return Ok(response);
1779 };
1780 policy = crate::storage::query::ast::Filter::And(
1781 Box::new(read_policy),
1782 Box::new(policy),
1783 );
1784 }
1785 let mut augmented = query.clone();
1786 augmented.filter = Some(match augmented.filter.take() {
1787 Some(existing) => {
1788 crate::storage::query::ast::Filter::And(Box::new(existing), Box::new(policy))
1789 }
1790 None => policy,
1791 });
1792 augmented_query = augmented;
1793 &augmented_query
1794 } else {
1795 query
1796 };
1797
1798 if let Some(items) = effective_query.returning.clone() {
1803 let mut inner_query = effective_query.clone();
1804 inner_query.returning = None;
1805 let (mut response, touched_ids) =
1806 self.execute_update_inner_tracked(raw_query, &inner_query)?;
1807
1808 let mut snapshots = if matches!(
1809 effective_query.target,
1810 UpdateTarget::Nodes | UpdateTarget::Edges
1811 ) {
1812 graph_update_returning_snapshots(self, &effective_query.table, &touched_ids)
1813 } else {
1814 super::dml_target_scan::DmlTargetScan::new(self, &effective_query.table, None, None)
1815 .row_snapshots(&touched_ids)
1816 };
1817 if matches!(effective_query.target, UpdateTarget::Kv) {
1818 restore_kv_returning_keys(
1819 self,
1820 &effective_query.table,
1821 &touched_ids,
1822 &mut snapshots,
1823 );
1824 }
1825
1826 response.result = build_returning_result(&items, &snapshots, None);
1827 response.engine = "runtime-dml-returning";
1828 return Ok(response);
1829 }
1830
1831 self.execute_update_inner(raw_query, effective_query)
1832 }
1833
1834 fn execute_update_inner(
1836 &self,
1837 raw_query: &str,
1838 query: &UpdateQuery,
1839 ) -> RedDBResult<RuntimeQueryResult> {
1840 self.execute_update_inner_tracked(raw_query, query)
1841 .map(|(res, _)| res)
1842 }
1843
1844 fn enforce_claim_order_by_index_gate(&self, query: &UpdateQuery) -> RedDBResult<()> {
1853 if query.claim_limit.is_none()
1854 || !matches!(query.target, UpdateTarget::Rows | UpdateTarget::Documents)
1855 {
1856 return Ok(());
1857 }
1858 let available_indexes: Vec<Vec<String>> = self
1859 .index_store_ref()
1860 .list_indices(&query.table)
1861 .into_iter()
1862 .map(|index| index.columns)
1863 .collect();
1864 reddb_rql::planner::check_claim_order_by_index_gate(query, &available_indexes)
1865 .map_err(RedDBError::InvalidOperation)
1866 }
1867
1868 fn execute_update_inner_tracked(
1869 &self,
1870 raw_query: &str,
1871 query: &UpdateQuery,
1872 ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
1873 self.enforce_claim_order_by_index_gate(query)?;
1874 let store = self.inner.db.store();
1875 let effective_filter = effective_update_filter(query);
1876 let compiled_plan = self.compile_update_plan(query)?;
1877 let needs_rmw_lock = update_needs_rmw_lock(query);
1878 let claim_model = update_model_name(update_target_model(query.target));
1879 if query.claim_limit.is_some() {
1880 self.inner
1881 .claim_telemetry
1882 .record_attempt(&query.table, claim_model);
1883 }
1884 let claim_lock = query.claim_limit.map(|_| {
1885 self.inner
1886 .rmw_locks
1887 .lock_for(&query.table, "__table_claim_update__")
1888 });
1889 let _claim_guard = if let Some(lock) = claim_lock.as_ref() {
1890 let Some(guard) = lock.try_lock() else {
1891 let skipped_locked = query.claim_limit.unwrap_or(1);
1892 self.inner.claim_telemetry.record_skipped_locked(
1893 &query.table,
1894 claim_model,
1895 skipped_locked,
1896 );
1897 tracing::debug!(
1898 target: "reddb::claim",
1899 collection = %query.table,
1900 model = claim_model,
1901 skipped_locked,
1902 "concurrent claim skipped locked candidates"
1903 );
1904 return Ok((
1905 RuntimeQueryResult::dml_result(
1906 raw_query.to_string(),
1907 0,
1908 "update",
1909 "runtime-dml",
1910 ),
1911 Vec::new(),
1912 ));
1913 };
1914 Some(guard)
1915 } else {
1916 None
1917 };
1918 let table_rmw_lock = if needs_rmw_lock {
1919 Some(
1920 self.inner
1921 .rmw_locks
1922 .lock_for(&query.table, "__table_rmw_update__"),
1923 )
1924 } else {
1925 None
1926 };
1927 let _table_rmw_guard = table_rmw_lock.as_ref().map(|lock| lock.lock());
1928 let mut touched_ids: Vec<EntityId> = Vec::new();
1929 let claim_cap = query.claim_limit.map(|limit| limit as usize);
1930 let limit_cap = claim_cap.or_else(|| query.limit.map(|limit| limit as usize));
1931 let manager = store
1932 .get_collection(&query.table)
1933 .ok_or_else(|| RedDBError::NotFound(query.table.clone()))?;
1934 let scan_limit = if query.order_by.is_empty() {
1935 limit_cap
1936 } else {
1937 None
1938 };
1939 let mut target_scan = super::dml_target_scan::DmlTargetScan::with_update_target(
1940 self,
1941 &query.table,
1942 effective_filter.as_ref(),
1943 scan_limit,
1944 query.target,
1945 );
1946 if needs_rmw_lock {
1947 target_scan = target_scan.with_live_table_rows();
1948 }
1949 let ids_to_update = target_scan.find_target_ids()?;
1950 let order_limit = if query.claim_limit.is_some() {
1951 None
1952 } else {
1953 limit_cap
1954 };
1955 let ids_to_update = if query.order_by.is_empty() {
1956 ids_to_update
1957 } else {
1958 ordered_update_target_ids(&manager, &ids_to_update, &query.order_by, order_limit)
1959 };
1960 let mut ids_to_update = if query.claim_limit.is_some() {
1961 self.filter_claim_locked_target_ids(&query.table, ids_to_update)
1962 } else {
1963 ids_to_update
1964 };
1965 if let Some(claim_cap) = claim_cap {
1966 ids_to_update.truncate(claim_cap);
1967 }
1968 if query.claim_exact
1969 && claim_cap.is_some_and(|claim_count| ids_to_update.len() < claim_count)
1970 {
1971 self.inner
1972 .claim_telemetry
1973 .record_miss(&query.table, claim_model);
1974 return Ok((
1975 RuntimeQueryResult::dml_result(raw_query.to_string(), 0, "update", "runtime-dml"),
1976 Vec::new(),
1977 ));
1978 }
1979
1980 if needs_rmw_lock {
1981 let result = self.execute_update_inner_tracked_locked(
1982 raw_query,
1983 query,
1984 &compiled_plan,
1985 &ids_to_update,
1986 effective_filter.as_ref(),
1987 )?;
1988 if query.claim_limit.is_some() {
1989 self.record_pending_claim_locks_for_touched_ids(&query.table, &result.1);
1990 }
1991 record_claim_outcome(
1992 &self.inner.claim_telemetry,
1993 query.claim_limit,
1994 &query.table,
1995 claim_model,
1996 result.0.affected_rows,
1997 );
1998 return Ok(result);
1999 }
2000
2001 let mut affected: u64 = 0;
2002 for chunk in ids_to_update.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2003 let mut applied_chunk = Vec::with_capacity(chunk.len());
2004 for entity in manager.get_many(chunk).into_iter().flatten() {
2005 let assignments =
2006 self.materialize_update_assignments_for_entity(query, &entity, &compiled_plan)?;
2007 let applied = self.apply_materialized_update_for_entity(
2008 query,
2009 entity,
2010 &compiled_plan,
2011 assignments,
2012 )?;
2013 touched_ids.push(applied.id);
2014 applied_chunk.push(applied);
2015 }
2016 self.persist_update_chunk(&applied_chunk)?;
2017 affected += applied_chunk.len() as u64;
2018 let lsns = self.flush_update_chunk(&applied_chunk)?;
2019 if !query.suppress_events {
2020 self.emit_update_events_for_collection(&query.table, &applied_chunk, &lsns)?;
2021 }
2022 }
2023
2024 if affected > 0 {
2025 self.note_table_write(&query.table);
2026 }
2027 if query.claim_limit.is_some() {
2028 self.record_pending_claim_locks_for_touched_ids(&query.table, &touched_ids);
2029 }
2030 record_claim_outcome(
2031 &self.inner.claim_telemetry,
2032 query.claim_limit,
2033 &query.table,
2034 claim_model,
2035 affected,
2036 );
2037
2038 Ok((
2039 RuntimeQueryResult::dml_result(
2040 raw_query.to_string(),
2041 affected,
2042 "update",
2043 "runtime-dml",
2044 ),
2045 touched_ids,
2046 ))
2047 }
2048
2049 fn filter_claim_locked_target_ids(&self, table: &str, ids: Vec<EntityId>) -> Vec<EntityId> {
2050 let conn_id = current_connection_id();
2051 let locks = self.inner.pending_claim_locks.read();
2052 ids.into_iter()
2053 .filter(|id| {
2054 let Some(entity) = self.inner.db.store().get(table, *id) else {
2055 return false;
2056 };
2057 let key = (table.to_string(), entity.logical_id());
2058 locks
2059 .get(&key)
2060 .is_none_or(|owner_conn_id| *owner_conn_id == conn_id)
2061 })
2062 .collect()
2063 }
2064
2065 fn record_pending_claim_locks_for_touched_ids(&self, table: &str, ids: &[EntityId]) {
2066 if self.current_xid().is_none() || ids.is_empty() {
2067 return;
2068 }
2069
2070 let conn_id = current_connection_id();
2071 let store = self.inner.db.store();
2072 let mut locks = self.inner.pending_claim_locks.write();
2073 for id in ids {
2074 let Some(entity) = store.get(table, *id) else {
2075 continue;
2076 };
2077 locks.insert((table.to_string(), entity.logical_id()), conn_id);
2078 }
2079 }
2080
2081 fn execute_update_inner_tracked_locked(
2082 &self,
2083 raw_query: &str,
2084 query: &UpdateQuery,
2085 compiled_plan: &CompiledUpdatePlan,
2086 ids_to_update: &[EntityId],
2087 effective_filter: Option<&Filter>,
2088 ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
2089 let store = self.inner.db.store();
2090 let mut touched_ids = Vec::new();
2091 let mut lock_entries = Vec::new();
2092
2093 for id in ids_to_update {
2094 let Some(candidate) = store.get(&query.table, *id) else {
2095 continue;
2096 };
2097 let logical_id = candidate.logical_id();
2098 let lock_key = format!("row:{}", logical_id.raw());
2099 let rmw_lock = self.inner.rmw_locks.lock_for(&query.table, &lock_key);
2100 lock_entries.push((lock_key, logical_id, rmw_lock));
2101 }
2102
2103 lock_entries.sort_by(|left, right| left.0.cmp(&right.0));
2104 lock_entries.dedup_by(|left, right| left.0 == right.0);
2105 let _rmw_guards: Vec<_> = lock_entries.iter().map(|entry| entry.2.lock()).collect();
2106
2107 let mut applied_chunk = Vec::new();
2108 for (_, logical_id, _) in &lock_entries {
2109 let Some(entity) = resolve_update_entity_by_logical_id(self, &query.table, *logical_id)
2110 else {
2111 continue;
2112 };
2113 if let Some(filter) = effective_filter {
2114 if !crate::runtime::query_exec::evaluate_entity_filter_with_db(
2115 Some(self.inner.db.as_ref()),
2116 &entity,
2117 filter,
2118 &query.table,
2119 &query.table,
2120 ) {
2121 continue;
2122 }
2123 }
2124
2125 let assignments =
2126 self.materialize_update_assignments_for_entity(query, &entity, compiled_plan)?;
2127 let applied = self.apply_materialized_update_for_entity(
2128 query,
2129 entity,
2130 compiled_plan,
2131 assignments,
2132 )?;
2133 touched_ids.push(applied.id);
2134 applied_chunk.push(applied);
2135 }
2136
2137 let affected = applied_chunk.len() as u64;
2138 if !applied_chunk.is_empty() {
2139 self.persist_update_chunk(&applied_chunk)?;
2140 let lsns = self.flush_update_chunk(&applied_chunk)?;
2141 if !query.suppress_events {
2142 self.emit_update_events_for_collection(&query.table, &applied_chunk, &lsns)?;
2143 }
2144 }
2145
2146 if affected > 0 {
2147 self.note_table_write(&query.table);
2148 }
2149
2150 Ok((
2151 RuntimeQueryResult::dml_result(
2152 raw_query.to_string(),
2153 affected,
2154 "update",
2155 "runtime-dml",
2156 ),
2157 touched_ids,
2158 ))
2159 }
2160
2161 fn compile_update_plan(&self, query: &UpdateQuery) -> RedDBResult<CompiledUpdatePlan> {
2162 let mut static_field_assignments = Vec::new();
2163 let mut static_metadata_assignments = Vec::new();
2164 let mut dynamic_assignments = Vec::new();
2165 let row_contract_plan = build_row_update_contract_plan(&self.db(), &query.table)?;
2166 let mut row_modified_columns = Vec::new();
2167
2168 for (idx, (column, expr)) in query.assignment_exprs.iter().enumerate() {
2169 let compound_op = query.compound_assignment_ops.get(idx).copied().flatten();
2170 let metadata_key = resolve_sql_ttl_metadata_key(column);
2171 if compound_op.is_some() && metadata_key.is_some() {
2172 return Err(RedDBError::Query(format!(
2173 "compound assignment is only supported for row fields: {column}"
2174 )));
2175 }
2176 if compound_op.is_none() {
2177 if let Ok(value) = fold_expr_to_value(expr.clone()) {
2178 if let Some(metadata_key) = metadata_key {
2179 let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2180 let (canonical_key, canonical_value) =
2181 canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2182 static_metadata_assignments
2183 .push((canonical_key.to_string(), canonical_value));
2184 } else {
2185 let value = self.resolve_crypto_sentinel(value)?;
2186 static_field_assignments.push((
2187 column.clone(),
2188 normalize_row_update_assignment_with_plan(
2189 &query.table,
2190 column,
2191 value,
2192 row_contract_plan.as_ref(),
2193 )?,
2194 ));
2195 row_modified_columns.push(column.clone());
2196 }
2197 continue;
2198 }
2199 }
2200
2201 dynamic_assignments.push(CompiledUpdateAssignment {
2202 column: column.clone(),
2203 expr: expr.clone(),
2204 compound_op,
2205 metadata_key,
2206 row_rule: if metadata_key.is_none() {
2207 if let Some(plan) = row_contract_plan.as_ref() {
2208 if plan.timestamps_enabled
2209 && (column == "created_at" || column == "updated_at")
2210 {
2211 return Err(RedDBError::Query(format!(
2212 "collection '{}' manages '{}' automatically — do not set it in UPDATE",
2213 query.table, column
2214 )));
2215 }
2216 if let Some(rule) = plan.declared_rules.get(column) {
2217 Some(rule.clone())
2218 } else if plan.strict_schema {
2219 return Err(RedDBError::Query(format!(
2220 "collection '{}' is strict and does not allow undeclared fields: {}",
2221 query.table, column
2222 )));
2223 } else {
2224 None
2225 }
2226 } else {
2227 None
2228 }
2229 } else {
2230 None
2231 },
2232 });
2233 if metadata_key.is_none() {
2234 row_modified_columns.push(column.clone());
2235 }
2236 }
2237
2238 let row_modified_columns = dedupe_update_columns(row_modified_columns);
2239 let row_touches_unique_columns = row_contract_plan.as_ref().is_some_and(|plan| {
2240 row_modified_columns.iter().any(|column| {
2241 plan.unique_columns
2242 .keys()
2243 .any(|unique| unique.eq_ignore_ascii_case(column))
2244 })
2245 });
2246
2247 if let Some(ttl_ms) = query.ttl_ms {
2248 static_metadata_assignments
2249 .push(("_ttl_ms".to_string(), metadata_u64_to_value(ttl_ms)));
2250 }
2251 if let Some(expires_at_ms) = query.expires_at_ms {
2252 static_metadata_assignments.push((
2253 "_expires_at".to_string(),
2254 metadata_u64_to_value(expires_at_ms),
2255 ));
2256 }
2257 for (key, val) in &query.with_metadata {
2258 static_metadata_assignments.push((key.clone(), storage_value_to_metadata_value(val)));
2259 }
2260
2261 Ok(CompiledUpdatePlan {
2262 static_field_assignments,
2263 static_metadata_assignments,
2264 dynamic_assignments,
2265 row_contract_plan,
2266 row_modified_columns,
2267 row_touches_unique_columns,
2268 })
2269 }
2270
2271 fn materialize_update_assignments_for_entity(
2272 &self,
2273 query: &UpdateQuery,
2274 entity: &UnifiedEntity,
2275 compiled_plan: &CompiledUpdatePlan,
2276 ) -> RedDBResult<MaterializedUpdateAssignments> {
2277 let mut assignments = MaterializedUpdateAssignments::default();
2278 let mut record: Option<UnifiedRecord> = None;
2279
2280 for assignment in &compiled_plan.dynamic_assignments {
2281 if assignment.compound_op.is_some()
2282 && !matches!(
2283 entity.data,
2284 EntityData::Row(_) | EntityData::Node(_) | EntityData::Edge(_)
2285 )
2286 {
2287 return Err(RedDBError::Query(format!(
2288 "compound assignment is only supported for row or graph UPDATE column '{}'",
2289 assignment.column
2290 )));
2291 }
2292 if record.is_none() {
2293 record = runtime_any_record_from_entity_ref(entity);
2294 }
2295 let Some(record) = record.as_ref() else {
2296 return Err(RedDBError::Query(format!(
2297 "UPDATE could not materialize runtime record for entity {} in '{}'",
2298 entity.id.raw(),
2299 query.table
2300 )));
2301 };
2302 let rhs = super::expr_eval::evaluate_runtime_expr_with_db(
2303 Some(self.inner.db.as_ref()),
2304 &assignment.expr,
2305 record,
2306 Some(query.table.as_str()),
2307 Some(query.table.as_str()),
2308 )
2309 .ok_or_else(|| {
2310 RedDBError::Query(format!(
2311 "failed to evaluate UPDATE expression for column '{}'",
2312 assignment.column
2313 ))
2314 })?;
2315 let value = if let Some(op) = assignment.compound_op {
2316 evaluate_compound_update_assignment(&assignment.column, record, op, rhs)?
2317 } else {
2318 rhs
2319 };
2320
2321 if let Some(metadata_key) = assignment.metadata_key {
2322 let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2323 let (canonical_key, canonical_value) =
2324 canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2325 assignments
2326 .dynamic_metadata_assignments
2327 .push((canonical_key.to_string(), canonical_value));
2328 } else {
2329 assignments.dynamic_field_assignments.push((
2330 assignment.column.clone(),
2331 normalize_row_update_value_for_rule(
2332 &query.table,
2333 self.resolve_crypto_sentinel(value)?,
2334 assignment.row_rule.as_ref(),
2335 )?,
2336 ));
2337 }
2338 }
2339
2340 Ok(assignments)
2341 }
2342
2343 fn apply_materialized_update_for_entity(
2344 &self,
2345 query: &UpdateQuery,
2346 entity: UnifiedEntity,
2347 compiled_plan: &CompiledUpdatePlan,
2348 mut assignments: MaterializedUpdateAssignments,
2349 ) -> RedDBResult<AppliedEntityMutation> {
2350 if matches!(query.target, UpdateTarget::Kv)
2351 && !compiled_plan
2352 .static_field_assignments
2353 .iter()
2354 .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2355 && !assignments
2356 .dynamic_field_assignments
2357 .iter()
2358 .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2359 {
2360 if let Some(key) = runtime_any_record_from_entity_ref(&entity)
2361 .and_then(|record| record.get("key").cloned())
2362 {
2363 assignments
2364 .dynamic_field_assignments
2365 .push(("key".to_string(), key));
2366 }
2367 }
2368 if matches!(entity.data, EntityData::Row(_)) {
2369 return self.apply_loaded_sql_update_row_core(
2370 query.table.clone(),
2371 entity,
2372 &compiled_plan.static_field_assignments,
2373 assignments.dynamic_field_assignments,
2374 &compiled_plan.static_metadata_assignments,
2375 assignments.dynamic_metadata_assignments,
2376 compiled_plan.row_contract_plan.as_ref(),
2377 &compiled_plan.row_modified_columns,
2378 compiled_plan.row_touches_unique_columns,
2379 );
2380 }
2381
2382 ensure_graph_identity_update_allowed(&entity, compiled_plan, &assignments)?;
2383
2384 let operations = build_patch_operations_from_materialized_assignments(
2385 &entity,
2386 compiled_plan,
2387 assignments,
2388 );
2389 self.apply_loaded_patch_entity_core(
2390 query.table.clone(),
2391 entity,
2392 crate::json::Value::Null,
2393 operations,
2394 )
2395 }
2396
2397 pub fn execute_delete(
2399 &self,
2400 raw_query: &str,
2401 query: &DeleteQuery,
2402 ) -> RedDBResult<RuntimeQueryResult> {
2403 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2404 if crate::runtime::blockchain_kind::is_chain(self.inner.db.store().as_ref(), &query.table) {
2407 return Err(RedDBError::InvalidOperation(format!(
2408 "BlockchainCollectionImmutable: DELETE not allowed on '{}'",
2409 query.table
2410 )));
2411 }
2412 crate::runtime::collection_contract::CollectionContractGate::check(
2416 self,
2417 &query.table,
2418 crate::runtime::collection_contract::MutationKind::Delete,
2419 )?;
2420
2421 if let Some(items) = query.returning.clone() {
2428 let select_sql = delete_to_select_sql(raw_query).ok_or_else(|| {
2429 RedDBError::Query(
2430 "DELETE ... RETURNING: cannot rewrite query for pre-image scan".to_string(),
2431 )
2432 })?;
2433 let captured = self.execute_query(&select_sql)?;
2434
2435 let mut inner_query = query.clone();
2436 inner_query.returning = None;
2437 let _ = self.execute_delete(raw_query, &inner_query)?;
2438
2439 let snapshots: Vec<Vec<(String, Value)>> = captured
2440 .result
2441 .records
2442 .iter()
2443 .map(|rec| {
2444 rec.iter_fields()
2445 .map(|(k, v)| (k.as_ref().to_string(), v.clone()))
2446 .collect()
2447 })
2448 .collect();
2449 let affected = snapshots.len() as u64;
2450 let result = build_returning_result(&items, &snapshots, None);
2451
2452 let mut response = RuntimeQueryResult::dml_result(
2453 raw_query.to_string(),
2454 affected,
2455 "delete",
2456 "runtime-dml-returning",
2457 );
2458 response.result = result;
2459 return Ok(response);
2460 }
2461 if crate::runtime::impl_core::rls_is_enabled(self, &query.table) {
2468 let rls_filter = crate::runtime::impl_core::rls_policy_filter(
2469 self,
2470 &query.table,
2471 crate::storage::query::ast::PolicyAction::Delete,
2472 );
2473 let Some(policy) = rls_filter else {
2474 return Ok(RuntimeQueryResult::dml_result(
2475 raw_query.to_string(),
2476 0,
2477 "delete",
2478 "runtime-dml-rls",
2479 ));
2480 };
2481 let mut augmented = query.clone();
2486 augmented.filter = Some(match augmented.filter.take() {
2487 Some(existing) => {
2488 crate::storage::query::ast::Filter::And(Box::new(existing), Box::new(policy))
2489 }
2490 None => policy,
2491 });
2492 return self.execute_delete_inner(raw_query, &augmented);
2493 }
2494 self.execute_delete_inner(raw_query, query)
2495 }
2496
2497 fn execute_delete_inner(
2498 &self,
2499 raw_query: &str,
2500 query: &DeleteQuery,
2501 ) -> RedDBResult<RuntimeQueryResult> {
2502 let effective_filter = effective_delete_filter(query);
2503
2504 let scan = super::dml_target_scan::DmlTargetScan::new(
2508 self,
2509 &query.table,
2510 effective_filter.as_ref(),
2511 None,
2512 );
2513 let ids_to_delete = scan.find_target_ids()?;
2514
2515 let needs_delete_events =
2518 !query.suppress_events && self.collection_has_delete_subscriptions(&query.table);
2519 let mut pre_images: HashMap<u64, crate::json::Value> = if needs_delete_events {
2520 scan.row_json_pre_images(&ids_to_delete)
2521 } else {
2522 HashMap::new()
2523 };
2524
2525 let mut affected: u64 = 0;
2526 for chunk in ids_to_delete.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2527 let (count, lsns) = self.delete_entities_batch(&query.table, chunk)?;
2528 affected += count;
2529 if needs_delete_events && !lsns.is_empty() {
2530 let deleted_chunk = &chunk[..lsns.len().min(chunk.len())];
2534 self.emit_delete_events_for_collection(
2535 &query.table,
2536 deleted_chunk,
2537 &lsns,
2538 &pre_images,
2539 )?;
2540 }
2541 }
2542 pre_images.clear();
2543
2544 if affected > 0 {
2545 self.note_table_write(&query.table);
2546 }
2547
2548 Ok(RuntimeQueryResult::dml_result(
2549 raw_query.to_string(),
2550 affected,
2551 "delete",
2552 "runtime-dml",
2553 ))
2554 }
2555}
2556
2557fn ensure_graph_identity_update_target_allowed(query: &UpdateQuery) -> RedDBResult<()> {
2563 if !matches!(query.target, UpdateTarget::Nodes | UpdateTarget::Edges) {
2564 return Ok(());
2565 }
2566 for (column, _) in &query.assignment_exprs {
2567 if is_immutable_graph_identity_field(column) {
2568 return Err(RedDBError::Query(format!(
2569 "immutable graph field '{column}' cannot be updated"
2570 )));
2571 }
2572 }
2573 Ok(())
2574}
2575
2576fn ensure_kv_key_update_target_allowed(query: &UpdateQuery) -> RedDBResult<()> {
2577 if !matches!(query.target, UpdateTarget::Kv) {
2578 return Ok(());
2579 }
2580 for (column, _) in &query.assignment_exprs {
2581 if column.eq_ignore_ascii_case("key") {
2582 return Err(RedDBError::Query(
2583 "KV key cannot be updated; delete and insert a new key instead".to_string(),
2584 ));
2585 }
2586 }
2587 Ok(())
2588}
2589
2590fn ensure_graph_identity_update_allowed(
2591 entity: &UnifiedEntity,
2592 compiled_plan: &CompiledUpdatePlan,
2593 assignments: &MaterializedUpdateAssignments,
2594) -> RedDBResult<()> {
2595 if !matches!(entity.data, EntityData::Node(_) | EntityData::Edge(_)) {
2596 return Ok(());
2597 }
2598
2599 for (column, _) in compiled_plan
2600 .static_field_assignments
2601 .iter()
2602 .chain(assignments.dynamic_field_assignments.iter())
2603 {
2604 if is_immutable_graph_identity_field(column) {
2605 return Err(RedDBError::Query(format!(
2606 "immutable graph field '{column}' cannot be updated"
2607 )));
2608 }
2609 }
2610
2611 Ok(())
2612}
2613
2614fn is_immutable_graph_identity_field(column: &str) -> bool {
2615 ["rid", "label", "from_rid", "to_rid", "from", "to"]
2616 .iter()
2617 .any(|reserved| column.eq_ignore_ascii_case(reserved))
2618}
2619
2620fn build_patch_operations_from_materialized_assignments(
2621 entity: &UnifiedEntity,
2622 compiled_plan: &CompiledUpdatePlan,
2623 assignments: MaterializedUpdateAssignments,
2624) -> Vec<PatchEntityOperation> {
2625 let mut operations = Vec::with_capacity(
2626 compiled_plan.static_field_assignments.len()
2627 + compiled_plan.static_metadata_assignments.len()
2628 + assignments.dynamic_field_assignments.len()
2629 + assignments.dynamic_metadata_assignments.len(),
2630 );
2631
2632 for (column, value) in &compiled_plan.static_field_assignments {
2633 operations.push(PatchEntityOperation {
2634 op: PatchEntityOperationType::Set,
2635 path: update_patch_path_for_entity(entity, column),
2636 value: Some(storage_value_to_json(value)),
2637 });
2638 }
2639
2640 for (column, value) in assignments.dynamic_field_assignments {
2641 operations.push(PatchEntityOperation {
2642 op: PatchEntityOperationType::Set,
2643 path: update_patch_path_for_entity(entity, &column),
2644 value: Some(storage_value_to_json(&value)),
2645 });
2646 }
2647
2648 for (key, value) in &compiled_plan.static_metadata_assignments {
2649 operations.push(PatchEntityOperation {
2650 op: PatchEntityOperationType::Set,
2651 path: vec!["metadata".to_string(), key.clone()],
2652 value: Some(metadata_value_to_json(value)),
2653 });
2654 }
2655
2656 for (key, value) in assignments.dynamic_metadata_assignments {
2657 operations.push(PatchEntityOperation {
2658 op: PatchEntityOperationType::Set,
2659 path: vec!["metadata".to_string(), key],
2660 value: Some(metadata_value_to_json(&value)),
2661 });
2662 }
2663
2664 operations
2665}
2666
2667fn update_patch_path_for_entity(entity: &UnifiedEntity, column: &str) -> Vec<String> {
2668 if matches!(
2669 (&entity.kind, &entity.data),
2670 (
2671 crate::storage::EntityKind::GraphNode(_),
2672 EntityData::Node(_)
2673 )
2674 ) && column.eq_ignore_ascii_case("node_type")
2675 {
2676 return vec!["node_type".to_string()];
2677 }
2678 if matches!(
2679 (&entity.kind, &entity.data),
2680 (
2681 crate::storage::EntityKind::GraphEdge(_),
2682 EntityData::Edge(_)
2683 )
2684 ) && column.eq_ignore_ascii_case("weight")
2685 {
2686 return vec!["weight".to_string()];
2687 }
2688 vec!["fields".to_string(), column.to_string()]
2689}
2690
2691fn delete_to_select_sql(sql: &str) -> Option<String> {
2701 let trimmed = sql.trim_start();
2702 let lowered = trimmed.to_ascii_lowercase();
2703 if !lowered.starts_with("delete ") && !lowered.starts_with("delete\t") {
2704 return None;
2705 }
2706 let from_idx = lowered.find(" from ")?;
2708 let after_from = &trimmed[from_idx + " from ".len()..];
2709 let after_from_lc = &lowered[from_idx + " from ".len()..];
2710
2711 let mut body = after_from.to_string();
2716 if let Some(pos) = find_top_level_keyword(after_from_lc, "returning") {
2717 body.truncate(pos);
2718 }
2719 Some(format!("SELECT * FROM {}", body.trim_end()))
2720}
2721
2722fn find_top_level_keyword(haystack: &str, needle: &str) -> Option<usize> {
2727 let bytes = haystack.as_bytes();
2728 let nlen = needle.len();
2729 let mut i = 0usize;
2730 let mut in_string = false;
2731 while i < bytes.len() {
2732 let c = bytes[i];
2733 if c == b'\'' {
2734 in_string = !in_string;
2735 i += 1;
2736 continue;
2737 }
2738 if !in_string
2739 && i + nlen <= bytes.len()
2740 && &bytes[i..i + nlen] == needle.as_bytes()
2741 && (i == 0 || bytes[i - 1].is_ascii_whitespace())
2742 && (i + nlen == bytes.len() || bytes[i + nlen].is_ascii_whitespace())
2743 {
2744 return Some(i);
2745 }
2746 i += 1;
2747 }
2748 None
2749}
2750
2751fn build_returning_result(
2758 items: &[ReturningItem],
2759 snapshots: &[Vec<(String, Value)>],
2760 outputs: Option<&[CreateEntityOutput]>,
2761) -> UnifiedResult {
2762 let project_all = items.iter().any(|it| matches!(it, ReturningItem::All));
2763 let public_item_outputs = outputs.is_some_and(|outs| {
2764 outs.first()
2765 .and_then(|out| out.entity.as_ref())
2766 .is_some_and(|entity| public_returning_item_kind(entity).is_some())
2767 });
2768
2769 let mut columns: Vec<String> = if project_all {
2770 let mut cols: Vec<String> = Vec::new();
2771 if public_item_outputs {
2772 cols.extend(
2773 [
2774 "rid",
2775 "collection",
2776 "kind",
2777 "tenant",
2778 "created_at",
2779 "updated_at",
2780 ]
2781 .into_iter()
2782 .map(str::to_string),
2783 );
2784 } else if outputs.is_some() {
2785 cols.push("rid".to_string());
2786 }
2787 if let Some(first) = snapshots.first() {
2788 for (name, _) in first {
2789 cols.push(name.clone());
2790 }
2791 }
2792 cols
2793 } else {
2794 items
2795 .iter()
2796 .filter_map(|it| match it {
2797 ReturningItem::Column(c) => Some(c.clone()),
2798 ReturningItem::All => None,
2799 })
2800 .collect()
2801 };
2802 {
2804 let mut seen = std::collections::HashSet::new();
2805 columns.retain(|c| seen.insert(c.clone()));
2806 }
2807
2808 let mut records: Vec<UnifiedRecord> = Vec::with_capacity(snapshots.len());
2809 for (idx, snap) in snapshots.iter().enumerate() {
2810 let mut values: HashMap<Arc<str>, Value> = HashMap::with_capacity(columns.len());
2811 if let Some(outs) = outputs {
2812 if let Some(out) = outs.get(idx) {
2813 if let Some(entity) = out.entity.as_ref() {
2814 if let Some(kind) = public_returning_item_kind(entity) {
2815 values.insert(
2816 Arc::clone(&sys_key_rid()),
2817 Value::UnsignedInteger(out.id.raw()),
2818 );
2819 values.insert(
2820 Arc::clone(&sys_key_collection()),
2821 Value::text(entity.kind.collection().to_string()),
2822 );
2823 values.insert(Arc::clone(&sys_key_kind()), Value::text(kind.to_string()));
2824 values.insert(
2825 Arc::clone(&sys_key_created_at()),
2826 Value::UnsignedInteger(entity.created_at),
2827 );
2828 values.insert(
2829 Arc::clone(&sys_key_updated_at()),
2830 Value::UnsignedInteger(entity.updated_at),
2831 );
2832 } else {
2833 values.insert(
2834 Arc::clone(&sys_key_rid()),
2835 Value::Integer(out.id.raw() as i64),
2836 );
2837 }
2838 } else {
2839 values.insert(
2840 Arc::clone(&sys_key_rid()),
2841 Value::Integer(out.id.raw() as i64),
2842 );
2843 }
2844 }
2845 }
2846 for (name, val) in snap {
2847 values.insert(Arc::from(name.as_str()), val.clone());
2848 }
2849 if !values.contains_key("tenant") {
2850 let tenant = values.get("tenant_id").cloned().unwrap_or(Value::Null);
2851 values.insert(Arc::clone(&sys_key_tenant()), tenant);
2852 }
2853 let mut rec = UnifiedRecord::default();
2854 for col in &columns {
2856 if let Some(v) = values.get(col.as_str()) {
2857 rec.set_arc(Arc::from(col.as_str()), v.clone());
2858 }
2859 }
2860 records.push(rec);
2861 }
2862
2863 UnifiedResult {
2864 columns,
2865 records,
2866 stats: Default::default(),
2867 pre_serialized_json: None,
2868 }
2869}
2870
2871fn public_returning_item_kind(entity: &crate::storage::UnifiedEntity) -> Option<&'static str> {
2872 match (&entity.kind, &entity.data) {
2873 (crate::storage::EntityKind::GraphNode(_), crate::storage::EntityData::Node(_)) => {
2874 Some("node")
2875 }
2876 (crate::storage::EntityKind::GraphEdge(_), crate::storage::EntityData::Edge(_)) => {
2877 Some("edge")
2878 }
2879 (_, crate::storage::EntityData::Row(_)) => Some(public_returning_row_kind(entity)),
2880 (_, crate::storage::EntityData::Vector(_)) => Some("vector"),
2885 _ => None,
2886 }
2887}
2888
2889fn public_returning_row_kind(entity: &crate::storage::UnifiedEntity) -> &'static str {
2890 let Some(row) = entity.data.as_row() else {
2891 return "row";
2892 };
2893
2894 let is_kv = row.named.as_ref().is_some_and(|named| {
2895 (named.len() == 2 && named.contains_key("key") && named.contains_key("value"))
2896 || (named.len() == 1 && (named.contains_key("key") || named.contains_key("value")))
2897 });
2898 if is_kv {
2899 return "kv";
2900 }
2901
2902 let is_document = row
2903 .named
2904 .as_ref()
2905 .is_some_and(|named| named.values().any(runtime_returning_documentish_value))
2906 || row.columns.iter().any(runtime_returning_documentish_value);
2907 if is_document {
2908 "document"
2909 } else {
2910 "row"
2911 }
2912}
2913
2914fn runtime_returning_documentish_value(value: &Value) -> bool {
2915 matches!(value, Value::Json(_) | Value::Blob(_))
2916}
2917
2918fn row_insert_returning_snapshots(
2919 outputs: &[CreateEntityOutput],
2920 fallback: Vec<Vec<(String, Value)>>,
2921) -> Vec<Vec<(String, Value)>> {
2922 outputs
2923 .iter()
2924 .enumerate()
2925 .map(|(idx, out)| {
2926 out.entity
2927 .as_ref()
2928 .map(entity_row_fields_snapshot)
2929 .filter(|snap| !snap.is_empty())
2930 .unwrap_or_else(|| fallback.get(idx).cloned().unwrap_or_default())
2931 })
2932 .collect()
2933}
2934
2935fn graph_insert_returning_snapshots(
2936 store: &crate::storage::unified::UnifiedStore,
2937 collection: &str,
2938 ids: &[EntityId],
2939) -> Vec<Vec<(String, Value)>> {
2940 let Some(manager) = store.get_collection(collection) else {
2941 return Vec::new();
2942 };
2943
2944 ids.iter()
2945 .filter_map(|id| manager.get(*id))
2946 .filter_map(|entity| {
2947 let mut record = runtime_any_record_from_entity_ref(&entity)?;
2948 record.set_arc(sys_key_collection(), Value::text(collection.to_string()));
2949 Some(record)
2950 })
2951 .map(|record| {
2952 record
2953 .iter_fields()
2954 .map(|(key, value)| (key.as_ref().to_string(), value.clone()))
2955 .collect()
2956 })
2957 .collect()
2958}
2959
2960fn graph_update_returning_snapshots(
2961 runtime: &RedDBRuntime,
2962 collection: &str,
2963 ids: &[EntityId],
2964) -> Vec<Vec<(String, Value)>> {
2965 let store = runtime.db().store();
2966 let Some(manager) = store.get_collection(collection) else {
2967 return Vec::new();
2968 };
2969
2970 manager
2971 .get_many(ids)
2972 .into_iter()
2973 .flatten()
2974 .filter_map(|entity| runtime_any_record_from_entity_ref(&entity))
2975 .map(|record| {
2976 record
2977 .iter_fields()
2978 .map(|(key, value)| (key.as_ref().to_string(), value.clone()))
2979 .collect()
2980 })
2981 .collect()
2982}
2983
2984fn restore_kv_returning_keys(
2985 runtime: &RedDBRuntime,
2986 collection: &str,
2987 ids: &[EntityId],
2988 snapshots: &mut [Vec<(String, Value)>],
2989) {
2990 if ids.is_empty() || snapshots.is_empty() {
2991 return;
2992 }
2993
2994 let store = runtime.db().store();
2995 for (idx, id) in ids.iter().enumerate() {
2996 let Some(snapshot) = snapshots.get_mut(idx) else {
2997 continue;
2998 };
2999 if snapshot.iter().any(|(name, _)| name == "key") {
3000 continue;
3001 }
3002 let Some(entity) = store.get(collection, *id) else {
3003 continue;
3004 };
3005 let logical_id = entity.logical_id();
3006 let key = store
3007 .table_row_versions_by_logical_id(collection, logical_id)
3008 .into_iter()
3009 .find_map(kv_key_value_from_entity)
3010 .or_else(|| kv_key_value_from_entity(entity));
3011 if let Some(key) = key {
3012 snapshot.push(("key".to_string(), key));
3013 }
3014 }
3015}
3016
3017fn kv_key_value_from_entity(entity: UnifiedEntity) -> Option<Value> {
3018 let row = entity.data.as_row()?;
3019 row.get_field("key")
3020 .cloned()
3021 .or_else(|| row.columns.first().cloned())
3022}
3023
3024fn ensure_update_target_contract(
3025 runtime: &RedDBRuntime,
3026 collection: &str,
3027 target: UpdateTarget,
3028) -> RedDBResult<()> {
3029 let Some(contract) = runtime.db().collection_contract(collection) else {
3030 return Ok(());
3031 };
3032 if update_target_contract_is_advisory(&contract)
3033 || update_target_allows_model(contract.declared_model, update_target_model(target))
3034 {
3035 return Ok(());
3036 }
3037 Err(RedDBError::InvalidOperation(format!(
3038 "collection '{}' is declared as '{}' and does not allow '{}' updates",
3039 collection,
3040 update_model_name(contract.declared_model),
3041 update_model_name(update_target_model(target))
3042 )))
3043}
3044
3045fn update_target_contract_is_advisory(contract: &crate::physical::CollectionContract) -> bool {
3046 matches!(
3047 (&contract.origin, &contract.schema_mode),
3048 (
3049 crate::physical::ContractOrigin::Implicit,
3050 crate::catalog::SchemaMode::Dynamic,
3051 )
3052 )
3053}
3054
3055fn update_target_model(target: UpdateTarget) -> crate::catalog::CollectionModel {
3056 match target {
3057 UpdateTarget::Rows => crate::catalog::CollectionModel::Table,
3058 UpdateTarget::Documents => crate::catalog::CollectionModel::Document,
3059 UpdateTarget::Kv => crate::catalog::CollectionModel::Kv,
3060 UpdateTarget::Nodes | UpdateTarget::Edges => crate::catalog::CollectionModel::Graph,
3061 }
3062}
3063
3064fn update_target_allows_model(
3065 declared_model: crate::catalog::CollectionModel,
3066 requested_model: crate::catalog::CollectionModel,
3067) -> bool {
3068 declared_model == requested_model || declared_model == crate::catalog::CollectionModel::Mixed
3069}
3070
3071fn update_model_name(model: crate::catalog::CollectionModel) -> &'static str {
3072 match model {
3073 crate::catalog::CollectionModel::Table => "table",
3074 crate::catalog::CollectionModel::Document => "document",
3075 crate::catalog::CollectionModel::Graph => "graph",
3076 crate::catalog::CollectionModel::Vector => "vector",
3077 crate::catalog::CollectionModel::Hll => "hll",
3078 crate::catalog::CollectionModel::Sketch => "sketch",
3079 crate::catalog::CollectionModel::Filter => "filter",
3080 crate::catalog::CollectionModel::Kv => "kv",
3081 crate::catalog::CollectionModel::Config => "config",
3082 crate::catalog::CollectionModel::Vault => "vault",
3083 crate::catalog::CollectionModel::Mixed => "mixed",
3084 crate::catalog::CollectionModel::TimeSeries => "timeseries",
3085 crate::catalog::CollectionModel::Queue => "queue",
3086 crate::catalog::CollectionModel::Metrics => "metrics",
3087 }
3088}
3089
3090fn ensure_graph_insert_contract(runtime: &RedDBRuntime, collection: &str) -> RedDBResult<()> {
3091 let db = runtime.db();
3092 if let Some(contract) = db.collection_contract(collection) {
3093 let advisory_implicit_dynamic = matches!(
3094 (&contract.origin, &contract.schema_mode),
3095 (
3096 crate::physical::ContractOrigin::Implicit,
3097 crate::catalog::SchemaMode::Dynamic,
3098 )
3099 );
3100 if advisory_implicit_dynamic
3101 || matches!(
3102 contract.declared_model,
3103 crate::catalog::CollectionModel::Graph | crate::catalog::CollectionModel::Mixed
3104 )
3105 {
3106 return Ok(());
3107 }
3108 return Err(RedDBError::InvalidOperation(format!(
3109 "collection '{}' is declared as '{:?}' and does not allow 'Graph' writes",
3110 collection, contract.declared_model
3111 )));
3112 }
3113
3114 let now = std::time::SystemTime::now()
3115 .duration_since(std::time::UNIX_EPOCH)
3116 .unwrap_or_default()
3117 .as_millis();
3118 db.save_collection_contract(crate::physical::CollectionContract {
3119 name: collection.to_string(),
3120 declared_model: crate::catalog::CollectionModel::Graph,
3121 schema_mode: crate::catalog::SchemaMode::Dynamic,
3122 origin: crate::physical::ContractOrigin::Implicit,
3123 version: 1,
3124 created_at_unix_ms: now,
3125 updated_at_unix_ms: now,
3126 default_ttl_ms: db.collection_default_ttl_ms(collection),
3127 vector_dimension: None,
3128 vector_metric: None,
3129 context_index_fields: Vec::new(),
3130 declared_columns: Vec::new(),
3131 table_def: None,
3132 timestamps_enabled: false,
3133 context_index_enabled: false,
3134 metrics_raw_retention_ms: None,
3135 metrics_rollup_policies: Vec::new(),
3136 metrics_tenant_identity: None,
3137 metrics_namespace: None,
3138 append_only: false,
3139 subscriptions: Vec::new(),
3140 analytics_config: Vec::new(),
3141 session_key: None,
3142 session_gap_ms: None,
3143 retention_duration_ms: None,
3144 analytical_storage: None,
3145
3146 ai_policy: None,
3147 })
3148 .map(|_| ())
3149 .map_err(|err| RedDBError::Internal(err.to_string()))
3150}
3151
3152fn update_needs_rmw_lock(query: &UpdateQuery) -> bool {
3153 query
3154 .assignment_exprs
3155 .iter()
3156 .enumerate()
3157 .any(|(idx, (column, expr))| {
3158 query
3159 .compound_assignment_ops
3160 .get(idx)
3161 .is_some_and(|op| op.is_some())
3162 || expr_references_update_column(expr, &query.table, column)
3163 })
3164}
3165
3166fn record_claim_outcome(
3167 telemetry: &crate::runtime::claim_telemetry::ClaimTelemetryCounters,
3168 claim_limit: Option<u64>,
3169 table: &str,
3170 model: &str,
3171 affected: u64,
3172) {
3173 if claim_limit.is_none() {
3174 return;
3175 }
3176 if affected == 0 {
3177 telemetry.record_miss(table, model);
3178 tracing::debug!(
3179 target: "reddb::claim",
3180 collection = table,
3181 model,
3182 "concurrent claim missed"
3183 );
3184 } else {
3185 telemetry.record_successful(table, model, affected);
3186 tracing::debug!(
3187 target: "reddb::claim",
3188 collection = table,
3189 model,
3190 successful = affected,
3191 "concurrent claim succeeded"
3192 );
3193 }
3194}
3195
3196fn evaluate_compound_update_assignment(
3197 column: &str,
3198 record: &UnifiedRecord,
3199 op: BinOp,
3200 rhs: Value,
3201) -> RedDBResult<Value> {
3202 let lhs = record.get(column).ok_or_else(|| {
3203 RedDBError::Query(format!(
3204 "compound assignment requires existing numeric field '{column}'"
3205 ))
3206 })?;
3207 if matches!(lhs, Value::Null) {
3208 return Err(RedDBError::Query(format!(
3209 "compound assignment requires non-null numeric field '{column}'"
3210 )));
3211 }
3212 apply_compound_numeric_op(column, op, lhs, &rhs)
3213}
3214
3215fn apply_compound_numeric_op(
3216 column: &str,
3217 op: BinOp,
3218 lhs: &Value,
3219 rhs: &Value,
3220) -> RedDBResult<Value> {
3221 let Some(lhs_number) = CompoundNumber::from_value(lhs) else {
3222 return Err(RedDBError::Query(format!(
3223 "compound assignment requires numeric field '{column}'"
3224 )));
3225 };
3226 let Some(rhs_number) = CompoundNumber::from_value(rhs) else {
3227 return Err(RedDBError::Query(format!(
3228 "compound assignment requires numeric right-hand value for field '{column}'"
3229 )));
3230 };
3231
3232 if lhs_number.is_float() || rhs_number.is_float() || matches!(op, BinOp::Div) {
3233 let a = lhs_number.as_f64();
3234 let b = rhs_number.as_f64();
3235 let out = match op {
3236 BinOp::Add => a + b,
3237 BinOp::Sub => a - b,
3238 BinOp::Mul => a * b,
3239 BinOp::Div => {
3240 if b == 0.0 {
3241 return Err(RedDBError::Query(format!(
3242 "division by zero in compound assignment for field '{column}'"
3243 )));
3244 }
3245 a / b
3246 }
3247 BinOp::Mod => {
3248 if b == 0.0 {
3249 return Err(RedDBError::Query(format!(
3250 "modulo by zero in compound assignment for field '{column}'"
3251 )));
3252 }
3253 a % b
3254 }
3255 _ => {
3256 return Err(RedDBError::Query(format!(
3257 "unsupported compound assignment operator for field '{column}'"
3258 )));
3259 }
3260 };
3261 if !out.is_finite() {
3262 return Err(RedDBError::Query(format!(
3263 "numeric overflow in compound assignment for field '{column}'"
3264 )));
3265 }
3266 return Ok(Value::Float(out));
3267 }
3268
3269 let a = lhs_number.as_i128();
3270 let b = rhs_number.as_i128();
3271 let out = match op {
3272 BinOp::Add => a.checked_add(b),
3273 BinOp::Sub => a.checked_sub(b),
3274 BinOp::Mul => a.checked_mul(b),
3275 BinOp::Mod => {
3276 if b == 0 {
3277 return Err(RedDBError::Query(format!(
3278 "modulo by zero in compound assignment for field '{column}'"
3279 )));
3280 }
3281 a.checked_rem(b)
3282 }
3283 BinOp::Div => unreachable!("integer division is handled by the float branch"),
3284 _ => None,
3285 }
3286 .ok_or_else(|| {
3287 RedDBError::Query(format!(
3288 "numeric overflow in compound assignment for field '{column}'"
3289 ))
3290 })?;
3291
3292 if matches!(lhs, Value::UnsignedInteger(_)) {
3293 let value = u64::try_from(out).map_err(|_| {
3294 RedDBError::Query(format!(
3295 "numeric overflow in compound assignment for field '{column}'"
3296 ))
3297 })?;
3298 Ok(Value::UnsignedInteger(value))
3299 } else {
3300 let value = i64::try_from(out).map_err(|_| {
3301 RedDBError::Query(format!(
3302 "numeric overflow in compound assignment for field '{column}'"
3303 ))
3304 })?;
3305 Ok(Value::Integer(value))
3306 }
3307}
3308
3309#[derive(Clone, Copy)]
3310enum CompoundNumber {
3311 Integer(i128),
3312 Float(f64),
3313}
3314
3315impl CompoundNumber {
3316 fn from_value(value: &Value) -> Option<Self> {
3317 match value {
3318 Value::Integer(value) | Value::BigInt(value) => Some(Self::Integer(*value as i128)),
3319 Value::UnsignedInteger(value) => Some(Self::Integer(*value as i128)),
3320 Value::Float(value) => value.is_finite().then_some(Self::Float(*value)),
3321 Value::Decimal(value) => Some(Self::Float(*value as f64 / 10_000.0)),
3322 _ => None,
3323 }
3324 }
3325
3326 fn is_float(self) -> bool {
3327 matches!(self, Self::Float(_))
3328 }
3329
3330 fn as_f64(self) -> f64 {
3331 match self {
3332 Self::Integer(value) => value as f64,
3333 Self::Float(value) => value,
3334 }
3335 }
3336
3337 fn as_i128(self) -> i128 {
3338 match self {
3339 Self::Integer(value) => value,
3340 Self::Float(_) => unreachable!("float compound number used as integer"),
3341 }
3342 }
3343}
3344
3345fn expr_references_update_column(expr: &Expr, table_name: &str, target_column: &str) -> bool {
3346 match expr {
3347 Expr::Literal { .. } | Expr::Parameter { .. } | Expr::Subquery { .. } => false,
3348 Expr::Column { field, .. } => {
3349 field_ref_matches_update_column(field, table_name, target_column)
3350 }
3351 Expr::BinaryOp { lhs, rhs, .. } => {
3352 expr_references_update_column(lhs, table_name, target_column)
3353 || expr_references_update_column(rhs, table_name, target_column)
3354 }
3355 Expr::UnaryOp { operand, .. } | Expr::Cast { inner: operand, .. } => {
3356 expr_references_update_column(operand, table_name, target_column)
3357 }
3358 Expr::FunctionCall { args, .. } => args
3359 .iter()
3360 .any(|arg| expr_references_update_column(arg, table_name, target_column)),
3361 Expr::Case {
3362 branches, else_, ..
3363 } => {
3364 branches.iter().any(|(cond, value)| {
3365 expr_references_update_column(cond, table_name, target_column)
3366 || expr_references_update_column(value, table_name, target_column)
3367 }) || else_
3368 .as_deref()
3369 .is_some_and(|expr| expr_references_update_column(expr, table_name, target_column))
3370 }
3371 Expr::IsNull { operand, .. } => {
3372 expr_references_update_column(operand, table_name, target_column)
3373 }
3374 Expr::InList { target, values, .. } => {
3375 expr_references_update_column(target, table_name, target_column)
3376 || values
3377 .iter()
3378 .any(|value| expr_references_update_column(value, table_name, target_column))
3379 }
3380 Expr::Between {
3381 target, low, high, ..
3382 } => {
3383 expr_references_update_column(target, table_name, target_column)
3384 || expr_references_update_column(low, table_name, target_column)
3385 || expr_references_update_column(high, table_name, target_column)
3386 }
3387 Expr::WindowFunctionCall { args, window, .. } => {
3388 args.iter()
3389 .any(|arg| expr_references_update_column(arg, table_name, target_column))
3390 || window
3391 .partition_by
3392 .iter()
3393 .any(|e| expr_references_update_column(e, table_name, target_column))
3394 || window
3395 .order_by
3396 .iter()
3397 .any(|o| expr_references_update_column(&o.expr, table_name, target_column))
3398 }
3399 }
3400}
3401
3402fn field_ref_matches_update_column(
3403 field: &FieldRef,
3404 table_name: &str,
3405 target_column: &str,
3406) -> bool {
3407 match field {
3408 FieldRef::TableColumn { table, column } => {
3409 column.eq_ignore_ascii_case(target_column)
3410 && (table.is_empty() || table.eq_ignore_ascii_case(table_name))
3411 }
3412 FieldRef::NodeProperty { .. } | FieldRef::EdgeProperty { .. } | FieldRef::NodeId { .. } => {
3413 false
3414 }
3415 }
3416}
3417
3418fn resolve_update_entity_by_logical_id(
3419 runtime: &RedDBRuntime,
3420 table: &str,
3421 logical_id: EntityId,
3422) -> Option<UnifiedEntity> {
3423 let store = runtime.inner.db.store();
3424 if let Some(entity) = store.get_table_row_by_logical_id(table, logical_id) {
3425 return Some(entity);
3426 }
3427 store.get(table, logical_id)
3430}
3431
3432fn update_cdc_item_kind(
3433 runtime: &RedDBRuntime,
3434 collection: &str,
3435 entity: &UnifiedEntity,
3436) -> &'static str {
3437 match &entity.data {
3438 EntityData::Node(_) => return "node",
3439 EntityData::Edge(_) => return "edge",
3440 _ => {}
3441 }
3442
3443 match runtime
3444 .db()
3445 .collection_contract(collection)
3446 .map(|contract| contract.declared_model)
3447 {
3448 Some(crate::catalog::CollectionModel::Document) => "document",
3449 Some(crate::catalog::CollectionModel::Kv)
3450 | Some(crate::catalog::CollectionModel::Vault) => "kv",
3451 _ => "row",
3452 }
3453}
3454
3455fn ordered_update_target_ids(
3456 manager: &Arc<crate::storage::SegmentManager>,
3457 entity_ids: &[EntityId],
3458 order_by: &[OrderByClause],
3459 limit: Option<usize>,
3460) -> Vec<EntityId> {
3461 let mut entities: Vec<UnifiedEntity> =
3462 manager.get_many(entity_ids).into_iter().flatten().collect();
3463 entities.sort_by(|left, right| compare_update_order(left, right, order_by));
3464 if let Some(limit) = limit {
3465 entities.truncate(limit);
3466 }
3467 entities.into_iter().map(|entity| entity.id).collect()
3468}
3469
3470fn compare_update_order(
3471 left: &UnifiedEntity,
3472 right: &UnifiedEntity,
3473 order_by: &[OrderByClause],
3474) -> Ordering {
3475 for clause in order_by {
3476 let left_value = update_order_value(left, &clause.field);
3477 let right_value = update_order_value(right, &clause.field);
3478 let ordering = compare_update_order_values(
3479 left_value.as_ref(),
3480 right_value.as_ref(),
3481 clause.nulls_first,
3482 );
3483 if ordering != Ordering::Equal {
3484 return if clause.ascending {
3485 ordering
3486 } else {
3487 ordering.reverse()
3488 };
3489 }
3490 }
3491 left.logical_id().raw().cmp(&right.logical_id().raw())
3492}
3493
3494fn compare_update_order_values(
3495 left: Option<&Value>,
3496 right: Option<&Value>,
3497 nulls_first: bool,
3498) -> Ordering {
3499 match (left, right) {
3500 (None, None) => Ordering::Equal,
3501 (None, Some(_)) => {
3502 if nulls_first {
3503 Ordering::Less
3504 } else {
3505 Ordering::Greater
3506 }
3507 }
3508 (Some(_), None) => {
3509 if nulls_first {
3510 Ordering::Greater
3511 } else {
3512 Ordering::Less
3513 }
3514 }
3515 (Some(left), Some(right)) => {
3516 crate::storage::query::value_compare::total_compare_values(left, right)
3517 }
3518 }
3519}
3520
3521fn update_order_value(entity: &UnifiedEntity, field: &FieldRef) -> Option<Value> {
3522 let FieldRef::TableColumn { table, column } = field else {
3523 return None;
3524 };
3525 if !table.is_empty() {
3526 return None;
3527 }
3528 if column.eq_ignore_ascii_case("rid") {
3529 return Some(Value::UnsignedInteger(entity.logical_id().raw()));
3530 }
3531 match &entity.data {
3532 EntityData::Row(row) => {
3539 row.get_field(column)
3540 .cloned()
3541 .or_else(|| match row.get_field("body") {
3542 Some(Value::Json(bytes)) => {
3543 crate::document_body::read_body_field(bytes, column)
3544 }
3545 _ => None,
3546 })
3547 }
3548 EntityData::Node(_) | EntityData::Edge(_) => runtime_any_record_from_entity_ref(entity)
3549 .and_then(|record| record.get(column).cloned()),
3550 _ => None,
3551 }
3552}
3553
3554fn dedupe_update_columns(mut columns: Vec<String>) -> Vec<String> {
3555 if columns.is_empty() {
3556 return columns;
3557 }
3558
3559 let mut unique = Vec::with_capacity(columns.len());
3560 for column in columns.drain(..) {
3561 if !unique
3562 .iter()
3563 .any(|existing: &String| existing.eq_ignore_ascii_case(&column))
3564 {
3565 unique.push(column);
3566 }
3567 }
3568 unique
3569}
3570
3571const SQL_TTL_METADATA_COLUMNS: [&str; 3] = ["_ttl", "_ttl_ms", "_expires_at"];
3576
3577fn resolve_sql_ttl_metadata_key(column: &str) -> Option<&'static str> {
3578 if column.eq_ignore_ascii_case("_ttl") {
3579 Some(SQL_TTL_METADATA_COLUMNS[0])
3580 } else if column.eq_ignore_ascii_case("_ttl_ms") {
3581 Some(SQL_TTL_METADATA_COLUMNS[1])
3582 } else if column.eq_ignore_ascii_case("_expires_at") {
3583 Some(SQL_TTL_METADATA_COLUMNS[2])
3584 } else {
3585 None
3586 }
3587}
3588
3589fn canonicalize_sql_ttl_metadata(
3594 key: &'static str,
3595 value: MetadataValue,
3596) -> (&'static str, MetadataValue) {
3597 if key != "_ttl" {
3598 return (key, value);
3599 }
3600 let scaled = match value {
3601 MetadataValue::Int(s) => MetadataValue::Int(s.saturating_mul(1_000)),
3602 MetadataValue::Timestamp(ms_or_s) => {
3603 MetadataValue::Timestamp(ms_or_s)
3606 }
3607 MetadataValue::Float(f) => MetadataValue::Float(f * 1_000.0),
3608 other => other,
3609 };
3610 ("_ttl_ms", scaled)
3611}
3612
3613pub(crate) const PLAINTEXT_SENTINEL: &str = "@@plain@@";
3617
3618impl RedDBRuntime {
3619 pub(crate) fn resolve_crypto_sentinel(&self, value: Value) -> RedDBResult<Value> {
3625 match value {
3626 Value::Password(marked) => {
3627 if let Some(plain) = marked.strip_prefix(PLAINTEXT_SENTINEL) {
3628 Ok(Value::Password(crate::auth::store::hash_password(plain)))
3629 } else {
3630 Ok(Value::Password(marked))
3631 }
3632 }
3633 Value::Secret(bytes) => {
3634 if bytes.starts_with(PLAINTEXT_SENTINEL.as_bytes()) {
3635 if !self.secret_auto_encrypt() {
3636 return Err(RedDBError::Query(
3637 "SECRET() literal rejected: red.config.secret.auto_encrypt \
3638 is false. Insert pre-encrypted bytes directly instead."
3639 .to_string(),
3640 ));
3641 }
3642 let key = self.secret_aes_key().ok_or_else(|| {
3643 RedDBError::Query(
3644 "SECRET() column encryption requires a bootstrapped \
3645 vault (red.secret.aes_key is missing). Start the server \
3646 with --vault to enable."
3647 .to_string(),
3648 )
3649 })?;
3650 let plain = &bytes[PLAINTEXT_SENTINEL.len()..];
3651 Ok(Value::Secret(encrypt_secret_payload(&key, plain)))
3652 } else {
3653 Ok(Value::Secret(bytes))
3654 }
3655 }
3656 other => Ok(other),
3657 }
3658 }
3659}
3660
3661fn encrypt_secret_payload(key: &[u8; 32], plaintext: &[u8]) -> Vec<u8> {
3664 let nonce_bytes = crate::auth::store::random_bytes(12);
3665 let mut nonce = [0u8; 12];
3666 nonce.copy_from_slice(&nonce_bytes[..12]);
3667 let ct = crate::crypto::aes_gcm::aes256_gcm_encrypt(key, &nonce, b"reddb.secret", plaintext);
3668 let mut out = Vec::with_capacity(12 + ct.len());
3669 out.extend_from_slice(&nonce);
3670 out.extend_from_slice(&ct);
3671 out
3672}
3673
3674pub(crate) fn decrypt_secret_payload(key: &[u8; 32], payload: &[u8]) -> Option<Vec<u8>> {
3678 if payload.len() < 12 {
3679 return None;
3680 }
3681 let mut nonce = [0u8; 12];
3682 nonce.copy_from_slice(&payload[..12]);
3683 crate::crypto::aes_gcm::aes256_gcm_decrypt(key, &nonce, b"reddb.secret", &payload[12..]).ok()
3684}
3685
3686fn split_insert_metadata(
3687 runtime: &RedDBRuntime,
3688 columns: &[String],
3689 values: &[Value],
3690) -> RedDBResult<(Vec<(String, Value)>, Vec<(String, MetadataValue)>)> {
3691 let mut fields = Vec::new();
3692 let mut metadata = Vec::new();
3693
3694 for (column, value) in columns.iter().zip(values.iter()) {
3695 if let Some(metadata_key) = resolve_sql_ttl_metadata_key(column) {
3697 let raw_value = sql_literal_to_metadata_value(metadata_key, value)?;
3698 let (canonical_key, canonical_value) =
3699 canonicalize_sql_ttl_metadata(metadata_key, raw_value);
3700 metadata.push((canonical_key.to_string(), canonical_value));
3701 continue;
3702 }
3703 fields.push((
3704 column.clone(),
3705 runtime.resolve_crypto_sentinel(value.clone())?,
3706 ));
3707 }
3708
3709 Ok((fields, metadata))
3710}
3711
3712fn merge_with_clauses(
3714 metadata: &mut Vec<(String, MetadataValue)>,
3715 ttl_ms: Option<u64>,
3716 expires_at_ms: Option<u64>,
3717 with_metadata: &[(String, Value)],
3718) {
3719 if let Some(ms) = ttl_ms {
3720 metadata.push((
3721 "_ttl_ms".to_string(),
3722 if ms <= i64::MAX as u64 {
3723 MetadataValue::Int(ms as i64)
3724 } else {
3725 MetadataValue::Timestamp(ms)
3726 },
3727 ));
3728 }
3729 if let Some(ms) = expires_at_ms {
3730 metadata.push(("_expires_at".to_string(), MetadataValue::Timestamp(ms)));
3731 }
3732 for (key, value) in with_metadata {
3733 let meta_value = match value {
3734 Value::Text(s) => MetadataValue::String(s.to_string()),
3735 Value::Integer(n) => MetadataValue::Int(*n),
3736 Value::Float(n) => MetadataValue::Float(*n),
3737 Value::Boolean(b) => MetadataValue::Bool(*b),
3738 _ => MetadataValue::String(value.to_string()),
3739 };
3740 metadata.push((key.clone(), meta_value));
3741 }
3742}
3743
3744fn merge_vector_metadata_column(
3745 metadata: &mut Vec<(String, MetadataValue)>,
3746 columns: &[String],
3747 values: &[Value],
3748) -> RedDBResult<()> {
3749 let Some(value) = columns
3750 .iter()
3751 .position(|column| column.eq_ignore_ascii_case("metadata"))
3752 .map(|index| &values[index])
3753 else {
3754 return Ok(());
3755 };
3756 let json = match value {
3757 Value::Null => return Ok(()),
3758 Value::Json(bytes) => crate::json::from_slice(bytes).map_err(|err| {
3759 RedDBError::Query(format!("column 'metadata' invalid JSON object: {err}"))
3760 })?,
3761 Value::Text(text) => crate::json::from_str(text).map_err(|err| {
3762 RedDBError::Query(format!("column 'metadata' invalid JSON object: {err}"))
3763 })?,
3764 other => {
3765 return Err(RedDBError::Query(format!(
3766 "column 'metadata' expected JSON object, got {other:?}"
3767 )))
3768 }
3769 };
3770 let parsed = metadata_from_json(&json)?;
3771 for (key, value) in parsed.iter() {
3772 metadata.push((key.clone(), value.clone()));
3773 }
3774 Ok(())
3775}
3776
3777fn apply_collection_default_ttl_metadata(
3778 runtime: &RedDBRuntime,
3779 collection: &str,
3780 metadata: &mut Vec<(String, MetadataValue)>,
3781) {
3782 if has_internal_ttl_metadata(metadata) {
3783 return;
3784 }
3785
3786 let Some(default_ttl_ms) = runtime.db().collection_default_ttl_ms(collection) else {
3787 return;
3788 };
3789
3790 metadata.push((
3791 "_ttl_ms".to_string(),
3792 if default_ttl_ms <= i64::MAX as u64 {
3793 MetadataValue::Int(default_ttl_ms as i64)
3794 } else {
3795 MetadataValue::Timestamp(default_ttl_ms)
3796 },
3797 ));
3798}
3799
3800fn ensure_non_tree_reserved_metadata_entries(
3801 metadata: &[(String, MetadataValue)],
3802) -> RedDBResult<()> {
3803 for (key, _) in metadata {
3804 ensure_non_tree_reserved_metadata_key(key)?;
3805 }
3806 Ok(())
3807}
3808
3809fn ensure_non_tree_reserved_metadata_key(key: &str) -> RedDBResult<()> {
3810 if key.starts_with(TREE_METADATA_PREFIX) {
3811 return Err(RedDBError::Query(format!(
3812 "metadata key '{}' is reserved for managed trees",
3813 key
3814 )));
3815 }
3816 Ok(())
3817}
3818
3819fn ensure_non_tree_structural_edge_label(label: &str) -> RedDBResult<()> {
3820 if label.eq_ignore_ascii_case(TREE_CHILD_EDGE_LABEL) {
3821 return Err(RedDBError::Query(format!(
3822 "edge label '{}' is reserved for managed trees",
3823 TREE_CHILD_EDGE_LABEL
3824 )));
3825 }
3826 Ok(())
3827}
3828
3829fn pairwise_columns_values(pairs: &[(String, Value)]) -> (Vec<String>, Vec<Value>) {
3830 let mut columns = Vec::with_capacity(pairs.len());
3831 let mut values = Vec::with_capacity(pairs.len());
3832
3833 for (column, value) in pairs {
3834 columns.push(column.clone());
3835 values.push(value.clone());
3836 }
3837
3838 (columns, values)
3839}
3840
3841fn find_column_value(columns: &[String], values: &[Value], name: &str) -> RedDBResult<Value> {
3843 for (i, col) in columns.iter().enumerate() {
3844 if col.eq_ignore_ascii_case(name) {
3845 return Ok(values[i].clone());
3846 }
3847 }
3848 Err(RedDBError::Query(format!(
3849 "required column '{name}' not found in INSERT"
3850 )))
3851}
3852
3853fn find_column_value_string(
3855 columns: &[String],
3856 values: &[Value],
3857 name: &str,
3858) -> RedDBResult<String> {
3859 let val = find_column_value(columns, values, name)?;
3860 match val {
3861 Value::Text(s) => Ok(s.to_string()),
3862 Value::Integer(n) => Ok(n.to_string()),
3863 Value::Float(n) => Ok(n.to_string()),
3864 other => Err(RedDBError::Query(format!(
3865 "column '{name}' expected text, got {other:?}"
3866 ))),
3867 }
3868}
3869
3870fn find_document_body_json(
3871 columns: &[String],
3872 values: &[Value],
3873) -> RedDBResult<crate::json::Value> {
3874 let val = find_column_value(columns, values, "body")?;
3875 match val {
3876 Value::Json(bytes) | Value::Blob(bytes) => {
3877 if let Some(body) = crate::document_body::decode_container_to_json(&bytes) {
3878 Ok(body)
3879 } else {
3880 crate::json::from_slice(&bytes)
3881 .map_err(|err| RedDBError::Query(format!("invalid JSON body: {err}")))
3882 }
3883 }
3884 Value::Text(text) => crate::json::from_str(text.as_ref())
3885 .map_err(|err| RedDBError::Query(format!("invalid JSON body: {err}"))),
3886 Value::Integer(value) => crate::json::from_str(&value.to_string())
3887 .map_err(|err| RedDBError::Query(format!("invalid JSON body: {err}"))),
3888 Value::UnsignedInteger(value) => crate::json::from_str(&value.to_string())
3889 .map_err(|err| RedDBError::Query(format!("invalid JSON body: {err}"))),
3890 Value::Float(value) => crate::json::from_str(&value.to_string())
3891 .map_err(|err| RedDBError::Query(format!("invalid JSON body: {err}"))),
3892 other => Err(RedDBError::Query(format!(
3893 "column 'body' expected JSON body, got {other:?}"
3894 ))),
3895 }
3896}
3897
3898fn find_column_value_f64(columns: &[String], values: &[Value], name: &str) -> RedDBResult<f64> {
3899 let val = find_column_value(columns, values, name)?;
3900 match val {
3901 Value::Float(n) => Ok(n),
3902 Value::Integer(n) => Ok(n as f64),
3903 Value::UnsignedInteger(n) => Ok(n as f64),
3904 Value::Text(s) => s
3905 .parse::<f64>()
3906 .map_err(|_| RedDBError::Query(format!("column '{name}' expected number, got '{s}'"))),
3907 other => Err(RedDBError::Query(format!(
3908 "column '{name}' expected number, got {other:?}"
3909 ))),
3910 }
3911}
3912
3913fn find_column_value_opt_string(
3915 columns: &[String],
3916 values: &[Value],
3917 name: &str,
3918) -> Option<String> {
3919 for (i, col) in columns.iter().enumerate() {
3920 if col.eq_ignore_ascii_case(name) {
3921 return match &values[i] {
3922 Value::Null => None,
3923 Value::Text(s) => Some(s.to_string()),
3924 Value::Integer(n) => Some(n.to_string()),
3925 Value::Float(n) => Some(n.to_string()),
3926 _ => None,
3927 };
3928 }
3929 }
3930 None
3931}
3932
3933fn resolve_edge_endpoint(
3940 store: &crate::storage::unified::UnifiedStore,
3941 collection: &str,
3942 columns: &[String],
3943 values: &[Value],
3944 name: &str,
3945) -> RedDBResult<u64> {
3946 let val = find_column_value(columns, values, name)?;
3947 match val {
3948 Value::Integer(n) => Ok(n as u64),
3949 Value::UnsignedInteger(n) => Ok(n),
3950 Value::Text(s) => {
3951 if let Ok(n) = s.parse::<u64>() {
3952 return Ok(n);
3953 }
3954 let matches = store.lookup_graph_nodes_by_label_in(collection, &s);
3955 match matches.len() {
3956 0 => Err(RedDBError::Query(format!(
3957 "column '{name}': no graph node with label '{s}' in collection '{collection}'"
3958 ))),
3959 1 => Ok(matches[0].raw()),
3960 n => Err(RedDBError::Query(format!(
3961 "column '{name}': ambiguous label '{s}' matches {n} nodes in collection '{collection}'; use the numeric id"
3962 ))),
3963 }
3964 }
3965 other => Err(RedDBError::Query(format!(
3966 "column '{name}' expected integer or node label, got {other:?}"
3967 ))),
3968 }
3969}
3970
3971fn resolve_edge_endpoint_any(
3972 store: &crate::storage::unified::UnifiedStore,
3973 collection: &str,
3974 columns: &[String],
3975 values: &[Value],
3976 names: &[&str],
3977) -> RedDBResult<u64> {
3978 for name in names {
3979 if columns
3980 .iter()
3981 .any(|column| column.eq_ignore_ascii_case(name))
3982 {
3983 return resolve_edge_endpoint(store, collection, columns, values, name);
3984 }
3985 }
3986
3987 Err(RedDBError::Query(format!(
3988 "required column '{}' not found in INSERT",
3989 names.first().copied().unwrap_or("from_rid")
3990 )))
3991}
3992
3993fn find_column_value_u64(columns: &[String], values: &[Value], name: &str) -> RedDBResult<u64> {
3995 let val = find_column_value(columns, values, name)?;
3996 match val {
3997 Value::Integer(n) => Ok(n as u64),
3998 Value::UnsignedInteger(n) => Ok(n),
3999 Value::Text(s) => s
4000 .parse::<u64>()
4001 .map_err(|_| RedDBError::Query(format!("column '{name}' expected integer, got '{s}'"))),
4002 other => Err(RedDBError::Query(format!(
4003 "column '{name}' expected integer, got {other:?}"
4004 ))),
4005 }
4006}
4007
4008fn find_column_value_f32_opt(columns: &[String], values: &[Value], name: &str) -> Option<f32> {
4010 for (i, col) in columns.iter().enumerate() {
4011 if col.eq_ignore_ascii_case(name) {
4012 return match &values[i] {
4013 Value::Float(n) => Some(*n as f32),
4014 Value::Integer(n) => Some(*n as f32),
4015 Value::Null => None,
4016 _ => None,
4017 };
4018 }
4019 }
4020 None
4021}
4022
4023fn find_column_value_vec_f32(
4025 columns: &[String],
4026 values: &[Value],
4027 name: &str,
4028) -> RedDBResult<Vec<f32>> {
4029 let val = find_column_value(columns, values, name)?;
4030 match val {
4031 Value::Vector(v) => Ok(v),
4032 Value::Json(bytes) => {
4033 let s = std::str::from_utf8(&bytes).map_err(|_| {
4035 RedDBError::Query(format!("column '{name}' contains invalid UTF-8"))
4036 })?;
4037 let arr: Vec<f32> = crate::json::from_str(s).map_err(|e| {
4038 RedDBError::Query(format!("column '{name}' invalid vector JSON: {e}"))
4039 })?;
4040 Ok(arr)
4041 }
4042 other => Err(RedDBError::Query(format!(
4043 "column '{name}' expected vector, got {other:?}"
4044 ))),
4045 }
4046}
4047
4048fn find_column_value_vec_f32_any(
4049 columns: &[String],
4050 values: &[Value],
4051 names: &[&str],
4052) -> RedDBResult<Vec<f32>> {
4053 for name in names {
4054 if columns
4055 .iter()
4056 .any(|column| column.eq_ignore_ascii_case(name))
4057 {
4058 return find_column_value_vec_f32(columns, values, name);
4059 }
4060 }
4061 Err(RedDBError::Query(format!(
4062 "required vector column '{}' not found in INSERT",
4063 names.join("' or '")
4064 )))
4065}
4066
4067fn extract_remaining_properties(
4069 columns: &[String],
4070 values: &[Value],
4071 exclude: &[&str],
4072) -> Vec<(String, Value)> {
4073 columns
4074 .iter()
4075 .zip(values.iter())
4076 .filter(|(col, _)| !exclude.iter().any(|e| col.eq_ignore_ascii_case(e)))
4077 .map(|(col, val)| (col.clone(), val.clone()))
4078 .collect()
4079}
4080
4081fn validate_timeseries_insert_columns(columns: &[String]) -> RedDBResult<()> {
4082 let mut invalid = Vec::new();
4083 for column in columns {
4084 if !is_timeseries_insert_column(column) && resolve_sql_ttl_metadata_key(column).is_none() {
4085 invalid.push(column.clone());
4086 }
4087 }
4088
4089 if invalid.is_empty() {
4090 Ok(())
4091 } else {
4092 Err(RedDBError::Query(format!(
4093 "timeseries INSERT only accepts metric, value, tags, timestamp, timestamp_ns, or time columns; got {}",
4094 invalid.join(", ")
4095 )))
4096 }
4097}
4098
4099fn is_timeseries_insert_column(column: &str) -> bool {
4100 matches!(
4101 column.to_ascii_lowercase().as_str(),
4102 "metric"
4103 | "value"
4104 | "tags"
4105 | "timestamp"
4106 | "timestamp_ns"
4107 | "time"
4108 | "event_name"
4113 | "payload"
4114 )
4115}
4116
4117fn find_timeseries_timestamp_ns(columns: &[String], values: &[Value]) -> RedDBResult<Option<u64>> {
4118 let mut found = None;
4119
4120 for alias in ["timestamp_ns", "timestamp", "time"] {
4121 for (index, column) in columns.iter().enumerate() {
4122 if !column.eq_ignore_ascii_case(alias) {
4123 continue;
4124 }
4125
4126 if found.is_some() {
4127 return Err(RedDBError::Query(
4128 "timeseries INSERT accepts only one timestamp column".to_string(),
4129 ));
4130 }
4131
4132 found = Some(coerce_value_to_non_negative_u64(&values[index], alias)?);
4133 }
4134 }
4135
4136 Ok(found)
4137}
4138
4139fn find_timeseries_tags(
4140 columns: &[String],
4141 values: &[Value],
4142) -> RedDBResult<std::collections::HashMap<String, String>> {
4143 for (index, column) in columns.iter().enumerate() {
4144 if column.eq_ignore_ascii_case("tags") {
4145 return parse_timeseries_tags(&values[index]);
4146 }
4147 }
4148 Ok(std::collections::HashMap::new())
4149}
4150
4151fn parse_timeseries_tags(value: &Value) -> RedDBResult<std::collections::HashMap<String, String>> {
4152 match value {
4153 Value::Null => Ok(std::collections::HashMap::new()),
4154 Value::Json(bytes) => parse_timeseries_tags_json(bytes),
4155 Value::Text(text) => parse_timeseries_tags_json(text.as_bytes()),
4156 other => Err(RedDBError::Query(format!(
4157 "timeseries tags must be a JSON object or JSON text, got {other:?}"
4158 ))),
4159 }
4160}
4161
4162fn parse_timeseries_tags_json(
4163 bytes: &[u8],
4164) -> RedDBResult<std::collections::HashMap<String, String>> {
4165 let json: crate::json::Value = crate::json::from_slice(bytes)
4166 .map_err(|err| RedDBError::Query(format!("timeseries tags must be valid JSON: {err}")))?;
4167
4168 let object = match json {
4169 crate::json::Value::Object(object) => object,
4170 other => {
4171 return Err(RedDBError::Query(format!(
4172 "timeseries tags must be a JSON object, got {other:?}"
4173 )))
4174 }
4175 };
4176
4177 let mut tags = std::collections::HashMap::with_capacity(object.len());
4178 for (key, value) in object {
4179 tags.insert(key, json_tag_value_to_string(&value));
4180 }
4181 Ok(tags)
4182}
4183
4184fn json_tag_value_to_string(value: &crate::json::Value) -> String {
4200 let mut buf = String::with_capacity(value.to_string_compact().len() + 1);
4201 buf.push(crate::runtime::query_exec::TIMESERIES_TAG_JSON_PREFIX);
4202 buf.push_str(&value.to_string_compact());
4203 buf
4204}
4205
4206fn coerce_value_to_non_negative_u64(value: &Value, column: &str) -> RedDBResult<u64> {
4207 match value {
4208 Value::UnsignedInteger(value) => Ok(*value),
4209 Value::Integer(value) if *value >= 0 => Ok(*value as u64),
4210 Value::Float(value) if *value >= 0.0 => Ok(*value as u64),
4211 Value::Text(value) => value.parse::<u64>().map_err(|_| {
4212 RedDBError::Query(format!(
4213 "column '{column}' expected a non-negative integer timestamp, got '{value}'"
4214 ))
4215 }),
4216 other => Err(RedDBError::Query(format!(
4217 "column '{column}' expected a non-negative integer timestamp, got {other:?}"
4218 ))),
4219 }
4220}
4221
4222fn current_unix_ns() -> u64 {
4223 std::time::SystemTime::now()
4224 .duration_since(std::time::UNIX_EPOCH)
4225 .unwrap_or_default()
4226 .as_nanos()
4227 .min(u128::from(u64::MAX)) as u64
4228}
4229
4230fn metadata_value_to_json(value: &MetadataValue) -> crate::json::Value {
4231 use crate::json::{Map, Value as JV};
4232 match value {
4233 MetadataValue::Null => JV::Null,
4234 MetadataValue::Bool(value) => JV::Bool(*value),
4235 MetadataValue::Int(value) => JV::Number(*value as f64),
4236 MetadataValue::Float(value) => JV::Number(*value),
4237 MetadataValue::String(value) => JV::String(value.clone()),
4238 MetadataValue::Bytes(value) => JV::Array(
4239 value
4240 .iter()
4241 .map(|value| JV::Number(*value as f64))
4242 .collect(),
4243 ),
4244 MetadataValue::Timestamp(value) => JV::Number(*value as f64),
4245 MetadataValue::Array(values) => {
4246 JV::Array(values.iter().map(metadata_value_to_json).collect())
4247 }
4248 MetadataValue::Object(object) => {
4249 let entries = object
4250 .iter()
4251 .map(|(key, value)| (key.clone(), metadata_value_to_json(value)))
4252 .collect();
4253 JV::Object(entries)
4254 }
4255 MetadataValue::Geo { lat, lon } => {
4256 let mut object = Map::new();
4257 object.insert("lat".to_string(), JV::Number(*lat));
4258 object.insert("lon".to_string(), JV::Number(*lon));
4259 JV::Object(object)
4260 }
4261 MetadataValue::Reference(target) => {
4262 let mut object = Map::new();
4263 object.insert(
4264 "collection".to_string(),
4265 JV::String(target.collection().to_string()),
4266 );
4267 object.insert(
4268 "entity_id".to_string(),
4269 JV::Number(target.entity_id().raw() as f64),
4270 );
4271 JV::Object(object)
4272 }
4273 MetadataValue::References(values) => {
4274 let refs = values
4275 .iter()
4276 .map(|target| {
4277 let mut object = Map::new();
4278 object.insert(
4279 "collection".to_string(),
4280 JV::String(target.collection().to_string()),
4281 );
4282 object.insert(
4283 "entity_id".to_string(),
4284 JV::Number(target.entity_id().raw() as f64),
4285 );
4286 JV::Object(object)
4287 })
4288 .collect();
4289 JV::Array(refs)
4290 }
4291 }
4292}
4293
4294fn storage_value_to_metadata_value(value: &Value) -> MetadataValue {
4295 match value {
4296 Value::Null => MetadataValue::Null,
4297 Value::Boolean(value) => MetadataValue::Bool(*value),
4298 Value::Integer(value) => MetadataValue::Int(*value),
4299 Value::UnsignedInteger(value) => metadata_u64_to_value(*value),
4300 Value::Float(value) => MetadataValue::Float(*value),
4301 Value::Text(value) => MetadataValue::String(value.to_string()),
4302 Value::Blob(value) => MetadataValue::Bytes(value.clone()),
4303 Value::Timestamp(value) => {
4304 if *value >= 0 {
4305 metadata_u64_to_value(*value as u64)
4306 } else {
4307 MetadataValue::Int(*value)
4308 }
4309 }
4310 Value::TimestampMs(value) => {
4311 if *value >= 0 {
4312 metadata_u64_to_value(*value as u64)
4313 } else {
4314 MetadataValue::Int(*value)
4315 }
4316 }
4317 Value::Json(value) => MetadataValue::String(String::from_utf8_lossy(value).into_owned()),
4318 Value::Uuid(value) => MetadataValue::String(format!("{value:?}")),
4319 Value::Date(value) => MetadataValue::String(value.to_string()),
4320 Value::Time(value) => MetadataValue::String(value.to_string()),
4321 Value::Decimal(value) => MetadataValue::String(value.to_string()),
4322 Value::Ipv4(value) => MetadataValue::String(format!(
4323 "{}.{}.{}.{}",
4324 (value >> 24) & 0xFF,
4325 (value >> 16) & 0xFF,
4326 (value >> 8) & 0xFF,
4327 value & 0xFF
4328 )),
4329 Value::Port(value) => MetadataValue::Int(i64::from(*value)),
4330 Value::Latitude(value) => MetadataValue::Float(*value as f64 / 1_000_000.0),
4331 Value::Longitude(value) => MetadataValue::Float(*value as f64 / 1_000_000.0),
4332 Value::GeoPoint(lat, lon) => MetadataValue::Geo {
4333 lat: *lat as f64 / 1_000_000.0,
4334 lon: *lon as f64 / 1_000_000.0,
4335 },
4336 Value::BigInt(value) => MetadataValue::String(value.to_string()),
4337 Value::TableRef(value) => MetadataValue::String(value.clone()),
4338 Value::PageRef(value) => MetadataValue::Int(*value as i64),
4339 Value::Password(value) => MetadataValue::String(value.clone()),
4340 Value::Array(values) => {
4341 MetadataValue::Array(values.iter().map(storage_value_to_metadata_value).collect())
4342 }
4343 _ => MetadataValue::String(value.to_string()),
4344 }
4345}
4346
4347fn sql_literal_to_metadata_value(field: &str, value: &Value) -> RedDBResult<MetadataValue> {
4348 match value {
4349 Value::Null => Ok(MetadataValue::Null),
4350 Value::Integer(value) if *value >= 0 => Ok(metadata_u64_to_value(*value as u64)),
4351 Value::Integer(_) => Err(RedDBError::Query(format!(
4352 "column '{field}' must be non-negative for TTL metadata"
4353 ))),
4354 Value::UnsignedInteger(value) => Ok(metadata_u64_to_value(*value)),
4355 Value::Float(value) if value.is_finite() => {
4356 if value.fract().abs() >= f64::EPSILON {
4357 return Err(RedDBError::Query(format!(
4358 "column '{field}' must be an integer (TTL metadata must be an integer)"
4359 )));
4360 }
4361 if *value < 0.0 {
4362 return Err(RedDBError::Query(format!(
4363 "column '{field}' must be non-negative for TTL metadata"
4364 )));
4365 }
4366 if *value > u64::MAX as f64 {
4367 return Err(RedDBError::Query(format!(
4368 "column '{field}' value is too large"
4369 )));
4370 }
4371 Ok(metadata_u64_to_value(*value as u64))
4372 }
4373 Value::Float(_) => Err(RedDBError::Query(format!(
4374 "column '{field}' must be a finite number"
4375 ))),
4376 Value::Text(value) => {
4377 let value = value.trim();
4378 if let Ok(value) = value.parse::<u64>() {
4379 Ok(metadata_u64_to_value(value))
4380 } else if let Ok(value) = value.parse::<i64>() {
4381 if value < 0 {
4382 return Err(RedDBError::Query(format!(
4383 "column '{field}' must be non-negative for TTL metadata"
4384 )));
4385 }
4386 Ok(metadata_u64_to_value(value as u64))
4387 } else if let Ok(value) = value.parse::<f64>() {
4388 if !value.is_finite() {
4389 return Err(RedDBError::Query(format!(
4390 "column '{field}' must be a finite number"
4391 )));
4392 }
4393 if value.fract().abs() >= f64::EPSILON {
4394 return Err(RedDBError::Query(format!(
4395 "column '{field}' must be an integer (TTL metadata must be an integer)"
4396 )));
4397 }
4398 if value < 0.0 {
4399 return Err(RedDBError::Query(format!(
4400 "column '{field}' must be non-negative for TTL metadata"
4401 )));
4402 }
4403 if value > u64::MAX as f64 {
4404 return Err(RedDBError::Query(format!(
4405 "column '{field}' value is too large"
4406 )));
4407 }
4408 Ok(metadata_u64_to_value(value as u64))
4409 } else {
4410 Err(RedDBError::Query(format!(
4411 "column '{field}' expects a numeric value for TTL metadata"
4412 )))
4413 }
4414 }
4415 _ => Err(RedDBError::Query(format!(
4416 "column '{field}' expects a numeric value for TTL metadata"
4417 ))),
4418 }
4419}
4420
4421fn metadata_u64_to_value(value: u64) -> MetadataValue {
4422 if value <= i64::MAX as u64 {
4423 MetadataValue::Int(value as i64)
4424 } else {
4425 MetadataValue::Timestamp(value)
4426 }
4427}
4428
4429fn dotted_tail_already_set(value: &Value, tail: &str) -> bool {
4435 let json = match value {
4436 Value::Null => return false,
4437 Value::Json(bytes) | Value::Blob(bytes) => {
4438 match crate::json::from_slice::<crate::json::Value>(bytes) {
4439 Ok(v) => v,
4440 Err(_) => return false,
4441 }
4442 }
4443 Value::Text(s) => {
4444 let trimmed = s.trim_start();
4445 if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
4446 return false;
4447 }
4448 match crate::json::from_str::<crate::json::Value>(s) {
4449 Ok(v) => v,
4450 Err(_) => return false,
4451 }
4452 }
4453 _ => return false,
4454 };
4455 let mut cursor = &json;
4456 for seg in tail.split('.') {
4457 match cursor {
4458 crate::json::Value::Object(map) => match map.iter().find(|(k, _)| *k == seg) {
4459 Some((_, v)) => cursor = v,
4460 None => return false,
4461 },
4462 _ => return false,
4463 }
4464 }
4465 !matches!(cursor, crate::json::Value::Null)
4466}
4467
4468fn merge_dotted_tenant(current: Value, tail: &str, tenant_id: &str) -> RedDBResult<Value> {
4479 let mut root = match current {
4480 Value::Null => crate::json::Value::Object(Default::default()),
4481 Value::Json(bytes) | Value::Blob(bytes) => {
4482 crate::json::from_slice(&bytes).map_err(|err| {
4483 RedDBError::Query(format!(
4484 "tenant auto-fill: root column is not valid JSON ({err})"
4485 ))
4486 })?
4487 }
4488 Value::Text(s) => {
4489 if s.trim().is_empty() {
4490 crate::json::Value::Object(Default::default())
4491 } else {
4492 crate::json::from_str::<crate::json::Value>(&s).map_err(|err| {
4493 RedDBError::Query(format!(
4494 "tenant auto-fill: text root is not valid JSON ({err})"
4495 ))
4496 })?
4497 }
4498 }
4499 other => {
4500 return Err(RedDBError::Query(format!(
4501 "tenant auto-fill: root column must be JSON / NULL, got {other:?}"
4502 )));
4503 }
4504 };
4505
4506 let segments: Vec<&str> = tail.split('.').collect();
4508 let mut cursor: &mut crate::json::Value = &mut root;
4509 for (i, seg) in segments.iter().enumerate() {
4510 let is_last = i + 1 == segments.len();
4511 let map = match cursor {
4512 crate::json::Value::Object(m) => m,
4513 _ => {
4514 return Err(RedDBError::Query(format!(
4515 "tenant auto-fill: segment '{seg}' is not inside an object"
4516 )));
4517 }
4518 };
4519 if is_last {
4520 map.insert(
4521 seg.to_string(),
4522 crate::json::Value::String(tenant_id.to_string()),
4523 );
4524 break;
4525 }
4526 cursor = map
4527 .entry(seg.to_string())
4528 .or_insert_with(|| crate::json::Value::Object(Default::default()));
4529 }
4530
4531 let bytes = crate::json::to_vec(&root).map_err(|err| {
4532 RedDBError::Query(format!(
4533 "tenant auto-fill: failed to re-serialize JSON ({err})"
4534 ))
4535 })?;
4536 Ok(Value::Json(bytes))
4537}
4538
4539#[cfg(test)]
4540mod tests {
4541 use crate::storage::schema::Value;
4542 use crate::storage::wal::{WalReader, WalRecord};
4543 use crate::storage::{DeployProfile, StoragePackaging, StorageProfileSelection};
4544 use crate::{RedDBOptions, RedDBRuntime};
4545 use std::path::Path;
4546
4547 fn persistent_operational_options(path: &Path) -> RedDBOptions {
4548 RedDBOptions::persistent(path)
4549 .with_storage_profile(StorageProfileSelection {
4550 deploy_profile: DeployProfile::Embedded,
4551 packaging: StoragePackaging::OperationalDirectory,
4552 replica_count: 0,
4553 managed_backup: false,
4554 wal_retention: false,
4555 })
4556 .unwrap()
4557 }
4558
4559 fn store_commit_batches(wal_path: &Path) -> Vec<Vec<Vec<u8>>> {
4560 WalReader::open(wal_path)
4561 .expect("wal opens")
4562 .iter()
4563 .map(|record| record.expect("wal record decodes").1)
4564 .filter_map(|record| match record {
4565 WalRecord::TxCommitBatch { actions, .. } => Some(actions),
4566 _ => None,
4567 })
4568 .collect()
4569 }
4570
4571 fn action_contains_text(action: &[u8], needle: &str) -> bool {
4572 action
4573 .windows(needle.len())
4574 .any(|window| window == needle.as_bytes())
4575 }
4576
4577 fn claim_metric_count(
4578 snapshot: &crate::runtime::ClaimTelemetrySnapshot,
4579 metric: &str,
4580 collection: &str,
4581 model: &str,
4582 ) -> u64 {
4583 let rows = match metric {
4584 "attempts" => &snapshot.attempts,
4585 "successful" => &snapshot.successful,
4586 "misses" => &snapshot.misses,
4587 "skipped_locked" => &snapshot.skipped_locked,
4588 other => panic!("unknown claim metric {other}"),
4589 };
4590 rows.iter()
4591 .find(|((actual_collection, actual_model), _)| {
4592 actual_collection == collection && actual_model == model
4593 })
4594 .map(|(_, count)| *count)
4595 .unwrap_or(0)
4596 }
4597
4598 fn assert_statement_writes_collections_in_one_new_wal_batch(
4599 rt: &RedDBRuntime,
4600 wal_path: &Path,
4601 statement: &str,
4602 source: &str,
4603 event_queue: &str,
4604 ) {
4605 let before_batches = store_commit_batches(wal_path).len();
4606
4607 rt.execute_query(statement).unwrap();
4608
4609 let batches = store_commit_batches(wal_path);
4610 let statement_batches = &batches[before_batches..];
4611 let source_batch = statement_batches
4612 .iter()
4613 .position(|actions| {
4614 actions.iter().any(|action| {
4615 action_contains_text(action, source)
4616 && !action_contains_text(action, event_queue)
4617 })
4618 })
4619 .expect("source collection write batch is present");
4620 let event_batch = statement_batches
4621 .iter()
4622 .position(|actions| {
4623 actions
4624 .iter()
4625 .any(|action| action_contains_text(action, event_queue))
4626 })
4627 .expect("event queue write batch is present");
4628
4629 assert_eq!(
4630 source_batch, event_batch,
4631 "WITH EVENTS must persist the source write and queue event in the same WAL batch"
4632 );
4633 }
4634
4635 #[test]
4636 fn with_events_autocommit_persists_mutation_and_event_in_one_wal_batch() {
4637 let dir = tempfile::tempdir().unwrap();
4638 let db_path = dir.path().join("events_dual_write.rdb");
4639 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
4640 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
4641
4642 rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
4643 .unwrap();
4644 assert_statement_writes_collections_in_one_new_wal_batch(
4645 &rt,
4646 &wal_path,
4647 "INSERT INTO users (id, email) VALUES (1, 'a@example.test')",
4648 "users",
4649 "users_events",
4650 );
4651 }
4652
4653 #[test]
4654 fn with_events_autocommit_update_persists_mutation_and_event_in_one_wal_batch() {
4655 let dir = tempfile::tempdir().unwrap();
4656 let db_path = dir.path().join("events_update_atomic.rdb");
4657 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
4658 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
4659
4660 rt.execute_query(
4661 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (UPDATE) TO user_updates",
4662 )
4663 .unwrap();
4664 rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
4665 .unwrap();
4666
4667 assert_statement_writes_collections_in_one_new_wal_batch(
4668 &rt,
4669 &wal_path,
4670 "UPDATE users SET email = 'b@example.test' WHERE id = 1",
4671 "users",
4672 "user_updates",
4673 );
4674 }
4675
4676 #[test]
4677 fn with_events_autocommit_delete_persists_mutation_and_event_in_one_wal_batch() {
4678 let dir = tempfile::tempdir().unwrap();
4679 let db_path = dir.path().join("events_delete_atomic.rdb");
4680 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
4681 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
4682
4683 rt.execute_query(
4684 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (DELETE) TO user_deletes",
4685 )
4686 .unwrap();
4687 rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
4688 .unwrap();
4689
4690 assert_statement_writes_collections_in_one_new_wal_batch(
4691 &rt,
4692 &wal_path,
4693 "DELETE FROM users WHERE id = 1",
4694 "users",
4695 "user_deletes",
4696 );
4697 }
4698
4699 #[test]
4700 fn update_where_id_in_with_hash_index_updates_expected_rows() {
4701 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4702 rt.execute_query("CREATE TABLE users (id INT, score INT)")
4703 .unwrap();
4704 for id in 0..5 {
4705 rt.execute_query(&format!("INSERT INTO users (id, score) VALUES ({id}, 0)"))
4706 .unwrap();
4707 }
4708 rt.execute_query("CREATE INDEX idx_id ON users (id) USING HASH")
4709 .unwrap();
4710
4711 let updated = rt
4712 .execute_query("UPDATE users SET score = 42 WHERE id IN (1,3,4)")
4713 .unwrap();
4714 assert_eq!(updated.affected_rows, 3);
4715
4716 let selected = rt
4717 .execute_query("SELECT id, score FROM users ORDER BY id")
4718 .unwrap();
4719 let scores: Vec<(i64, i64)> = selected
4720 .result
4721 .records
4722 .iter()
4723 .map(|record| {
4724 let id = match record.get("id").unwrap() {
4725 Value::Integer(value) => *value,
4726 other => panic!("expected integer id, got {other:?}"),
4727 };
4728 let score = match record.get("score").unwrap() {
4729 Value::Integer(value) => *value,
4730 other => panic!("expected integer score, got {other:?}"),
4731 };
4732 (id, score)
4733 })
4734 .collect();
4735 assert_eq!(scores, vec![(0, 0), (1, 42), (2, 0), (3, 42), (4, 42)]);
4736 }
4737
4738 #[test]
4745 fn update_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
4746 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4747 rt.execute_query("CREATE TABLE items (id INT, score INT)")
4748 .unwrap();
4749 for id in 0..5 {
4750 rt.execute_query(&format!(
4751 "INSERT INTO items (id, score) VALUES ({id}, {})",
4752 id * 10
4753 ))
4754 .unwrap();
4755 }
4756 rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
4757 .unwrap();
4758
4759 let updated_one = rt
4763 .execute_query("UPDATE items SET score = 5 WHERE id = 2")
4764 .unwrap();
4765 assert_eq!(updated_one.affected_rows, 1);
4766
4767 let updated_many = rt
4771 .execute_query("UPDATE items SET score = 7 WHERE score > 25")
4772 .unwrap();
4773 assert_eq!(updated_many.affected_rows, 2);
4774
4775 let snapshot = rt
4776 .execute_query("SELECT id, score FROM items ORDER BY id")
4777 .unwrap();
4778 let pairs: Vec<(i64, i64)> = snapshot
4779 .result
4780 .records
4781 .iter()
4782 .map(|record| {
4783 let id = match record.get("id").unwrap() {
4784 Value::Integer(value) => *value,
4785 other => panic!("expected integer id, got {other:?}"),
4786 };
4787 let score = match record.get("score").unwrap() {
4788 Value::Integer(value) => *value,
4789 other => panic!("expected integer score, got {other:?}"),
4790 };
4791 (id, score)
4792 })
4793 .collect();
4794 assert_eq!(pairs, vec![(0, 0), (1, 10), (2, 5), (3, 7), (4, 7)]);
4795
4796 let updated_all = rt.execute_query("UPDATE items SET score = 1").unwrap();
4798 assert_eq!(updated_all.affected_rows, 5);
4799 let after = rt
4800 .execute_query("SELECT score FROM items ORDER BY id")
4801 .unwrap();
4802 let scores: Vec<i64> = after
4803 .result
4804 .records
4805 .iter()
4806 .map(|record| match record.get("score").unwrap() {
4807 Value::Integer(value) => *value,
4808 other => panic!("expected integer score, got {other:?}"),
4809 })
4810 .collect();
4811 assert_eq!(scores, vec![1, 1, 1, 1, 1]);
4812 }
4813
4814 #[test]
4820 fn delete_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
4821 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4822 rt.execute_query("CREATE TABLE items (id INT, score INT)")
4823 .unwrap();
4824 for id in 0..5 {
4825 rt.execute_query(&format!(
4826 "INSERT INTO items (id, score) VALUES ({id}, {})",
4827 id * 10
4828 ))
4829 .unwrap();
4830 }
4831 rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
4832 .unwrap();
4833
4834 let deleted_one = rt.execute_query("DELETE FROM items WHERE id = 2").unwrap();
4837 assert_eq!(deleted_one.affected_rows, 1);
4838
4839 let deleted_many = rt
4843 .execute_query("DELETE FROM items WHERE score > 25")
4844 .unwrap();
4845 assert_eq!(deleted_many.affected_rows, 2);
4846
4847 let surviving = rt
4848 .execute_query("SELECT id FROM items ORDER BY id")
4849 .unwrap();
4850 let ids: Vec<i64> = surviving
4851 .result
4852 .records
4853 .iter()
4854 .map(|record| match record.get("id").unwrap() {
4855 Value::Integer(value) => *value,
4856 other => panic!("expected integer id, got {other:?}"),
4857 })
4858 .collect();
4859 assert_eq!(ids, vec![0, 1]);
4860
4861 let deleted_rest = rt.execute_query("DELETE FROM items").unwrap();
4863 assert_eq!(deleted_rest.affected_rows, 2);
4864 let empty = rt.execute_query("SELECT id FROM items").unwrap();
4865 assert!(empty.result.records.is_empty());
4866 }
4867
4868 #[test]
4873 fn collection_contract_gate_blocks_update_and_delete_on_append_only() {
4874 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4875 rt.execute_query("CREATE TABLE events (id INT, payload TEXT) APPEND ONLY")
4876 .unwrap();
4877
4878 let inserted = rt
4881 .execute_query("INSERT INTO events (id, payload) VALUES (1, 'hello')")
4882 .unwrap();
4883 assert_eq!(inserted.affected_rows, 1);
4884
4885 let update_err = rt
4887 .execute_query("UPDATE events SET payload = 'mut' WHERE id = 1")
4888 .unwrap_err();
4889 let msg = format!("{update_err}");
4890 assert!(
4891 msg.contains("APPEND ONLY") && msg.contains("UPDATE is rejected"),
4892 "expected UPDATE rejection message, got: {msg}"
4893 );
4894
4895 let delete_err = rt
4897 .execute_query("DELETE FROM events WHERE id = 1")
4898 .unwrap_err();
4899 let msg = format!("{delete_err}");
4900 assert!(
4901 msg.contains("APPEND ONLY") && msg.contains("DELETE is rejected"),
4902 "expected DELETE rejection message, got: {msg}"
4903 );
4904
4905 let surviving = rt.execute_query("SELECT id FROM events").unwrap();
4908 assert_eq!(surviving.result.records.len(), 1);
4909 }
4910
4911 #[test]
4915 fn collection_contract_gate_allows_all_verbs_on_unrestricted_table() {
4916 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4917 rt.execute_query("CREATE TABLE notes (id INT, body TEXT)")
4918 .unwrap();
4919
4920 rt.execute_query("INSERT INTO notes (id, body) VALUES (1, 'a')")
4921 .unwrap();
4922 let updated = rt
4923 .execute_query("UPDATE notes SET body = 'b' WHERE id = 1")
4924 .unwrap();
4925 assert_eq!(updated.affected_rows, 1);
4926 let deleted = rt.execute_query("DELETE FROM notes WHERE id = 1").unwrap();
4927 assert_eq!(deleted.affected_rows, 1);
4928 }
4929
4930 #[test]
4931 fn insert_into_event_enabled_table_emits_event_to_configured_queue() {
4932 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4933 rt.execute_query(
4934 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (INSERT) TO audit_log",
4935 )
4936 .unwrap();
4937
4938 let inserted = rt
4939 .execute_query("INSERT INTO users (id, email) VALUES (7, 'a@example.com')")
4940 .unwrap();
4941 assert_eq!(inserted.affected_rows, 1);
4942
4943 let events = queue_payloads(&rt, "audit_log");
4944 assert_eq!(events.len(), 1);
4945 let event = events[0].as_object().expect("event payload object");
4946 assert!(event
4947 .get("event_id")
4948 .and_then(crate::json::Value::as_str)
4949 .is_some_and(|value| !value.is_empty()));
4950 assert_eq!(
4951 event.get("op").and_then(crate::json::Value::as_str),
4952 Some("insert")
4953 );
4954 assert_eq!(
4955 event.get("collection").and_then(crate::json::Value::as_str),
4956 Some("users")
4957 );
4958 assert_eq!(
4959 event.get("id").and_then(crate::json::Value::as_u64),
4960 Some(7)
4961 );
4962 assert!(event
4963 .get("ts")
4964 .and_then(crate::json::Value::as_u64)
4965 .is_some());
4966 assert!(event
4967 .get("lsn")
4968 .and_then(crate::json::Value::as_u64)
4969 .is_some());
4970 assert!(matches!(
4971 event.get("tenant"),
4972 Some(crate::json::Value::Null)
4973 ));
4974 assert!(matches!(
4975 event.get("before"),
4976 Some(crate::json::Value::Null)
4977 ));
4978 let after = event
4979 .get("after")
4980 .and_then(crate::json::Value::as_object)
4981 .expect("after object");
4982 assert_eq!(
4983 after.get("id").and_then(crate::json::Value::as_u64),
4984 Some(7)
4985 );
4986 assert_eq!(
4987 after.get("email").and_then(crate::json::Value::as_str),
4988 Some("a@example.com")
4989 );
4990 }
4991
4992 #[test]
4993 fn multi_row_insert_emits_one_insert_event_per_row_in_order() {
4994 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4995 rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
4996 .unwrap();
4997
4998 rt.execute_query(
4999 "INSERT INTO users (id, email) VALUES (1, 'a@example.com'), (2, 'b@example.com')",
5000 )
5001 .unwrap();
5002
5003 let events = queue_payloads(&rt, "users_events");
5004 assert_eq!(events.len(), 2);
5005 let mut previous_lsn = 0;
5006 for (event, expected_id) in events.iter().zip([1_u64, 2]) {
5007 let object = event.as_object().expect("event payload object");
5008 assert_eq!(
5009 object.get("op").and_then(crate::json::Value::as_str),
5010 Some("insert")
5011 );
5012 assert_eq!(
5013 object.get("id").and_then(crate::json::Value::as_u64),
5014 Some(expected_id)
5015 );
5016 let lsn = object
5017 .get("lsn")
5018 .and_then(crate::json::Value::as_u64)
5019 .expect("event lsn");
5020 assert!(
5021 lsn > previous_lsn,
5022 "event LSNs should increase in row order"
5023 );
5024 previous_lsn = lsn;
5025 let after = object
5026 .get("after")
5027 .and_then(crate::json::Value::as_object)
5028 .expect("after object");
5029 assert_eq!(
5030 after.get("id").and_then(crate::json::Value::as_u64),
5031 Some(expected_id)
5032 );
5033 }
5034 }
5035
5036 #[test]
5037 fn claim_metrics_increment_skipped_locked_without_counting_miss() {
5038 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
5039 rt.execute_query("CREATE TABLE claim_metric_locked (id INT, rank INT, status TEXT)")
5040 .expect("create table");
5041 rt.execute_query("CREATE INDEX idx_claim_metric_locked_rank ON claim_metric_locked (rank)")
5043 .expect("create index");
5044 rt.execute_query(
5045 "INSERT INTO claim_metric_locked (id, rank, status) VALUES \
5046 (1, 10, 'ready'), (2, 20, 'ready')",
5047 )
5048 .expect("insert rows");
5049
5050 let claim_lock = rt
5051 .inner
5052 .rmw_locks
5053 .lock_for("claim_metric_locked", "__table_claim_update__");
5054 let _guard = claim_lock.lock();
5055 let updated = rt
5056 .execute_query(
5057 "UPDATE claim_metric_locked SET status = 'claimed' WHERE status = 'ready' \
5058 CLAIM LIMIT 2 ORDER BY rank ASC",
5059 )
5060 .expect("claim skips locked candidates");
5061
5062 assert_eq!(updated.affected_rows, 0);
5063 let snapshot = rt.claim_telemetry_snapshot();
5064 assert_eq!(
5065 claim_metric_count(&snapshot, "attempts", "claim_metric_locked", "table"),
5066 1
5067 );
5068 assert_eq!(
5069 claim_metric_count(&snapshot, "skipped_locked", "claim_metric_locked", "table"),
5070 2
5071 );
5072 assert_eq!(
5073 claim_metric_count(&snapshot, "misses", "claim_metric_locked", "table"),
5074 0
5075 );
5076 assert_eq!(
5077 snapshot.skipped_locked.len(),
5078 1,
5079 "skipped-lock labels stay bounded to collection/model"
5080 );
5081 }
5082
5083 fn queue_payloads(rt: &RedDBRuntime, queue: &str) -> Vec<crate::json::Value> {
5084 let result = rt
5085 .execute_query(&format!("QUEUE PEEK {queue} 10"))
5086 .expect("peek queue");
5087 result
5088 .result
5089 .records
5090 .iter()
5091 .map(
5092 |record| match record.get("payload").expect("payload column") {
5093 Value::Json(bytes) => crate::json::from_slice(bytes).expect("json payload"),
5094 other => panic!("expected JSON queue payload, got {other:?}"),
5095 },
5096 )
5097 .collect()
5098 }
5099
5100 #[test]
5112 fn auto_index_id_fires_on_first_insert() {
5113 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5114 rt.execute_query("CREATE TABLE bench_users (id INT, score INT)")
5115 .unwrap();
5116
5117 assert!(
5119 rt.index_store_ref()
5120 .find_index_for_column("bench_users", "id")
5121 .is_none(),
5122 "freshly created collection should not have an `id` index"
5123 );
5124
5125 rt.execute_query("INSERT INTO bench_users (id, score) VALUES (1, 10)")
5127 .unwrap();
5128
5129 let registered = rt
5131 .index_store_ref()
5132 .find_index_for_column("bench_users", "id")
5133 .expect("auto-index hook should have registered idx_id on first insert");
5134 assert_eq!(registered.name, "idx_id");
5135 assert_eq!(registered.collection, "bench_users");
5136 assert_eq!(registered.columns, vec!["id".to_string()]);
5137 assert!(matches!(
5138 registered.method,
5139 super::super::index_store::IndexMethodKind::Hash
5140 ));
5141
5142 for id in 2..=5 {
5145 rt.execute_query(&format!(
5146 "INSERT INTO bench_users (id, score) VALUES ({id}, {})",
5147 id * 10
5148 ))
5149 .unwrap();
5150 }
5151 for id in 1..=5 {
5152 let result = rt
5153 .execute_query(&format!("SELECT score FROM bench_users WHERE id = {id}"))
5154 .unwrap();
5155 assert_eq!(
5156 result.result.records.len(),
5157 1,
5158 "id={id} should match one row"
5159 );
5160 }
5161
5162 let deleted = rt
5167 .execute_query("DELETE FROM bench_users WHERE id = 3")
5168 .unwrap();
5169 assert_eq!(deleted.affected_rows, 1);
5170 }
5171
5172 #[test]
5177 fn auto_index_id_fires_on_first_bulk_insert() {
5178 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5179 rt.execute_query("CREATE TABLE bench_bulk (id INT, score INT)")
5180 .unwrap();
5181
5182 rt.execute_query("INSERT INTO bench_bulk (id, score) VALUES (1, 10), (2, 20), (3, 30)")
5183 .unwrap();
5184
5185 let registered = rt
5186 .index_store_ref()
5187 .find_index_for_column("bench_bulk", "id")
5188 .expect("auto-index hook should fire on first bulk insert");
5189 assert_eq!(registered.name, "idx_id");
5190
5191 for id in 1..=3 {
5193 let result = rt
5194 .execute_query(&format!("SELECT score FROM bench_bulk WHERE id = {id}"))
5195 .unwrap();
5196 assert_eq!(result.result.records.len(), 1);
5197 }
5198 }
5199
5200 #[test]
5204 fn auto_index_id_skips_when_no_id_column() {
5205 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5206 rt.execute_query("CREATE TABLE plain (uid INT, label TEXT)")
5207 .unwrap();
5208 rt.execute_query("INSERT INTO plain (uid, label) VALUES (1, 'a')")
5209 .unwrap();
5210
5211 assert!(rt
5212 .index_store_ref()
5213 .find_index_for_column("plain", "id")
5214 .is_none());
5215 assert!(rt
5216 .index_store_ref()
5217 .find_index_for_column("plain", "uid")
5218 .is_none());
5219 }
5220
5221 #[test]
5226 fn auto_index_id_skips_when_index_already_exists() {
5227 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5228 rt.execute_query("CREATE TABLE pre (id INT, score INT)")
5229 .unwrap();
5230 rt.execute_query("CREATE INDEX user_idx ON pre (id) USING BTREE")
5232 .unwrap();
5233 rt.execute_query("INSERT INTO pre (id, score) VALUES (1, 10)")
5234 .unwrap();
5235
5236 let registered = rt
5237 .index_store_ref()
5238 .find_index_for_column("pre", "id")
5239 .expect("user index should still be there");
5240 assert_eq!(
5241 registered.name, "user_idx",
5242 "auto-index hook must not overwrite an existing index"
5243 );
5244 }
5245
5246 #[test]
5250 fn auto_index_id_dropped_with_collection() {
5251 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5252 rt.execute_query("CREATE TABLE ephemeral (id INT, score INT)")
5253 .unwrap();
5254 rt.execute_query("INSERT INTO ephemeral (id, score) VALUES (1, 10)")
5255 .unwrap();
5256 assert!(rt
5257 .index_store_ref()
5258 .find_index_for_column("ephemeral", "id")
5259 .is_some());
5260
5261 rt.execute_query("DROP TABLE ephemeral").unwrap();
5262
5263 assert!(
5264 rt.index_store_ref()
5265 .find_index_for_column("ephemeral", "id")
5266 .is_none(),
5267 "implicit `idx_id` must be reaped when its collection drops"
5268 );
5269 }
5270
5271 #[test]
5276 fn auto_index_id_disabled_by_config() {
5277 let opts = RedDBOptions::in_memory().with_auto_index_id(false);
5278 let rt = RedDBRuntime::with_options(opts).unwrap();
5279
5280 rt.execute_query("CREATE TABLE off (id INT, score INT)")
5281 .unwrap();
5282 rt.execute_query("INSERT INTO off (id, score) VALUES (1, 10)")
5283 .unwrap();
5284
5285 assert!(
5286 rt.index_store_ref()
5287 .find_index_for_column("off", "id")
5288 .is_none(),
5289 "with auto_index_id=false, no implicit index should be created"
5290 );
5291 }
5292
5293 #[test]
5296 fn update_single_row_emits_update_event() {
5297 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5298 rt.execute_query(
5299 "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO audit_log",
5300 )
5301 .unwrap();
5302 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
5303 .unwrap();
5304
5305 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
5306 .unwrap();
5307
5308 let events = queue_payloads(&rt, "audit_log");
5309 assert_eq!(events.len(), 1, "expected exactly 1 update event");
5310 let event = events[0].as_object().expect("event payload object");
5311 assert_eq!(
5312 event.get("op").and_then(crate::json::Value::as_str),
5313 Some("update")
5314 );
5315 assert_eq!(
5316 event.get("collection").and_then(crate::json::Value::as_str),
5317 Some("users")
5318 );
5319 assert!(event
5320 .get("event_id")
5321 .and_then(crate::json::Value::as_str)
5322 .is_some_and(|v| !v.is_empty()));
5323 let before = event
5324 .get("before")
5325 .and_then(crate::json::Value::as_object)
5326 .expect("before must be an object");
5327 let after = event
5328 .get("after")
5329 .and_then(crate::json::Value::as_object)
5330 .expect("after must be an object");
5331 assert_eq!(
5332 before.get("name").and_then(crate::json::Value::as_str),
5333 Some("Alice"),
5334 "before.name should be the old value"
5335 );
5336 assert_eq!(
5337 after.get("name").and_then(crate::json::Value::as_str),
5338 Some("Bob"),
5339 "after.name should be the new value"
5340 );
5341 }
5342
5343 #[test]
5344 fn update_event_only_includes_changed_fields() {
5345 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5346 rt.execute_query(
5347 "CREATE TABLE users (id INT, name TEXT, email TEXT) WITH EVENTS (UPDATE) TO evts",
5348 )
5349 .unwrap();
5350 rt.execute_query("INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'a@x.com')")
5351 .unwrap();
5352
5353 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
5354 .unwrap();
5355
5356 let events = queue_payloads(&rt, "evts");
5357 assert_eq!(events.len(), 1);
5358 let event = events[0].as_object().unwrap();
5359 let before = event
5360 .get("before")
5361 .and_then(crate::json::Value::as_object)
5362 .unwrap();
5363 let after = event
5364 .get("after")
5365 .and_then(crate::json::Value::as_object)
5366 .unwrap();
5367 assert!(
5369 before.contains_key("name"),
5370 "before must include changed field"
5371 );
5372 assert!(
5373 after.contains_key("name"),
5374 "after must include changed field"
5375 );
5376 assert!(
5378 !before.contains_key("email"),
5379 "before must not include unchanged email"
5380 );
5381 assert!(
5382 !after.contains_key("email"),
5383 "after must not include unchanged email"
5384 );
5385 }
5386
5387 #[test]
5388 fn multi_row_update_emits_one_event_per_row() {
5389 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5390 rt.execute_query("CREATE TABLE items (id INT, status TEXT) WITH EVENTS (UPDATE) TO evts")
5391 .unwrap();
5392 rt.execute_query(
5393 "INSERT INTO items (id, status) VALUES (1, 'new'), (2, 'new'), (3, 'new')",
5394 )
5395 .unwrap();
5396
5397 rt.execute_query("UPDATE items SET status = 'done'")
5398 .unwrap();
5399
5400 let events = queue_payloads(&rt, "evts");
5401 assert_eq!(events.len(), 3, "expected one update event per row");
5402 for event in &events {
5403 let obj = event.as_object().unwrap();
5404 assert_eq!(
5405 obj.get("op").and_then(crate::json::Value::as_str),
5406 Some("update")
5407 );
5408 }
5409 }
5410
5411 #[test]
5412 fn delete_single_row_emits_delete_event() {
5413 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5414 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (DELETE) TO del_log")
5415 .unwrap();
5416 rt.execute_query("INSERT INTO users (id, name) VALUES (42, 'Alice')")
5417 .unwrap();
5418
5419 rt.execute_query("DELETE FROM users WHERE id = 42").unwrap();
5420
5421 let events = queue_payloads(&rt, "del_log");
5422 assert_eq!(events.len(), 1);
5423 let event = events[0].as_object().expect("event payload object");
5424 assert_eq!(
5425 event.get("op").and_then(crate::json::Value::as_str),
5426 Some("delete")
5427 );
5428 assert_eq!(
5429 event.get("collection").and_then(crate::json::Value::as_str),
5430 Some("users")
5431 );
5432 assert!(event
5433 .get("event_id")
5434 .and_then(crate::json::Value::as_str)
5435 .is_some_and(|v| !v.is_empty()));
5436 let before = event
5437 .get("before")
5438 .and_then(crate::json::Value::as_object)
5439 .expect("before must be an object for delete");
5440 assert_eq!(
5441 before.get("id").and_then(crate::json::Value::as_u64),
5442 Some(42)
5443 );
5444 assert_eq!(
5445 before.get("name").and_then(crate::json::Value::as_str),
5446 Some("Alice")
5447 );
5448 assert!(matches!(event.get("after"), Some(crate::json::Value::Null)));
5449 }
5450
5451 #[test]
5452 fn multi_row_delete_emits_one_event_per_row() {
5453 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5454 rt.execute_query("CREATE TABLE items (id INT, val INT) WITH EVENTS (DELETE) TO del_log")
5455 .unwrap();
5456 rt.execute_query("INSERT INTO items (id, val) VALUES (1, 10), (2, 20), (3, 30)")
5457 .unwrap();
5458
5459 rt.execute_query("DELETE FROM items").unwrap();
5460
5461 let events = queue_payloads(&rt, "del_log");
5462 assert_eq!(events.len(), 3, "expected one delete event per deleted row");
5463 for event in &events {
5464 let obj = event.as_object().unwrap();
5465 assert_eq!(
5466 obj.get("op").and_then(crate::json::Value::as_str),
5467 Some("delete")
5468 );
5469 assert!(matches!(obj.get("after"), Some(crate::json::Value::Null)));
5470 }
5471 }
5472
5473 #[test]
5474 fn ops_filter_update_does_not_emit_on_insert_or_delete() {
5475 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5476 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO evts")
5477 .unwrap();
5478
5479 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
5480 .unwrap();
5481 rt.execute_query("DELETE FROM users WHERE id = 1").unwrap();
5482
5483 let events = queue_payloads(&rt, "evts");
5484 assert!(
5485 events.is_empty(),
5486 "UPDATE-only filter must not emit INSERT or DELETE events"
5487 );
5488 }
5489
5490 #[test]
5493 fn suppress_events_on_insert_emits_no_events() {
5494 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5495 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
5496 .unwrap();
5497
5498 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
5499 .unwrap();
5500
5501 let events = queue_payloads(&rt, "evts");
5502 assert!(
5503 events.is_empty(),
5504 "SUPPRESS EVENTS must prevent INSERT events"
5505 );
5506 }
5507
5508 #[test]
5509 fn suppress_events_on_update_emits_no_events() {
5510 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5511 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
5512 .unwrap();
5513 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
5514 .unwrap();
5515 let _ = queue_payloads(&rt, "evts");
5517 rt.execute_query("QUEUE PURGE evts").unwrap();
5519
5520 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1 SUPPRESS EVENTS")
5521 .unwrap();
5522
5523 let events = queue_payloads(&rt, "evts");
5524 assert!(
5525 events.is_empty(),
5526 "SUPPRESS EVENTS must prevent UPDATE events"
5527 );
5528 }
5529
5530 #[test]
5531 fn suppress_events_on_delete_emits_no_events() {
5532 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5533 rt.execute_query(
5534 "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (INSERT, DELETE) TO evts",
5535 )
5536 .unwrap();
5537 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
5538 .unwrap();
5539
5540 rt.execute_query("DELETE FROM users WHERE id = 1 SUPPRESS EVENTS")
5541 .unwrap();
5542
5543 let events = queue_payloads(&rt, "evts");
5544 assert!(
5545 events.is_empty(),
5546 "SUPPRESS EVENTS must prevent DELETE events"
5547 );
5548 }
5549
5550 #[test]
5551 fn normal_insert_after_suppress_still_emits() {
5552 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5553 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
5554 .unwrap();
5555
5556 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
5557 .unwrap();
5558 rt.execute_query("INSERT INTO users (id, name) VALUES (2, 'Bob')")
5559 .unwrap();
5560
5561 let events = queue_payloads(&rt, "evts");
5562 assert_eq!(
5563 events.len(),
5564 1,
5565 "only the non-suppressed INSERT should emit"
5566 );
5567 assert_eq!(
5568 events[0].get("id").and_then(crate::json::Value::as_u64),
5569 Some(2)
5570 );
5571 }
5572}