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::*;
37use super::impl_dml_support::*;
41use super::impl_dml_claim::*;
45use super::impl_dml_returning::*;
46use super::impl_dml_update_analysis::*;
47use super::impl_dml_update_target::*;
48pub(super) use super::impl_dml_ttl::{canonicalize_sql_ttl_metadata, resolve_sql_ttl_metadata_key};
54
55const UPDATE_APPLY_CHUNK_SIZE: usize = 2048;
56pub(super) const TREE_CHILD_EDGE_LABEL: &str = "TREE_CHILD";
57pub(super) const TREE_METADATA_PREFIX: &str = "red.tree.";
58
59#[derive(Clone)]
60pub(super) struct CompiledUpdateAssignment {
61 column: String,
62 expr: Expr,
63 compound_op: Option<BinOp>,
64 metadata_key: Option<&'static str>,
65 row_rule: Option<RowUpdateColumnRule>,
66}
67
68pub(super) struct CompiledUpdatePlan {
69 pub(super) static_field_assignments: Vec<(String, Value)>,
70 pub(super) static_metadata_assignments: Vec<(String, MetadataValue)>,
71 dynamic_assignments: Vec<CompiledUpdateAssignment>,
72 row_contract_plan: Option<RowUpdateContractPlan>,
73 row_modified_columns: Vec<String>,
74 row_touches_unique_columns: bool,
75}
76
77#[derive(Default)]
78pub(super) struct MaterializedUpdateAssignments {
79 pub(super) dynamic_field_assignments: Vec<(String, Value)>,
80 pub(super) dynamic_metadata_assignments: Vec<(String, MetadataValue)>,
81}
82
83impl RedDBRuntime {
84 fn infer_unmarked_document_insert(
103 &self,
104 query: &InsertQuery,
105 ) -> RedDBResult<Option<InsertQuery>> {
106 if !matches!(query.entity_type, InsertEntityType::Row) || !query.columns.is_empty() {
107 return Ok(None);
108 }
109 match self
110 .db()
111 .collection_contract_arc(&query.table)
112 .map(|contract| contract.declared_model)
113 {
114 Some(crate::catalog::CollectionModel::Document) => {
115 let mut rewritten = query.clone();
116 rewritten.entity_type = InsertEntityType::Document;
117 rewritten.columns = vec!["body".to_string()];
118 Ok(Some(rewritten))
119 }
120 Some(other) => Err(RedDBError::InvalidOperation(format!(
121 "collection '{table}' is declared as '{model}'; the bare \
122 `INSERT INTO {table} VALUES (…)` form is the document body shorthand — \
123 write an explicit column list (`INSERT INTO {table} (col, …) VALUES (…)`) \
124 to insert into a {model} collection",
125 table = query.table,
126 model = crate::runtime::ddl::polymorphic_resolver::model_name(other),
127 ))),
128 None => Err(RedDBError::InvalidOperation(format!(
129 "collection '{table}' does not exist and the INSERT carries no model marker; \
130 write `INSERT INTO {table} DOCUMENT VALUES ({{…}})` to create it as a document \
131 (idempotent), or run `CREATE DOCUMENT {table}` first",
132 table = query.table,
133 ))),
134 }
135 }
136
137 fn resolve_unmarked_update_target(
150 &self,
151 query: &UpdateQuery,
152 ) -> RedDBResult<Option<UpdateQuery>> {
153 if matches!(query.target, UpdateTarget::Nodes | UpdateTarget::Edges) {
156 ensure_update_dotted_targets_allowed(query, false)?;
157 return Ok(None);
158 }
159 let declared_model = self
160 .db()
161 .collection_contract_arc(&query.table)
162 .map(|contract| contract.declared_model);
163 let is_document = matches!(
164 declared_model,
165 Some(crate::catalog::CollectionModel::Document)
166 );
167 ensure_update_dotted_targets_allowed(query, is_document)?;
168 let inferred = match declared_model {
169 Some(crate::catalog::CollectionModel::Document) => UpdateTarget::Documents,
170 Some(crate::catalog::CollectionModel::Kv) => UpdateTarget::Kv,
171 _ => return Ok(None),
172 };
173 let mut rewritten = query.clone();
174 rewritten.target = inferred;
175 Ok(Some(rewritten))
176 }
177
178 pub fn execute_insert(
183 &self,
184 raw_query: &str,
185 query: &InsertQuery,
186 ) -> RedDBResult<RuntimeQueryResult> {
187 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
188 crate::runtime::collection_contract::CollectionContractGate::check(
194 self,
195 &query.table,
196 crate::runtime::collection_contract::MutationKind::Insert,
197 )?;
198 let inferred_owned;
207 let query = match self.infer_unmarked_document_insert(query)? {
208 Some(rewritten) => {
209 inferred_owned = rewritten;
210 &inferred_owned
211 }
212 None => query,
213 };
214 let augmented_owned;
223 let query = match self.maybe_inject_tenant_column(query)? {
224 Some(new_q) => {
225 augmented_owned = new_q;
226 &augmented_owned
227 }
228 None => query,
229 };
230 self.check_insert_column_policy(query)?;
231 if let Some(ref embed_config) = query.auto_embed {
232 let provider =
236 crate::ai::resolve_embeddings_provider_from_runtime(self, &embed_config.provider)?;
237 crate::runtime::ai::provider_gate::enforce(self, &provider)?;
241 if matches!(provider, crate::ai::AiProvider::Local) {
242 crate::runtime::ai::local_embedding::ensure_local_embedding_available()?;
243 let model_name = embed_config.model.as_deref().map(str::trim).unwrap_or("");
251 if model_name.is_empty() {
252 return Err(RedDBError::Query(
253 "AUTO EMBED with provider=local requires MODEL '<registered-model-name>'; \
254 the local provider does not have an implicit default model"
255 .to_string(),
256 ));
257 }
258 crate::runtime::ai::local_embedding::preflight_local_embedding(
259 &self.inner.db,
260 model_name,
261 )?;
262 }
263 }
264
265 let mut inserted_count: u64 = 0;
266 let effective_rows =
267 effective_insert_rows(query).map_err(|msg| RedDBError::Query(msg.to_string()))?;
268
269 let store = self.inner.db.store();
271 let _ = store.get_or_create_collection(&query.table);
272 let declared_model = self
273 .db()
274 .collection_contract_arc(&query.table)
275 .map(|contract| contract.declared_model);
276
277 let mut returning_snapshots: Option<Vec<Vec<(String, Value)>>> =
278 if query.returning.is_some() {
279 Some(Vec::with_capacity(effective_rows.len()))
280 } else {
281 None
282 };
283 let mut returning_result: Option<UnifiedResult> = None;
284
285 if matches!(query.entity_type, InsertEntityType::Row)
286 && !matches!(
287 declared_model,
288 Some(crate::catalog::CollectionModel::TimeSeries)
289 )
290 {
291 let chain_mode = crate::runtime::blockchain_kind::is_chain(&store, &query.table);
302 let _chain_lock_arc: Option<Arc<parking_lot::Mutex<()>>> = if chain_mode {
303 Some(self.inner.rmw_locks.lock_for(&query.table, "__chain__"))
304 } else {
305 None
306 };
307 let _chain_guard = _chain_lock_arc.as_ref().map(|m| m.lock());
308
309 if chain_mode && self.is_chain_integrity_broken(&query.table) {
312 return Err(RedDBError::InvalidOperation(format!(
313 "ChainIntegrityBroken: collection '{}' is locked until \
314 POST /collections/{}/clear-integrity-flag is called by an admin",
315 query.table, query.table
316 )));
317 }
318
319 let mut chain_tip_full: Option<crate::runtime::blockchain_kind::ChainTipFull> =
323 if chain_mode {
324 let mut cache = self.inner.chain_tip_cache.lock();
325 if let Some(existing) = cache.get(&query.table) {
326 Some(existing.clone())
327 } else if let Some(scanned) =
328 crate::runtime::blockchain_kind::chain_tip_full(&store, &query.table)
329 {
330 cache.insert(query.table.clone(), scanned.clone());
331 Some(scanned)
332 } else {
333 None
334 }
335 } else {
336 None
337 };
338
339 let mut rows = Vec::with_capacity(effective_rows.len());
340 for row_values in &effective_rows {
341 if row_values.len() != query.columns.len() {
342 return Err(RedDBError::Query(format!(
343 "INSERT column count ({}) does not match value count ({})",
344 query.columns.len(),
345 row_values.len()
346 )));
347 }
348 let (mut fields, mut metadata) =
349 split_insert_metadata(self, &query.columns, row_values)?;
350 if chain_mode {
351 use crate::runtime::blockchain_kind::{
352 chain_conflict_error, COL_BLOCK_HEIGHT, COL_HASH, COL_PREV_HASH,
353 COL_TIMESTAMP, RESERVED_COLUMNS,
354 };
355 let supplied_height = fields
356 .iter()
357 .find(|(k, _)| k == COL_BLOCK_HEIGHT)
358 .map(|(_, v)| v.clone());
359 let supplied_prev = fields
360 .iter()
361 .find(|(k, _)| k == COL_PREV_HASH)
362 .map(|(_, v)| v.clone());
363 let supplied_ts = fields
364 .iter()
365 .find(|(k, _)| k == COL_TIMESTAMP)
366 .map(|(_, v)| v.clone());
367 let supplied_hash = fields.iter().any(|(k, _)| k == COL_HASH);
368 let user_supplied_any = supplied_height.is_some()
369 || supplied_prev.is_some()
370 || supplied_ts.is_some()
371 || supplied_hash;
372
373 fields.retain(|(k, _)| !RESERVED_COLUMNS.contains(&k.as_str()));
374 let payload = crate::runtime::blockchain_kind::canonical_payload(&fields);
375
376 let (tip_prev_hash, tip_next_height) = match &chain_tip_full {
377 Some(t) => (t.hash, t.height + 1),
378 None => (crate::storage::blockchain::GENESIS_PREV_HASH, 0u64),
379 };
380 let server_now = crate::runtime::blockchain_kind::now_ms();
381
382 let (use_prev, use_height, use_ts) = if user_supplied_any {
383 if supplied_hash {
386 return Err(chain_conflict_error(
387 tip_next_height.saturating_sub(1),
388 tip_prev_hash,
389 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
390 server_now,
391 "hash column is engine-computed and cannot be supplied",
392 ));
393 }
394 let caller_prev = match &supplied_prev {
395 Some(Value::Blob(b)) if b.len() == 32 => {
396 let mut a = [0u8; 32];
397 a.copy_from_slice(b);
398 a
399 }
400 Some(Value::Text(s)) if s.len() == 64 => {
401 let mut a = [0u8; 32];
405 let mut ok = true;
406 for (i, slot) in a.iter_mut().enumerate() {
407 let pair = &s.as_ref()[i * 2..i * 2 + 2];
408 match u8::from_str_radix(pair, 16) {
409 Ok(byte) => *slot = byte,
410 Err(_) => {
411 ok = false;
412 break;
413 }
414 }
415 }
416 if !ok {
417 return Err(chain_conflict_error(
418 tip_next_height.saturating_sub(1),
419 tip_prev_hash,
420 chain_tip_full
421 .as_ref()
422 .map(|t| t.timestamp_ms)
423 .unwrap_or(0),
424 server_now,
425 "prev_hash is not valid hex",
426 ));
427 }
428 a
429 }
430 _ => {
431 return Err(chain_conflict_error(
432 tip_next_height.saturating_sub(1),
433 tip_prev_hash,
434 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
435 server_now,
436 "prev_hash missing or not a 32-byte Blob",
437 ));
438 }
439 };
440 if caller_prev != tip_prev_hash {
441 return Err(chain_conflict_error(
442 tip_next_height.saturating_sub(1),
443 tip_prev_hash,
444 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
445 server_now,
446 "prev_hash does not match current tip",
447 ));
448 }
449 let caller_height = match &supplied_height {
450 Some(Value::UnsignedInteger(v)) => *v,
451 Some(Value::Integer(v)) if *v >= 0 => *v as u64,
452 _ => {
453 return Err(chain_conflict_error(
454 tip_next_height.saturating_sub(1),
455 tip_prev_hash,
456 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
457 server_now,
458 "block_height missing or not an unsigned integer",
459 ));
460 }
461 };
462 if caller_height != tip_next_height {
463 return Err(chain_conflict_error(
464 tip_next_height.saturating_sub(1),
465 tip_prev_hash,
466 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
467 server_now,
468 "block_height does not match tip+1",
469 ));
470 }
471 let caller_ts = match &supplied_ts {
472 Some(Value::UnsignedInteger(v)) => *v,
473 Some(Value::Integer(v)) if *v >= 0 => *v as u64,
474 _ => {
475 return Err(chain_conflict_error(
476 tip_next_height.saturating_sub(1),
477 tip_prev_hash,
478 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
479 server_now,
480 "timestamp missing or not an unsigned integer",
481 ));
482 }
483 };
484 let drift = (caller_ts as i128) - (server_now as i128);
485 if drift.abs() > 60_000 {
486 return Err(chain_conflict_error(
487 tip_next_height.saturating_sub(1),
488 tip_prev_hash,
489 chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
490 server_now,
491 "timestamp outside ±60s of server_time",
492 ));
493 }
494 (caller_prev, caller_height, caller_ts)
495 } else {
496 (tip_prev_hash, tip_next_height, server_now)
497 };
498
499 let (reserved, new_hash) =
500 crate::runtime::blockchain_kind::make_block_reserved_fields(
501 use_prev, use_height, use_ts, &payload,
502 );
503 fields.extend(reserved);
504 chain_tip_full = Some(crate::runtime::blockchain_kind::ChainTipFull {
505 height: use_height,
506 hash: new_hash,
507 timestamp_ms: use_ts,
508 });
509 }
510 if crate::runtime::signed_writes_kind::is_signed(&store, &query.table) {
522 let (pk_col, sig_col, residual) =
523 crate::runtime::signed_writes_kind::split_signature_fields(fields);
524 let payload = crate::runtime::blockchain_kind::canonical_payload(&residual);
525 let reg = crate::runtime::signed_writes_kind::registry(&store, &query.table);
526 crate::runtime::signed_writes_kind::verify_row(
527 ®,
528 pk_col.as_ref().map(|c| c.bytes.as_slice()),
529 sig_col.as_ref().map(|c| c.bytes.as_slice()),
530 &payload,
531 )
532 .map_err(crate::runtime::signed_writes_kind::map_error)?;
533 fields = residual;
534 if let Some(col) = pk_col {
539 fields.push((
540 crate::storage::signed_writes::RESERVED_SIGNER_PUBKEY_COL.to_string(),
541 col.raw_value,
542 ));
543 }
544 if let Some(col) = sig_col {
545 fields.push((
546 crate::storage::signed_writes::RESERVED_SIGNATURE_COL.to_string(),
547 col.raw_value,
548 ));
549 }
550 }
551 merge_with_clauses(
552 &mut metadata,
553 query.ttl_ms,
554 query.expires_at_ms,
555 &query.with_metadata,
556 );
557 if let Some(snaps) = returning_snapshots.as_mut() {
558 snaps.push(fields.clone());
559 }
560 rows.push(CreateRowInput {
561 collection: query.table.clone(),
562 fields,
563 metadata,
564 node_links: Vec::new(),
565 vector_links: Vec::new(),
566 });
567 }
568 let outputs = self.create_rows_batch(CreateRowsBatchInput {
569 collection: query.table.clone(),
570 rows,
571 suppress_events: query.suppress_events,
572 })?;
573 inserted_count = outputs.len() as u64;
574
575 if chain_mode {
579 if let Some(new_tip) = chain_tip_full.as_ref() {
580 self.inner
581 .chain_tip_cache
582 .lock()
583 .insert(query.table.clone(), new_tip.clone());
584 }
585 }
586
587 if let Some(spec) = self.inner.db.hypertables().get(&query.table) {
595 let time_col = &spec.time_column;
596 if let Some(idx) = query.columns.iter().position(|c| c == time_col) {
598 for row in &effective_rows {
599 if let Some(Value::Integer(n) | Value::BigInt(n)) = row.get(idx) {
600 if *n >= 0 {
601 let _ = self.inner.db.hypertables().route(&query.table, *n as u64);
602 }
603 } else if let Some(Value::UnsignedInteger(n)) = row.get(idx) {
604 let _ = self.inner.db.hypertables().route(&query.table, *n);
605 }
606 }
607 }
608 }
609
610 if let (Some(items), Some(snaps)) =
611 (query.returning.as_ref(), returning_snapshots.take())
612 {
613 let snaps = row_insert_returning_snapshots(&outputs, snaps);
614 returning_result = Some(build_returning_result(items, &snaps, Some(&outputs)));
615 }
616 } else {
617 let mut entity_outputs: Vec<crate::application::entity::CreateEntityOutput> =
624 Vec::with_capacity(effective_rows.len());
625 let mut returning_field_snaps: Vec<Vec<(String, Value)>> = if query.returning.is_some()
626 {
627 Vec::with_capacity(effective_rows.len())
628 } else {
629 Vec::new()
630 };
631 if matches!(
632 query.entity_type,
633 InsertEntityType::Node | InsertEntityType::Edge
634 ) {
635 enum PreparedGraphInsert {
636 Node {
637 fields: Vec<(String, Value)>,
638 input: CreateNodeInput,
639 },
640 Edge {
641 fields: Vec<(String, Value)>,
642 input: CreateEdgeInput,
643 },
644 }
645
646 let mut prepared = Vec::with_capacity(effective_rows.len());
647 for row_values in &effective_rows {
648 if row_values.len() != query.columns.len() {
649 return Err(RedDBError::Query(format!(
650 "INSERT column count ({}) does not match value count ({})",
651 query.columns.len(),
652 row_values.len()
653 )));
654 }
655
656 match query.entity_type {
657 InsertEntityType::Node => {
658 let (node_values, mut metadata) =
659 split_insert_metadata(self, &query.columns, row_values)?;
660 merge_with_clauses(
661 &mut metadata,
662 query.ttl_ms,
663 query.expires_at_ms,
664 &query.with_metadata,
665 );
666 ensure_non_tree_reserved_metadata_entries(&metadata)?;
667 apply_collection_default_ttl_metadata(
668 self,
669 &query.table,
670 &mut metadata,
671 );
672 let (columns, values) = pairwise_columns_values(&node_values);
673 let label = find_column_value_string(&columns, &values, "label")?;
674 let node_type =
675 find_column_value_opt_string(&columns, &values, "node_type");
676 let properties = extract_remaining_properties(
677 &columns,
678 &values,
679 &["label", "node_type"],
680 );
681 crate::reserved_fields::ensure_no_reserved_public_item_fields(
682 properties.iter().map(|(key, _)| key.as_str()),
683 &format!("node '{}'", query.table),
684 )?;
685 prepared.push(PreparedGraphInsert::Node {
686 fields: node_values,
687 input: CreateNodeInput {
688 collection: query.table.clone(),
689 label,
690 node_type,
691 properties,
692 metadata,
693 embeddings: Vec::new(),
694 table_links: Vec::new(),
695 node_links: Vec::new(),
696 },
697 });
698 }
699 InsertEntityType::Edge => {
700 let (edge_values, mut metadata) =
701 split_insert_metadata(self, &query.columns, row_values)?;
702 merge_with_clauses(
703 &mut metadata,
704 query.ttl_ms,
705 query.expires_at_ms,
706 &query.with_metadata,
707 );
708 ensure_non_tree_reserved_metadata_entries(&metadata)?;
709 apply_collection_default_ttl_metadata(
710 self,
711 &query.table,
712 &mut metadata,
713 );
714 let (columns, values) = pairwise_columns_values(&edge_values);
715 let label = find_column_value_string(&columns, &values, "label")?;
716 ensure_non_tree_structural_edge_label(&label)?;
717 let from_id = resolve_edge_endpoint_any(
718 self.inner.db.store().as_ref(),
719 &query.table,
720 &columns,
721 &values,
722 &["from_rid", "from"],
723 )?;
724 let to_id = resolve_edge_endpoint_any(
725 self.inner.db.store().as_ref(),
726 &query.table,
727 &columns,
728 &values,
729 &["to_rid", "to"],
730 )?;
731 let weight = find_column_value_f32_opt(&columns, &values, "weight");
732 let properties = extract_remaining_properties(
733 &columns,
734 &values,
735 &["label", "from_rid", "to_rid", "from", "to", "weight"],
736 );
737 crate::reserved_fields::ensure_no_reserved_public_item_fields(
738 properties.iter().map(|(key, _)| key.as_str()),
739 &format!("edge '{}'", query.table),
740 )?;
741 prepared.push(PreparedGraphInsert::Edge {
742 fields: edge_values,
743 input: CreateEdgeInput {
744 collection: query.table.clone(),
745 label,
746 from: EntityId::new(from_id),
747 to: EntityId::new(to_id),
748 weight,
749 properties,
750 metadata,
751 },
752 });
753 }
754 _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
755 }
756 }
757
758 ensure_graph_insert_contract(self, &query.table)?;
759 let mut batch = self.inner.db.batch();
760 let mut graph_index_fields: Vec<Vec<(String, Value)>> =
761 Vec::with_capacity(prepared.len());
762 for item in prepared {
763 match item {
764 PreparedGraphInsert::Node { fields, input } => {
765 if query.returning.is_some() {
766 returning_field_snaps.push(fields.clone());
767 }
768 graph_index_fields.push(fields);
769 let node_type = input.node_type.unwrap_or_else(|| input.label.clone());
770 batch = batch.add_node_with_type(
771 input.collection,
772 input.label,
773 node_type,
774 input.properties.into_iter().collect(),
775 input.metadata.into_iter().collect(),
776 );
777 }
778 PreparedGraphInsert::Edge { fields, input } => {
779 if query.returning.is_some() {
780 returning_field_snaps.push(fields.clone());
781 }
782 graph_index_fields.push(fields);
783 batch = batch.add_edge(
784 input.collection,
785 input.label,
786 input.from,
787 input.to,
788 input.weight.unwrap_or(1.0),
789 input.properties.into_iter().collect(),
790 input.metadata.into_iter().collect(),
791 );
792 }
793 }
794 }
795 let batch_result = batch
796 .execute()
797 .map_err(|err| RedDBError::Internal(format!("{err:?}")))?;
798 let (ids, entity_kind) = match query.entity_type {
799 InsertEntityType::Node => (batch_result.nodes, "graph_node"),
800 InsertEntityType::Edge => (batch_result.edges, "graph_edge"),
801 _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
802 };
803 for id in &ids {
804 self.stamp_xmin_if_in_txn(&query.table, *id);
805 }
806 if !graph_index_fields.is_empty() {
807 let index_rows: Vec<_> = ids
808 .iter()
809 .zip(graph_index_fields)
810 .map(|(id, fields)| (*id, fields))
811 .collect();
812 self.inner
813 .index_store
814 .index_entity_insert_batch(&query.table, &index_rows)
815 .map_err(RedDBError::Internal)?;
816 }
817 if query.returning.is_some() {
818 returning_field_snaps = graph_insert_returning_snapshots(
819 self.inner.db.store().as_ref(),
820 &query.table,
821 &ids,
822 );
823 }
824 self.cdc_emit_insert_batch_no_cache_invalidate(&query.table, &ids, entity_kind);
825 let store = self.inner.db.store();
826 entity_outputs.extend(ids.iter().map(|id| {
827 crate::application::entity::CreateEntityOutput {
828 id: *id,
829 entity: store.get(&query.table, *id),
830 }
831 }));
832 inserted_count = ids.len() as u64;
833 } else {
834 for row_values in &effective_rows {
835 if row_values.len() != query.columns.len() {
836 return Err(RedDBError::Query(format!(
837 "INSERT column count ({}) does not match value count ({})",
838 query.columns.len(),
839 row_values.len()
840 )));
841 }
842
843 match query.entity_type {
844 InsertEntityType::Row => {
845 if query.returning.is_some() {
846 return Err(RedDBError::Query(
847 "RETURNING is not yet supported for this INSERT path (TimeSeries)"
848 .to_string(),
849 ));
850 }
851 let (fields, mut metadata) =
852 split_insert_metadata(self, &query.columns, row_values)?;
853 merge_with_clauses(
854 &mut metadata,
855 query.ttl_ms,
856 query.expires_at_ms,
857 &query.with_metadata,
858 );
859 self.insert_timeseries_point(&query.table, fields, metadata)?;
860 }
861 InsertEntityType::Node | InsertEntityType::Edge => {
862 unreachable!("NODE and EDGE are handled by the prepared graph path")
863 }
864 InsertEntityType::Vector => {
865 let (vector_values, mut metadata) =
866 split_insert_metadata(self, &query.columns, row_values)?;
867 merge_with_clauses(
868 &mut metadata,
869 query.ttl_ms,
870 query.expires_at_ms,
871 &query.with_metadata,
872 );
873 let (columns, values) = pairwise_columns_values(&vector_values);
874 let dense = find_column_value_vec_f32_any(
875 &columns,
876 &values,
877 &["dense", "embedding"],
878 )?;
879 merge_vector_metadata_column(&mut metadata, &columns, &values)?;
880 let content =
881 find_column_value_opt_string(&columns, &values, "content");
882 if query.returning.is_some() {
883 returning_field_snaps.push(vector_values.clone());
884 }
885 let input = CreateVectorInput {
886 collection: query.table.clone(),
887 dense,
888 content,
889 metadata,
890 link_row: None,
891 link_node: None,
892 };
893 entity_outputs.push(self.create_vector(input)?);
894 }
895 InsertEntityType::Document => {
896 let (document_values, mut metadata) =
897 split_insert_metadata(self, &query.columns, row_values)?;
898 merge_with_clauses(
899 &mut metadata,
900 query.ttl_ms,
901 query.expires_at_ms,
902 &query.with_metadata,
903 );
904 let (columns, values) = pairwise_columns_values(&document_values);
905 let body = find_document_body_json(&columns, &values)?;
906 let input = CreateDocumentInput {
907 collection: query.table.clone(),
908 body,
909 metadata,
910 node_links: Vec::new(),
911 vector_links: Vec::new(),
912 };
913 let output = self.create_document(input)?;
914 if query.returning.is_some() {
915 let fields = output
916 .entity
917 .as_ref()
918 .map(entity_row_fields_snapshot)
919 .filter(|fields| !fields.is_empty())
920 .unwrap_or(document_values);
921 returning_field_snaps.push(fields);
922 }
923 entity_outputs.push(output);
924 }
925 InsertEntityType::Kv => {
926 let (kv_values, mut metadata) =
927 split_insert_metadata(self, &query.columns, row_values)?;
928 merge_with_clauses(
929 &mut metadata,
930 query.ttl_ms,
931 query.expires_at_ms,
932 &query.with_metadata,
933 );
934 let (columns, values) = pairwise_columns_values(&kv_values);
935 let key = find_column_value_string(&columns, &values, "key")?;
936 let value = find_column_value(&columns, &values, "value")?;
937 if query.returning.is_some() {
938 returning_field_snaps.push(kv_values.clone());
939 }
940 let input = CreateKvInput {
941 collection: query.table.clone(),
942 key,
943 value,
944 metadata,
945 };
946 entity_outputs.push(self.create_kv(input)?);
947 }
948 }
949
950 inserted_count += 1;
951 }
952 }
953
954 if let Some(items) = query.returning.as_ref() {
955 if !entity_outputs.is_empty() {
956 returning_result = Some(build_returning_result(
957 items,
958 &returning_field_snaps,
959 Some(&entity_outputs),
960 ));
961 }
962 }
963 }
964
965 if let Some(ref embed_config) = query.auto_embed {
967 let store = self.inner.db.store();
968 let provider =
969 crate::ai::resolve_embeddings_provider_from_runtime(self, &embed_config.provider)?;
970 let is_local_provider = matches!(provider, crate::ai::AiProvider::Local);
971 let api_key = if is_local_provider {
976 String::new()
977 } else {
978 crate::ai::resolve_api_key_from_runtime(&provider, None, self)?
979 };
980 let model = crate::ai::resolve_embeddings_model_from_runtime(
983 self,
984 &provider,
985 embed_config.model.as_deref(),
986 );
987
988 let manager = store
990 .get_collection(&query.table)
991 .ok_or_else(|| RedDBError::NotFound(query.table.clone()))?;
992 let entities = manager.query_all(|_| true);
993 let recent: Vec<_> = entities
994 .into_iter()
995 .rev()
996 .take(effective_rows.len())
997 .collect();
998
999 let entity_combos: Vec<(usize, String)> = recent
1001 .iter()
1002 .enumerate()
1003 .filter_map(|(i, entity)| {
1004 if let EntityData::Row(ref row) = entity.data {
1005 if let Some(ref named) = row.named {
1006 let texts: Vec<String> = embed_config
1007 .fields
1008 .iter()
1009 .filter_map(|field| match named.get(field) {
1010 Some(Value::Text(t)) if !t.is_empty() => Some(t.to_string()),
1011 _ => None,
1012 })
1013 .collect();
1014 if !texts.is_empty() {
1015 return Some((i, texts.join(" ")));
1016 }
1017 }
1018 }
1019 None
1020 })
1021 .collect();
1022
1023 if !entity_combos.is_empty() {
1024 let batch_texts: Vec<String> =
1026 entity_combos.iter().map(|(_, t)| t.clone()).collect();
1027
1028 let embeddings = if is_local_provider {
1038 let response = crate::runtime::ai::local_embedding::embed_local_with_db(
1039 &self.inner.db,
1040 &model,
1041 batch_texts,
1042 )?;
1043 response.embeddings
1044 } else {
1045 let batch_client =
1046 crate::runtime::ai::batch_client::AiBatchClient::from_runtime(self);
1047
1048 match tokio::runtime::Handle::try_current() {
1049 Ok(handle) => tokio::task::block_in_place(|| {
1050 handle.block_on(batch_client.embed_batch(
1051 &provider,
1052 &model,
1053 &api_key,
1054 batch_texts,
1055 ))
1056 }),
1057 Err(_) => {
1058 return Err(RedDBError::Query(
1059 "AUTO EMBED requires a Tokio runtime context".to_string(),
1060 ));
1061 }
1062 }
1063 .map_err(|e| RedDBError::Query(e.to_string()))?
1064 };
1065
1066 for ((_, combined), dense) in entity_combos.iter().zip(embeddings) {
1068 if dense.is_empty() {
1069 continue;
1070 }
1071 self.create_vector(CreateVectorInput {
1072 collection: query.table.clone(),
1073 dense,
1074 content: Some(combined.clone()),
1075 metadata: Vec::new(),
1076 link_row: None,
1077 link_node: None,
1078 })?;
1079 }
1080 }
1081 }
1082
1083 if inserted_count > 0 {
1084 self.note_table_write(&query.table);
1085 }
1086
1087 let mut result = RuntimeQueryResult::dml_result(
1088 raw_query.to_string(),
1089 inserted_count,
1090 "insert",
1091 "runtime-dml",
1092 );
1093 if let Some(returning) = returning_result {
1094 result.result = returning;
1095 }
1096 Ok(result)
1097 }
1098
1099 fn check_insert_column_policy(&self, query: &InsertQuery) -> RedDBResult<()> {
1100 let Some(auth_store) = self.inner.auth_store.read().clone() else {
1101 return Ok(());
1102 };
1103 if !auth_store.iam_authorization_enabled() {
1104 return Ok(());
1105 }
1106 let Some((username, role)) = crate::runtime::impl_core::current_auth_identity() else {
1107 return Ok(());
1108 };
1109
1110 let tenant = crate::runtime::impl_core::current_tenant();
1111 let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
1112 let request = crate::auth::ColumnAccessRequest {
1113 action: "insert".to_string(),
1114 schema: None,
1115 table: query.table.clone(),
1116 columns: query.columns.clone(),
1117 };
1118 let ctx = crate::auth::policies::EvalContext {
1119 principal_tenant: tenant.clone(),
1120 current_tenant: tenant,
1121 peer_ip: None,
1122 mfa_present: false,
1123 now_ms: crate::auth::now_ms(),
1124 principal_is_admin_role: role == crate::auth::Role::Admin,
1125 principal_is_platform_scoped: principal.tenant.is_none(),
1126 };
1127
1128 let outcome = auth_store.check_column_projection_authz(&principal, &request, &ctx);
1129 let table_allowed = matches!(
1130 outcome.table_decision,
1131 crate::auth::policies::Decision::Allow { .. }
1132 | crate::auth::policies::Decision::AdminBypass
1133 );
1134 if !table_allowed {
1135 return Err(RedDBError::Query(format!(
1136 "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1137 outcome.table_resource.kind, outcome.table_resource.name
1138 )));
1139 }
1140 if let Some(denied) = outcome.first_denied_column() {
1141 return Err(RedDBError::Query(format!(
1142 "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1143 denied.resource.kind, denied.resource.name
1144 )));
1145 }
1146
1147 Ok(())
1148 }
1149
1150 pub(crate) fn insert_timeseries_point(
1151 &self,
1152 collection: &str,
1153 fields: Vec<(String, Value)>,
1154 mut metadata: Vec<(String, MetadataValue)>,
1155 ) -> RedDBResult<EntityId> {
1156 apply_collection_default_ttl_metadata(self, collection, &mut metadata);
1157
1158 let (columns, values) = pairwise_columns_values(&fields);
1159 validate_timeseries_insert_columns(&columns)?;
1160
1161 let event_name_opt = find_column_value_opt_string(&columns, &values, "event_name");
1170 let payload_opt = find_column_value_opt_string(&columns, &values, "payload");
1171 if let Some(event_name) = event_name_opt.as_deref() {
1172 let store_for_schema = self.inner.db.store();
1173 if super::analytics_schema_registry::latest(store_for_schema.as_ref(), event_name)
1174 .is_some()
1175 {
1176 let payload_json = payload_opt.as_deref().unwrap_or("{}");
1177 super::analytics_schema_registry::validate(
1178 store_for_schema.as_ref(),
1179 event_name,
1180 payload_json,
1181 )
1182 .map_err(super::analytics_schema_registry::validation_error_to_reddb)?;
1183 }
1184 }
1185
1186 let metric = match find_column_value_opt_string(&columns, &values, "metric") {
1191 Some(m) => m,
1192 None => event_name_opt.clone().ok_or_else(|| {
1193 RedDBError::Query(
1194 "timeseries INSERT requires either `metric` or `event_name`".to_string(),
1195 )
1196 })?,
1197 };
1198 let value = match find_column_value_opt_string(&columns, &values, "value") {
1202 Some(s) => s.parse::<f64>().unwrap_or(1.0),
1203 None => columns
1204 .iter()
1205 .position(|c| c.eq_ignore_ascii_case("value"))
1206 .and_then(|i| match &values[i] {
1207 Value::Float(f) => Some(*f),
1208 Value::Integer(n) | Value::BigInt(n) => Some(*n as f64),
1209 Value::UnsignedInteger(n) => Some(*n as f64),
1210 _ => None,
1211 })
1212 .unwrap_or(1.0),
1213 };
1214 let timestamp_ns =
1215 find_timeseries_timestamp_ns(&columns, &values)?.unwrap_or_else(current_unix_ns);
1216 let mut tags = find_timeseries_tags(&columns, &values)?;
1217 if let Some(ref name) = event_name_opt {
1218 tags.entry("event_name".to_string())
1219 .or_insert_with(|| name.clone());
1220 }
1221 if let Some(ref payload) = payload_opt {
1222 tags.entry("payload".to_string())
1223 .or_insert_with(|| payload.clone());
1224 }
1225 let fields = extract_remaining_properties(
1226 &columns,
1227 &values,
1228 &[
1229 "metric",
1230 "value",
1231 "tags",
1232 "timestamp",
1233 "timestamp_ns",
1234 "time",
1235 "event_name",
1236 "payload",
1237 ],
1238 );
1239 let index_fields = fields.clone();
1240 self.admit_non_evictable_growth(
1241 crate::storage::memory_pools::MemoryPool::SegmentArena,
1242 &format!("insert into {collection}"),
1243 crate::runtime::memory_admission::estimate_timeseries_point_growth(
1244 &metric, &tags, &fields,
1245 ),
1246 )?;
1247 let indexed_columns = self
1248 .inner
1249 .index_store
1250 .list_indices(collection)
1251 .into_iter()
1252 .flat_map(|index| index.columns)
1253 .collect::<Vec<_>>();
1254 if !indexed_columns.is_empty() {
1255 self.admit_non_evictable_growth(
1256 crate::storage::memory_pools::MemoryPool::IndexMemory,
1257 &format!("index insert into {collection}"),
1258 crate::runtime::memory_admission::estimate_index_growth(
1259 std::slice::from_ref(&index_fields),
1260 &indexed_columns,
1261 ),
1262 )?;
1263 }
1264 let series_id = super::impl_timeseries::intern_timeseries_series(
1265 self.inner.db.store().as_ref(),
1266 collection,
1267 &metric,
1268 &tags,
1269 )?;
1270
1271 let mut entity = UnifiedEntity::new(
1272 EntityId::new(0),
1273 EntityKind::TimeSeriesPoint(Box::new(crate::storage::TimeSeriesPointKind {
1274 series: collection.to_string(),
1275 metric: metric.clone(),
1276 })),
1277 EntityData::TimeSeries(crate::storage::TimeSeriesData {
1278 metric,
1279 series_id: Some(series_id),
1280 timestamp_ns,
1281 value,
1282 tags: HashMap::new(),
1283 fields: fields.into_iter().collect(),
1284 }),
1285 );
1286 let writer_xid = match self.current_xid() {
1290 Some(xid) => xid,
1291 None => {
1292 let mgr = self.snapshot_manager();
1293 let xid = mgr.begin();
1294 mgr.commit(xid);
1295 xid
1296 }
1297 };
1298 entity.set_xmin(writer_xid);
1299
1300 let store = self.inner.db.store();
1301 let id = store
1302 .insert_auto(collection, entity)
1303 .map_err(|err| RedDBError::Internal(err.to_string()))?;
1304 self.inner
1305 .index_store
1306 .index_entity_insert(collection, id, &index_fields)
1307 .map_err(RedDBError::Internal)?;
1308
1309 if !metadata.is_empty() {
1310 let _ = store.set_metadata(
1311 collection,
1312 id,
1313 Metadata::with_fields(metadata.into_iter().collect()),
1314 );
1315 }
1316
1317 let _ = self.inner.db.hypertables().route(collection, timestamp_ns);
1318
1319 self.cdc_emit(
1320 crate::replication::cdc::ChangeOperation::Insert,
1321 collection,
1322 id.raw(),
1323 "timeseries",
1324 );
1325
1326 Ok(id)
1327 }
1328
1329 pub fn execute_update(
1334 &self,
1335 raw_query: &str,
1336 query: &UpdateQuery,
1337 ) -> RedDBResult<RuntimeQueryResult> {
1338 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
1339 if crate::runtime::blockchain_kind::is_chain(self.inner.db.store().as_ref(), &query.table) {
1343 return Err(RedDBError::InvalidOperation(format!(
1344 "BlockchainCollectionImmutable: UPDATE not allowed on '{}'",
1345 query.table
1346 )));
1347 }
1348 if query.claim_limit.is_some() && self.is_queue_collection(&query.table) {
1355 return self.execute_queue_shaped_claim(raw_query, query);
1356 }
1357 let inferred_owned;
1365 let query = match self.resolve_unmarked_update_target(query)? {
1366 Some(rewritten) => {
1367 inferred_owned = rewritten;
1368 &inferred_owned
1369 }
1370 None => query,
1371 };
1372 crate::runtime::collection_contract::CollectionContractGate::check(
1378 self,
1379 &query.table,
1380 crate::runtime::collection_contract::MutationKind::Update,
1381 )?;
1382 ensure_update_target_contract(self, &query.table, query.target)?;
1383 ensure_kv_key_update_target_allowed(query)?;
1384 ensure_graph_identity_update_target_allowed(query)?;
1385
1386 let rls_gated = crate::runtime::impl_core::rls_is_enabled(self, &query.table);
1392 let augmented_query: UpdateQuery;
1393 let effective_query: &UpdateQuery = if rls_gated {
1394 let update_filter = crate::runtime::impl_core::rls_policy_filter(
1395 self,
1396 &query.table,
1397 crate::storage::query::ast::PolicyAction::Update,
1398 );
1399 let Some(mut policy) = update_filter else {
1400 let mut response = RuntimeQueryResult::dml_result(
1403 raw_query.to_string(),
1404 0,
1405 "update",
1406 "runtime-dml-rls",
1407 );
1408 if let Some(items) = query.returning.clone() {
1409 response.result = build_returning_result(&items, &[], None);
1410 }
1411 return Ok(response);
1412 };
1413 if query.claim_limit.is_some() {
1414 let read_filter = crate::runtime::impl_core::rls_policy_filter(
1415 self,
1416 &query.table,
1417 crate::storage::query::ast::PolicyAction::Select,
1418 );
1419 let Some(read_policy) = read_filter else {
1420 let mut response = RuntimeQueryResult::dml_result(
1421 raw_query.to_string(),
1422 0,
1423 "update",
1424 "runtime-dml-rls",
1425 );
1426 if let Some(items) = query.returning.clone() {
1427 response.result = build_returning_result(&items, &[], None);
1428 }
1429 return Ok(response);
1430 };
1431 policy = crate::storage::query::ast::Filter::And(
1432 Box::new(read_policy),
1433 Box::new(policy),
1434 );
1435 }
1436 let mut augmented = query.clone();
1437 augmented.filter = Some(match augmented.filter.take() {
1438 Some(existing) => {
1439 crate::storage::query::ast::Filter::And(Box::new(existing), Box::new(policy))
1440 }
1441 None => policy,
1442 });
1443 augmented_query = augmented;
1444 &augmented_query
1445 } else {
1446 query
1447 };
1448
1449 if let Some(items) = effective_query.returning.clone() {
1454 let mut inner_query = effective_query.clone();
1455 inner_query.returning = None;
1456 let (mut response, touched_ids) =
1457 self.execute_update_inner_tracked(raw_query, &inner_query)?;
1458
1459 let mut snapshots = if matches!(
1460 effective_query.target,
1461 UpdateTarget::Nodes | UpdateTarget::Edges
1462 ) {
1463 graph_update_returning_snapshots(self, &effective_query.table, &touched_ids)
1464 } else {
1465 super::dml_target_scan::DmlTargetScan::new(self, &effective_query.table, None, None)
1466 .row_snapshots(&touched_ids)
1467 };
1468 if matches!(effective_query.target, UpdateTarget::Kv) {
1469 restore_kv_returning_keys(
1470 self,
1471 &effective_query.table,
1472 &touched_ids,
1473 &mut snapshots,
1474 );
1475 }
1476
1477 response.result = build_returning_result(&items, &snapshots, None);
1478 response.engine = "runtime-dml-returning";
1479 return Ok(response);
1480 }
1481
1482 self.execute_update_inner(raw_query, effective_query)
1483 }
1484
1485 fn execute_update_inner(
1487 &self,
1488 raw_query: &str,
1489 query: &UpdateQuery,
1490 ) -> RedDBResult<RuntimeQueryResult> {
1491 self.execute_update_inner_tracked(raw_query, query)
1492 .map(|(res, _)| res)
1493 }
1494
1495 fn enforce_claim_order_by_index_gate(&self, query: &UpdateQuery) -> RedDBResult<()> {
1504 if query.claim_limit.is_none()
1505 || !matches!(query.target, UpdateTarget::Rows | UpdateTarget::Documents)
1506 {
1507 return Ok(());
1508 }
1509 let available_indexes: Vec<Vec<String>> = self
1510 .index_store_ref()
1511 .list_indices(&query.table)
1512 .into_iter()
1513 .map(|index| index.columns)
1514 .collect();
1515 reddb_rql::planner::check_claim_order_by_index_gate(query, &available_indexes)
1516 .map_err(RedDBError::InvalidOperation)
1517 }
1518
1519 fn execute_update_inner_tracked(
1520 &self,
1521 raw_query: &str,
1522 query: &UpdateQuery,
1523 ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
1524 self.enforce_claim_order_by_index_gate(query)?;
1525 let store = self.inner.db.store();
1526 let effective_filter = effective_update_filter(query);
1527 let compiled_plan = self.compile_update_plan(query)?;
1528 let needs_rmw_lock = update_needs_rmw_lock(query);
1529 let claim_model = update_model_name(update_target_model(query.target));
1530 if query.claim_limit.is_some() {
1531 self.inner
1532 .claim_telemetry
1533 .record_attempt(&query.table, claim_model);
1534 }
1535 let claim_lock = query.claim_limit.map(|_| {
1536 self.inner
1537 .rmw_locks
1538 .lock_for(&query.table, "__table_claim_update__")
1539 });
1540 let _claim_guard = if let Some(lock) = claim_lock.as_ref() {
1541 let Some(guard) = lock.try_lock() else {
1542 let skipped_locked = query.claim_limit.unwrap_or(1);
1543 self.inner.claim_telemetry.record_skipped_locked(
1544 &query.table,
1545 claim_model,
1546 skipped_locked,
1547 );
1548 tracing::debug!(
1549 target: "reddb::claim",
1550 collection = %query.table,
1551 model = claim_model,
1552 skipped_locked,
1553 "concurrent claim skipped locked candidates"
1554 );
1555 return Ok((
1556 RuntimeQueryResult::dml_result(
1557 raw_query.to_string(),
1558 0,
1559 "update",
1560 "runtime-dml",
1561 ),
1562 Vec::new(),
1563 ));
1564 };
1565 Some(guard)
1566 } else {
1567 None
1568 };
1569 let table_rmw_lock = if needs_rmw_lock {
1570 Some(
1571 self.inner
1572 .rmw_locks
1573 .lock_for(&query.table, "__table_rmw_update__"),
1574 )
1575 } else {
1576 None
1577 };
1578 let _table_rmw_guard = table_rmw_lock.as_ref().map(|lock| lock.lock());
1579 let mut touched_ids: Vec<EntityId> = Vec::new();
1580 let claim_cap = query.claim_limit.map(|limit| limit as usize);
1581 let limit_cap = claim_cap.or_else(|| query.limit.map(|limit| limit as usize));
1582 let manager = store
1583 .get_collection(&query.table)
1584 .ok_or_else(|| RedDBError::NotFound(query.table.clone()))?;
1585 let scan_limit = if query.order_by.is_empty() {
1586 limit_cap
1587 } else {
1588 None
1589 };
1590 let mut target_scan = super::dml_target_scan::DmlTargetScan::with_update_target(
1591 self,
1592 &query.table,
1593 effective_filter.as_ref(),
1594 scan_limit,
1595 query.target,
1596 );
1597 if needs_rmw_lock {
1598 target_scan = target_scan.with_live_table_rows();
1599 }
1600 let ids_to_update = target_scan.find_target_ids()?;
1601 let order_limit = if query.claim_limit.is_some() {
1602 None
1603 } else {
1604 limit_cap
1605 };
1606 let ids_to_update = if query.order_by.is_empty() {
1607 ids_to_update
1608 } else {
1609 ordered_update_target_ids(&manager, &ids_to_update, &query.order_by, order_limit)
1610 };
1611 let mut ids_to_update = if query.claim_limit.is_some() {
1612 self.filter_claim_locked_target_ids(&query.table, ids_to_update)
1613 } else {
1614 ids_to_update
1615 };
1616 if let Some(claim_cap) = claim_cap {
1617 ids_to_update.truncate(claim_cap);
1618 }
1619 if query.claim_exact
1620 && claim_cap.is_some_and(|claim_count| ids_to_update.len() < claim_count)
1621 {
1622 self.inner
1623 .claim_telemetry
1624 .record_miss(&query.table, claim_model);
1625 return Ok((
1626 RuntimeQueryResult::dml_result(raw_query.to_string(), 0, "update", "runtime-dml"),
1627 Vec::new(),
1628 ));
1629 }
1630
1631 if needs_rmw_lock {
1632 let result = self.execute_update_inner_tracked_locked(
1633 raw_query,
1634 query,
1635 &compiled_plan,
1636 &ids_to_update,
1637 effective_filter.as_ref(),
1638 )?;
1639 if query.claim_limit.is_some() {
1640 self.record_pending_claim_locks_for_touched_ids(&query.table, &result.1);
1641 }
1642 record_claim_outcome(
1643 &self.inner.claim_telemetry,
1644 query.claim_limit,
1645 &query.table,
1646 claim_model,
1647 result.0.affected_rows,
1648 );
1649 return Ok(result);
1650 }
1651
1652 let mut affected: u64 = 0;
1653 for chunk in ids_to_update.chunks(UPDATE_APPLY_CHUNK_SIZE) {
1654 let mut applied_chunk = Vec::with_capacity(chunk.len());
1655 for entity in manager.get_many(chunk).into_iter().flatten() {
1656 let assignments =
1657 self.materialize_update_assignments_for_entity(query, &entity, &compiled_plan)?;
1658 let applied = self.apply_materialized_update_for_entity(
1659 query,
1660 entity,
1661 &compiled_plan,
1662 assignments,
1663 )?;
1664 touched_ids.push(applied.id);
1665 applied_chunk.push(applied);
1666 }
1667 self.persist_update_chunk(&applied_chunk)?;
1668 affected += applied_chunk.len() as u64;
1669 let lsns = self.flush_update_chunk(&applied_chunk)?;
1670 if !query.suppress_events {
1671 self.emit_update_events_for_collection(&query.table, &applied_chunk, &lsns)?;
1672 }
1673 }
1674
1675 if affected > 0 {
1676 self.note_table_write(&query.table);
1677 }
1678 if query.claim_limit.is_some() {
1679 self.record_pending_claim_locks_for_touched_ids(&query.table, &touched_ids);
1680 }
1681 record_claim_outcome(
1682 &self.inner.claim_telemetry,
1683 query.claim_limit,
1684 &query.table,
1685 claim_model,
1686 affected,
1687 );
1688
1689 Ok((
1690 RuntimeQueryResult::dml_result(
1691 raw_query.to_string(),
1692 affected,
1693 "update",
1694 "runtime-dml",
1695 ),
1696 touched_ids,
1697 ))
1698 }
1699
1700 fn filter_claim_locked_target_ids(&self, table: &str, ids: Vec<EntityId>) -> Vec<EntityId> {
1701 let conn_id = current_connection_id();
1702 let locks = self.inner.pending_claim_locks.read();
1703 ids.into_iter()
1704 .filter(|id| {
1705 let Some(entity) = self.inner.db.store().get(table, *id) else {
1706 return false;
1707 };
1708 let key = (table.to_string(), entity.logical_id());
1709 locks
1710 .get(&key)
1711 .is_none_or(|owner_conn_id| *owner_conn_id == conn_id)
1712 })
1713 .collect()
1714 }
1715
1716 fn record_pending_claim_locks_for_touched_ids(&self, table: &str, ids: &[EntityId]) {
1717 if self.current_xid().is_none() || ids.is_empty() {
1718 return;
1719 }
1720
1721 let conn_id = current_connection_id();
1722 let store = self.inner.db.store();
1723 let mut locks = self.inner.pending_claim_locks.write();
1724 for id in ids {
1725 let Some(entity) = store.get(table, *id) else {
1726 continue;
1727 };
1728 locks.insert((table.to_string(), entity.logical_id()), conn_id);
1729 }
1730 }
1731
1732 fn execute_update_inner_tracked_locked(
1733 &self,
1734 raw_query: &str,
1735 query: &UpdateQuery,
1736 compiled_plan: &CompiledUpdatePlan,
1737 ids_to_update: &[EntityId],
1738 effective_filter: Option<&Filter>,
1739 ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
1740 let store = self.inner.db.store();
1741 let mut touched_ids = Vec::new();
1742 let mut lock_entries = Vec::new();
1743
1744 for id in ids_to_update {
1745 let Some(candidate) = store.get(&query.table, *id) else {
1746 continue;
1747 };
1748 let logical_id = candidate.logical_id();
1749 let lock_key = format!("row:{}", logical_id.raw());
1750 let rmw_lock = self.inner.rmw_locks.lock_for(&query.table, &lock_key);
1751 lock_entries.push((lock_key, logical_id, rmw_lock));
1752 }
1753
1754 lock_entries.sort_by(|left, right| left.0.cmp(&right.0));
1755 lock_entries.dedup_by(|left, right| left.0 == right.0);
1756 let _rmw_guards: Vec<_> = lock_entries.iter().map(|entry| entry.2.lock()).collect();
1757
1758 let mut applied_chunk = Vec::new();
1759 for (_, logical_id, _) in &lock_entries {
1760 let Some(entity) = resolve_update_entity_by_logical_id(self, &query.table, *logical_id)
1761 else {
1762 continue;
1763 };
1764 if let Some(filter) = effective_filter {
1765 if !crate::runtime::query_exec::evaluate_entity_filter_with_db(
1766 Some(self.inner.db.as_ref()),
1767 &entity,
1768 filter,
1769 &query.table,
1770 &query.table,
1771 ) {
1772 continue;
1773 }
1774 }
1775
1776 let assignments =
1777 self.materialize_update_assignments_for_entity(query, &entity, compiled_plan)?;
1778 let applied = self.apply_materialized_update_for_entity(
1779 query,
1780 entity,
1781 compiled_plan,
1782 assignments,
1783 )?;
1784 touched_ids.push(applied.id);
1785 applied_chunk.push(applied);
1786 }
1787
1788 let affected = applied_chunk.len() as u64;
1789 if !applied_chunk.is_empty() {
1790 self.persist_update_chunk(&applied_chunk)?;
1791 let lsns = self.flush_update_chunk(&applied_chunk)?;
1792 if !query.suppress_events {
1793 self.emit_update_events_for_collection(&query.table, &applied_chunk, &lsns)?;
1794 }
1795 }
1796
1797 if affected > 0 {
1798 self.note_table_write(&query.table);
1799 }
1800
1801 Ok((
1802 RuntimeQueryResult::dml_result(
1803 raw_query.to_string(),
1804 affected,
1805 "update",
1806 "runtime-dml",
1807 ),
1808 touched_ids,
1809 ))
1810 }
1811
1812 fn compile_update_plan(&self, query: &UpdateQuery) -> RedDBResult<CompiledUpdatePlan> {
1813 let mut static_field_assignments = Vec::new();
1814 let mut static_metadata_assignments = Vec::new();
1815 let mut dynamic_assignments = Vec::new();
1816 let row_contract_plan = build_row_update_contract_plan(&self.db(), &query.table)?;
1817 let mut row_modified_columns = Vec::new();
1818
1819 for (idx, (column, expr)) in query.assignment_exprs.iter().enumerate() {
1820 let compound_op = query.compound_assignment_ops.get(idx).copied().flatten();
1821 let metadata_key = resolve_sql_ttl_metadata_key(column);
1822 if compound_op.is_some() && metadata_key.is_some() {
1823 return Err(RedDBError::Query(format!(
1824 "compound assignment is only supported for row fields: {column}"
1825 )));
1826 }
1827 if compound_op.is_none() {
1828 if let Ok(value) = fold_expr_to_value(expr.clone()) {
1829 if let Some(metadata_key) = metadata_key {
1830 let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
1831 let (canonical_key, canonical_value) =
1832 canonicalize_sql_ttl_metadata(metadata_key, raw_value);
1833 static_metadata_assignments
1834 .push((canonical_key.to_string(), canonical_value));
1835 } else {
1836 let value = self.resolve_crypto_sentinel(value)?;
1837 static_field_assignments.push((
1838 column.clone(),
1839 normalize_row_update_assignment_with_plan(
1840 &query.table,
1841 column,
1842 value,
1843 row_contract_plan.as_ref(),
1844 )?,
1845 ));
1846 row_modified_columns.push(column.clone());
1847 }
1848 continue;
1849 }
1850 }
1851
1852 dynamic_assignments.push(CompiledUpdateAssignment {
1853 column: column.clone(),
1854 expr: expr.clone(),
1855 compound_op,
1856 metadata_key,
1857 row_rule: if metadata_key.is_none() {
1858 if let Some(plan) = row_contract_plan.as_ref() {
1859 if plan.timestamps_enabled
1860 && (column == "created_at" || column == "updated_at")
1861 {
1862 return Err(RedDBError::Query(format!(
1863 "collection '{}' manages '{}' automatically — do not set it in UPDATE",
1864 query.table, column
1865 )));
1866 }
1867 if let Some(rule) = plan.declared_rules.get(column) {
1868 Some(rule.clone())
1869 } else if plan.strict_schema {
1870 return Err(RedDBError::Query(format!(
1871 "collection '{}' is strict and does not allow undeclared fields: {}",
1872 query.table, column
1873 )));
1874 } else {
1875 None
1876 }
1877 } else {
1878 None
1879 }
1880 } else {
1881 None
1882 },
1883 });
1884 if metadata_key.is_none() {
1885 row_modified_columns.push(column.clone());
1886 }
1887 }
1888
1889 let row_modified_columns = dedupe_update_columns(row_modified_columns);
1890 let row_touches_unique_columns = row_contract_plan.as_ref().is_some_and(|plan| {
1891 row_modified_columns.iter().any(|column| {
1892 plan.unique_columns
1893 .keys()
1894 .any(|unique| unique.eq_ignore_ascii_case(column))
1895 })
1896 });
1897
1898 if let Some(ttl_ms) = query.ttl_ms {
1899 static_metadata_assignments
1900 .push(("_ttl_ms".to_string(), metadata_u64_to_value(ttl_ms)));
1901 }
1902 if let Some(expires_at_ms) = query.expires_at_ms {
1903 static_metadata_assignments.push((
1904 "_expires_at".to_string(),
1905 metadata_u64_to_value(expires_at_ms),
1906 ));
1907 }
1908 for (key, val) in &query.with_metadata {
1909 static_metadata_assignments.push((key.clone(), storage_value_to_metadata_value(val)));
1910 }
1911
1912 Ok(CompiledUpdatePlan {
1913 static_field_assignments,
1914 static_metadata_assignments,
1915 dynamic_assignments,
1916 row_contract_plan,
1917 row_modified_columns,
1918 row_touches_unique_columns,
1919 })
1920 }
1921
1922 fn materialize_update_assignments_for_entity(
1923 &self,
1924 query: &UpdateQuery,
1925 entity: &UnifiedEntity,
1926 compiled_plan: &CompiledUpdatePlan,
1927 ) -> RedDBResult<MaterializedUpdateAssignments> {
1928 let mut assignments = MaterializedUpdateAssignments::default();
1929 let mut record: Option<UnifiedRecord> = None;
1930
1931 for assignment in &compiled_plan.dynamic_assignments {
1932 if assignment.compound_op.is_some()
1933 && !matches!(
1934 entity.data,
1935 EntityData::Row(_) | EntityData::Node(_) | EntityData::Edge(_)
1936 )
1937 {
1938 return Err(RedDBError::Query(format!(
1939 "compound assignment is only supported for row or graph UPDATE column '{}'",
1940 assignment.column
1941 )));
1942 }
1943 if record.is_none() {
1944 record = runtime_any_record_from_entity_ref(entity);
1945 }
1946 let Some(record) = record.as_ref() else {
1947 return Err(RedDBError::Query(format!(
1948 "UPDATE could not materialize runtime record for entity {} in '{}'",
1949 entity.id.raw(),
1950 query.table
1951 )));
1952 };
1953 let rhs = super::expr_eval::evaluate_runtime_expr_with_db(
1954 Some(self.inner.db.as_ref()),
1955 &assignment.expr,
1956 record,
1957 Some(query.table.as_str()),
1958 Some(query.table.as_str()),
1959 )
1960 .ok_or_else(|| {
1961 RedDBError::Query(format!(
1962 "failed to evaluate UPDATE expression for column '{}'",
1963 assignment.column
1964 ))
1965 })?;
1966 let value = if let Some(op) = assignment.compound_op {
1967 evaluate_compound_update_assignment(&assignment.column, record, op, rhs)?
1968 } else {
1969 rhs
1970 };
1971
1972 if let Some(metadata_key) = assignment.metadata_key {
1973 let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
1974 let (canonical_key, canonical_value) =
1975 canonicalize_sql_ttl_metadata(metadata_key, raw_value);
1976 assignments
1977 .dynamic_metadata_assignments
1978 .push((canonical_key.to_string(), canonical_value));
1979 } else {
1980 assignments.dynamic_field_assignments.push((
1981 assignment.column.clone(),
1982 normalize_row_update_value_for_rule(
1983 &query.table,
1984 self.resolve_crypto_sentinel(value)?,
1985 assignment.row_rule.as_ref(),
1986 )?,
1987 ));
1988 }
1989 }
1990
1991 Ok(assignments)
1992 }
1993
1994 fn apply_materialized_update_for_entity(
1995 &self,
1996 query: &UpdateQuery,
1997 entity: UnifiedEntity,
1998 compiled_plan: &CompiledUpdatePlan,
1999 mut assignments: MaterializedUpdateAssignments,
2000 ) -> RedDBResult<AppliedEntityMutation> {
2001 if matches!(query.target, UpdateTarget::Kv)
2002 && !compiled_plan
2003 .static_field_assignments
2004 .iter()
2005 .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2006 && !assignments
2007 .dynamic_field_assignments
2008 .iter()
2009 .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2010 {
2011 if let Some(key) = runtime_any_record_from_entity_ref(&entity)
2012 .and_then(|record| record.get("key").cloned())
2013 {
2014 assignments
2015 .dynamic_field_assignments
2016 .push(("key".to_string(), key));
2017 }
2018 }
2019 if matches!(entity.data, EntityData::Row(_)) {
2020 return self.apply_loaded_sql_update_row_core(
2021 query.table.clone(),
2022 entity,
2023 &compiled_plan.static_field_assignments,
2024 assignments.dynamic_field_assignments,
2025 &compiled_plan.static_metadata_assignments,
2026 assignments.dynamic_metadata_assignments,
2027 compiled_plan.row_contract_plan.as_ref(),
2028 &compiled_plan.row_modified_columns,
2029 compiled_plan.row_touches_unique_columns,
2030 );
2031 }
2032
2033 ensure_graph_identity_update_allowed(&entity, compiled_plan, &assignments)?;
2034
2035 let operations = build_patch_operations_from_materialized_assignments(
2036 &entity,
2037 compiled_plan,
2038 assignments,
2039 );
2040 self.apply_loaded_patch_entity_core(
2041 query.table.clone(),
2042 entity,
2043 crate::json::Value::Null,
2044 operations,
2045 )
2046 }
2047
2048 pub fn execute_delete(
2050 &self,
2051 raw_query: &str,
2052 query: &DeleteQuery,
2053 ) -> RedDBResult<RuntimeQueryResult> {
2054 self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2055 if crate::runtime::blockchain_kind::is_chain(self.inner.db.store().as_ref(), &query.table) {
2058 return Err(RedDBError::InvalidOperation(format!(
2059 "BlockchainCollectionImmutable: DELETE not allowed on '{}'",
2060 query.table
2061 )));
2062 }
2063 crate::runtime::collection_contract::CollectionContractGate::check(
2067 self,
2068 &query.table,
2069 crate::runtime::collection_contract::MutationKind::Delete,
2070 )?;
2071
2072 if let Some(items) = query.returning.clone() {
2079 let select_sql = delete_to_select_sql(raw_query).ok_or_else(|| {
2080 RedDBError::Query(
2081 "DELETE ... RETURNING: cannot rewrite query for pre-image scan".to_string(),
2082 )
2083 })?;
2084 let captured = self.execute_query(&select_sql)?;
2085
2086 let mut inner_query = query.clone();
2087 inner_query.returning = None;
2088 let _ = self.execute_delete(raw_query, &inner_query)?;
2089
2090 let snapshots: Vec<Vec<(String, Value)>> = captured
2091 .result
2092 .records
2093 .iter()
2094 .map(|rec| {
2095 rec.iter_fields()
2096 .map(|(k, v)| (k.as_ref().to_string(), v.clone()))
2097 .collect()
2098 })
2099 .collect();
2100 let affected = snapshots.len() as u64;
2101 let result = build_returning_result(&items, &snapshots, None);
2102
2103 let mut response = RuntimeQueryResult::dml_result(
2104 raw_query.to_string(),
2105 affected,
2106 "delete",
2107 "runtime-dml-returning",
2108 );
2109 response.result = result;
2110 return Ok(response);
2111 }
2112 if crate::runtime::impl_core::rls_is_enabled(self, &query.table) {
2119 let rls_filter = crate::runtime::impl_core::rls_policy_filter(
2120 self,
2121 &query.table,
2122 crate::storage::query::ast::PolicyAction::Delete,
2123 );
2124 let Some(policy) = rls_filter else {
2125 return Ok(RuntimeQueryResult::dml_result(
2126 raw_query.to_string(),
2127 0,
2128 "delete",
2129 "runtime-dml-rls",
2130 ));
2131 };
2132 let mut augmented = query.clone();
2137 augmented.filter = Some(match augmented.filter.take() {
2138 Some(existing) => {
2139 crate::storage::query::ast::Filter::And(Box::new(existing), Box::new(policy))
2140 }
2141 None => policy,
2142 });
2143 return self.execute_delete_inner(raw_query, &augmented);
2144 }
2145 self.execute_delete_inner(raw_query, query)
2146 }
2147
2148 fn execute_delete_inner(
2149 &self,
2150 raw_query: &str,
2151 query: &DeleteQuery,
2152 ) -> RedDBResult<RuntimeQueryResult> {
2153 let effective_filter = effective_delete_filter(query);
2154
2155 let scan = super::dml_target_scan::DmlTargetScan::new(
2159 self,
2160 &query.table,
2161 effective_filter.as_ref(),
2162 None,
2163 );
2164 let ids_to_delete = scan.find_target_ids()?;
2165
2166 let needs_delete_events =
2169 !query.suppress_events && self.collection_has_delete_subscriptions(&query.table);
2170 let mut pre_images: HashMap<u64, crate::json::Value> = if needs_delete_events {
2171 scan.row_json_pre_images(&ids_to_delete)
2172 } else {
2173 HashMap::new()
2174 };
2175
2176 let mut affected: u64 = 0;
2177 for chunk in ids_to_delete.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2178 let (count, lsns) = self.delete_entities_batch(&query.table, chunk)?;
2179 affected += count;
2180 if needs_delete_events && !lsns.is_empty() {
2181 let deleted_chunk = &chunk[..lsns.len().min(chunk.len())];
2185 self.emit_delete_events_for_collection(
2186 &query.table,
2187 deleted_chunk,
2188 &lsns,
2189 &pre_images,
2190 )?;
2191 }
2192 }
2193 pre_images.clear();
2194
2195 if affected > 0 {
2196 self.note_table_write(&query.table);
2197 }
2198
2199 Ok(RuntimeQueryResult::dml_result(
2200 raw_query.to_string(),
2201 affected,
2202 "delete",
2203 "runtime-dml",
2204 ))
2205 }
2206}
2207
2208#[cfg(test)]
2209mod tests {
2210 use crate::storage::schema::Value;
2211 use crate::storage::wal::{WalReader, WalRecord};
2212 use crate::storage::{DeployProfile, StoragePackaging, StorageProfileSelection};
2213 use crate::{RedDBOptions, RedDBRuntime};
2214 use std::path::Path;
2215
2216 fn persistent_operational_options(path: &Path) -> RedDBOptions {
2217 RedDBOptions::persistent(path)
2218 .with_storage_profile(StorageProfileSelection {
2219 deploy_profile: DeployProfile::Embedded,
2220 packaging: StoragePackaging::OperationalDirectory,
2221 replica_count: 0,
2222 managed_backup: false,
2223 wal_retention: false,
2224 })
2225 .unwrap()
2226 }
2227
2228 fn store_commit_batches(wal_path: &Path) -> Vec<Vec<Vec<u8>>> {
2229 WalReader::open(wal_path)
2230 .expect("wal opens")
2231 .iter()
2232 .map(|record| record.expect("wal record decodes").1)
2233 .filter_map(|record| match record {
2234 WalRecord::TxCommitBatch { actions, .. } => Some(actions),
2235 _ => None,
2236 })
2237 .collect()
2238 }
2239
2240 fn action_contains_text(action: &[u8], needle: &str) -> bool {
2241 action
2242 .windows(needle.len())
2243 .any(|window| window == needle.as_bytes())
2244 }
2245
2246 fn claim_metric_count(
2247 snapshot: &crate::runtime::ClaimTelemetrySnapshot,
2248 metric: &str,
2249 collection: &str,
2250 model: &str,
2251 ) -> u64 {
2252 let rows = match metric {
2253 "attempts" => &snapshot.attempts,
2254 "successful" => &snapshot.successful,
2255 "misses" => &snapshot.misses,
2256 "skipped_locked" => &snapshot.skipped_locked,
2257 other => panic!("unknown claim metric {other}"),
2258 };
2259 rows.iter()
2260 .find(|((actual_collection, actual_model), _)| {
2261 actual_collection == collection && actual_model == model
2262 })
2263 .map(|(_, count)| *count)
2264 .unwrap_or(0)
2265 }
2266
2267 fn assert_statement_writes_collections_in_one_new_wal_batch(
2268 rt: &RedDBRuntime,
2269 wal_path: &Path,
2270 statement: &str,
2271 source: &str,
2272 event_queue: &str,
2273 ) {
2274 let before_batches = store_commit_batches(wal_path).len();
2275
2276 rt.execute_query(statement).unwrap();
2277
2278 let batches = store_commit_batches(wal_path);
2279 let statement_batches = &batches[before_batches..];
2280 let source_batch = statement_batches
2281 .iter()
2282 .position(|actions| {
2283 actions.iter().any(|action| {
2284 action_contains_text(action, source)
2285 && !action_contains_text(action, event_queue)
2286 })
2287 })
2288 .expect("source collection write batch is present");
2289 let event_batch = statement_batches
2290 .iter()
2291 .position(|actions| {
2292 actions
2293 .iter()
2294 .any(|action| action_contains_text(action, event_queue))
2295 })
2296 .expect("event queue write batch is present");
2297
2298 assert_eq!(
2299 source_batch, event_batch,
2300 "WITH EVENTS must persist the source write and queue event in the same WAL batch"
2301 );
2302 }
2303
2304 #[test]
2305 fn with_events_autocommit_persists_mutation_and_event_in_one_wal_batch() {
2306 let dir = tempfile::tempdir().unwrap();
2307 let db_path = dir.path().join("events_dual_write.rdb");
2308 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
2309 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
2310
2311 rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
2312 .unwrap();
2313 assert_statement_writes_collections_in_one_new_wal_batch(
2314 &rt,
2315 &wal_path,
2316 "INSERT INTO users (id, email) VALUES (1, 'a@example.test')",
2317 "users",
2318 "users_events",
2319 );
2320 }
2321
2322 #[test]
2323 fn with_events_autocommit_update_persists_mutation_and_event_in_one_wal_batch() {
2324 let dir = tempfile::tempdir().unwrap();
2325 let db_path = dir.path().join("events_update_atomic.rdb");
2326 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
2327 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
2328
2329 rt.execute_query(
2330 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (UPDATE) TO user_updates",
2331 )
2332 .unwrap();
2333 rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
2334 .unwrap();
2335
2336 assert_statement_writes_collections_in_one_new_wal_batch(
2337 &rt,
2338 &wal_path,
2339 "UPDATE users SET email = 'b@example.test' WHERE id = 1",
2340 "users",
2341 "user_updates",
2342 );
2343 }
2344
2345 #[test]
2346 fn with_events_autocommit_delete_persists_mutation_and_event_in_one_wal_batch() {
2347 let dir = tempfile::tempdir().unwrap();
2348 let db_path = dir.path().join("events_delete_atomic.rdb");
2349 let wal_path = reddb_file::layout::unified_wal_path(&db_path);
2350 let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
2351
2352 rt.execute_query(
2353 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (DELETE) TO user_deletes",
2354 )
2355 .unwrap();
2356 rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
2357 .unwrap();
2358
2359 assert_statement_writes_collections_in_one_new_wal_batch(
2360 &rt,
2361 &wal_path,
2362 "DELETE FROM users WHERE id = 1",
2363 "users",
2364 "user_deletes",
2365 );
2366 }
2367
2368 #[test]
2369 fn update_where_id_in_with_hash_index_updates_expected_rows() {
2370 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2371 rt.execute_query("CREATE TABLE users (id INT, score INT)")
2372 .unwrap();
2373 for id in 0..5 {
2374 rt.execute_query(&format!("INSERT INTO users (id, score) VALUES ({id}, 0)"))
2375 .unwrap();
2376 }
2377 rt.execute_query("CREATE INDEX idx_id ON users (id) USING HASH")
2378 .unwrap();
2379
2380 let updated = rt
2381 .execute_query("UPDATE users SET score = 42 WHERE id IN (1,3,4)")
2382 .unwrap();
2383 assert_eq!(updated.affected_rows, 3);
2384
2385 let selected = rt
2386 .execute_query("SELECT id, score FROM users ORDER BY id")
2387 .unwrap();
2388 let scores: Vec<(i64, i64)> = selected
2389 .result
2390 .records
2391 .iter()
2392 .map(|record| {
2393 let id = match record.get("id").unwrap() {
2394 Value::Integer(value) => *value,
2395 other => panic!("expected integer id, got {other:?}"),
2396 };
2397 let score = match record.get("score").unwrap() {
2398 Value::Integer(value) => *value,
2399 other => panic!("expected integer score, got {other:?}"),
2400 };
2401 (id, score)
2402 })
2403 .collect();
2404 assert_eq!(scores, vec![(0, 0), (1, 42), (2, 0), (3, 42), (4, 42)]);
2405 }
2406
2407 #[test]
2414 fn update_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
2415 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2416 rt.execute_query("CREATE TABLE items (id INT, score INT)")
2417 .unwrap();
2418 for id in 0..5 {
2419 rt.execute_query(&format!(
2420 "INSERT INTO items (id, score) VALUES ({id}, {})",
2421 id * 10
2422 ))
2423 .unwrap();
2424 }
2425 rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
2426 .unwrap();
2427
2428 let updated_one = rt
2432 .execute_query("UPDATE items SET score = 5 WHERE id = 2")
2433 .unwrap();
2434 assert_eq!(updated_one.affected_rows, 1);
2435
2436 let updated_many = rt
2440 .execute_query("UPDATE items SET score = 7 WHERE score > 25")
2441 .unwrap();
2442 assert_eq!(updated_many.affected_rows, 2);
2443
2444 let snapshot = rt
2445 .execute_query("SELECT id, score FROM items ORDER BY id")
2446 .unwrap();
2447 let pairs: Vec<(i64, i64)> = snapshot
2448 .result
2449 .records
2450 .iter()
2451 .map(|record| {
2452 let id = match record.get("id").unwrap() {
2453 Value::Integer(value) => *value,
2454 other => panic!("expected integer id, got {other:?}"),
2455 };
2456 let score = match record.get("score").unwrap() {
2457 Value::Integer(value) => *value,
2458 other => panic!("expected integer score, got {other:?}"),
2459 };
2460 (id, score)
2461 })
2462 .collect();
2463 assert_eq!(pairs, vec![(0, 0), (1, 10), (2, 5), (3, 7), (4, 7)]);
2464
2465 let updated_all = rt.execute_query("UPDATE items SET score = 1").unwrap();
2467 assert_eq!(updated_all.affected_rows, 5);
2468 let after = rt
2469 .execute_query("SELECT score FROM items ORDER BY id")
2470 .unwrap();
2471 let scores: Vec<i64> = after
2472 .result
2473 .records
2474 .iter()
2475 .map(|record| match record.get("score").unwrap() {
2476 Value::Integer(value) => *value,
2477 other => panic!("expected integer score, got {other:?}"),
2478 })
2479 .collect();
2480 assert_eq!(scores, vec![1, 1, 1, 1, 1]);
2481 }
2482
2483 #[test]
2489 fn delete_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
2490 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2491 rt.execute_query("CREATE TABLE items (id INT, score INT)")
2492 .unwrap();
2493 for id in 0..5 {
2494 rt.execute_query(&format!(
2495 "INSERT INTO items (id, score) VALUES ({id}, {})",
2496 id * 10
2497 ))
2498 .unwrap();
2499 }
2500 rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
2501 .unwrap();
2502
2503 let deleted_one = rt.execute_query("DELETE FROM items WHERE id = 2").unwrap();
2506 assert_eq!(deleted_one.affected_rows, 1);
2507
2508 let deleted_many = rt
2512 .execute_query("DELETE FROM items WHERE score > 25")
2513 .unwrap();
2514 assert_eq!(deleted_many.affected_rows, 2);
2515
2516 let surviving = rt
2517 .execute_query("SELECT id FROM items ORDER BY id")
2518 .unwrap();
2519 let ids: Vec<i64> = surviving
2520 .result
2521 .records
2522 .iter()
2523 .map(|record| match record.get("id").unwrap() {
2524 Value::Integer(value) => *value,
2525 other => panic!("expected integer id, got {other:?}"),
2526 })
2527 .collect();
2528 assert_eq!(ids, vec![0, 1]);
2529
2530 let deleted_rest = rt.execute_query("DELETE FROM items").unwrap();
2532 assert_eq!(deleted_rest.affected_rows, 2);
2533 let empty = rt.execute_query("SELECT id FROM items").unwrap();
2534 assert!(empty.result.records.is_empty());
2535 }
2536
2537 #[test]
2542 fn collection_contract_gate_blocks_update_and_delete_on_append_only() {
2543 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2544 rt.execute_query("CREATE TABLE events (id INT, payload TEXT) APPEND ONLY")
2545 .unwrap();
2546
2547 let inserted = rt
2550 .execute_query("INSERT INTO events (id, payload) VALUES (1, 'hello')")
2551 .unwrap();
2552 assert_eq!(inserted.affected_rows, 1);
2553
2554 let update_err = rt
2556 .execute_query("UPDATE events SET payload = 'mut' WHERE id = 1")
2557 .unwrap_err();
2558 let msg = format!("{update_err}");
2559 assert!(
2560 msg.contains("APPEND ONLY") && msg.contains("UPDATE is rejected"),
2561 "expected UPDATE rejection message, got: {msg}"
2562 );
2563
2564 let delete_err = rt
2566 .execute_query("DELETE FROM events WHERE id = 1")
2567 .unwrap_err();
2568 let msg = format!("{delete_err}");
2569 assert!(
2570 msg.contains("APPEND ONLY") && msg.contains("DELETE is rejected"),
2571 "expected DELETE rejection message, got: {msg}"
2572 );
2573
2574 let surviving = rt.execute_query("SELECT id FROM events").unwrap();
2577 assert_eq!(surviving.result.records.len(), 1);
2578 }
2579
2580 #[test]
2584 fn collection_contract_gate_allows_all_verbs_on_unrestricted_table() {
2585 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2586 rt.execute_query("CREATE TABLE notes (id INT, body TEXT)")
2587 .unwrap();
2588
2589 rt.execute_query("INSERT INTO notes (id, body) VALUES (1, 'a')")
2590 .unwrap();
2591 let updated = rt
2592 .execute_query("UPDATE notes SET body = 'b' WHERE id = 1")
2593 .unwrap();
2594 assert_eq!(updated.affected_rows, 1);
2595 let deleted = rt.execute_query("DELETE FROM notes WHERE id = 1").unwrap();
2596 assert_eq!(deleted.affected_rows, 1);
2597 }
2598
2599 #[test]
2600 fn insert_into_event_enabled_table_emits_event_to_configured_queue() {
2601 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2602 rt.execute_query(
2603 "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (INSERT) TO audit_log",
2604 )
2605 .unwrap();
2606
2607 let inserted = rt
2608 .execute_query("INSERT INTO users (id, email) VALUES (7, 'a@example.com')")
2609 .unwrap();
2610 assert_eq!(inserted.affected_rows, 1);
2611
2612 let events = queue_payloads(&rt, "audit_log");
2613 assert_eq!(events.len(), 1);
2614 let event = events[0].as_object().expect("event payload object");
2615 assert!(event
2616 .get("event_id")
2617 .and_then(crate::json::Value::as_str)
2618 .is_some_and(|value| !value.is_empty()));
2619 assert_eq!(
2620 event.get("op").and_then(crate::json::Value::as_str),
2621 Some("insert")
2622 );
2623 assert_eq!(
2624 event.get("collection").and_then(crate::json::Value::as_str),
2625 Some("users")
2626 );
2627 assert_eq!(
2628 event.get("id").and_then(crate::json::Value::as_u64),
2629 Some(7)
2630 );
2631 assert!(event
2632 .get("ts")
2633 .and_then(crate::json::Value::as_u64)
2634 .is_some());
2635 assert!(event
2636 .get("lsn")
2637 .and_then(crate::json::Value::as_u64)
2638 .is_some());
2639 assert!(matches!(
2640 event.get("tenant"),
2641 Some(crate::json::Value::Null)
2642 ));
2643 assert!(matches!(
2644 event.get("before"),
2645 Some(crate::json::Value::Null)
2646 ));
2647 let after = event
2648 .get("after")
2649 .and_then(crate::json::Value::as_object)
2650 .expect("after object");
2651 assert_eq!(
2652 after.get("id").and_then(crate::json::Value::as_u64),
2653 Some(7)
2654 );
2655 assert_eq!(
2656 after.get("email").and_then(crate::json::Value::as_str),
2657 Some("a@example.com")
2658 );
2659 }
2660
2661 #[test]
2662 fn multi_row_insert_emits_one_insert_event_per_row_in_order() {
2663 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2664 rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
2665 .unwrap();
2666
2667 rt.execute_query(
2668 "INSERT INTO users (id, email) VALUES (1, 'a@example.com'), (2, 'b@example.com')",
2669 )
2670 .unwrap();
2671
2672 let events = queue_payloads(&rt, "users_events");
2673 assert_eq!(events.len(), 2);
2674 let mut previous_lsn = 0;
2675 for (event, expected_id) in events.iter().zip([1_u64, 2]) {
2676 let object = event.as_object().expect("event payload object");
2677 assert_eq!(
2678 object.get("op").and_then(crate::json::Value::as_str),
2679 Some("insert")
2680 );
2681 assert_eq!(
2682 object.get("id").and_then(crate::json::Value::as_u64),
2683 Some(expected_id)
2684 );
2685 let lsn = object
2686 .get("lsn")
2687 .and_then(crate::json::Value::as_u64)
2688 .expect("event lsn");
2689 assert!(
2690 lsn > previous_lsn,
2691 "event LSNs should increase in row order"
2692 );
2693 previous_lsn = lsn;
2694 let after = object
2695 .get("after")
2696 .and_then(crate::json::Value::as_object)
2697 .expect("after object");
2698 assert_eq!(
2699 after.get("id").and_then(crate::json::Value::as_u64),
2700 Some(expected_id)
2701 );
2702 }
2703 }
2704
2705 #[test]
2706 fn claim_metrics_increment_skipped_locked_without_counting_miss() {
2707 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
2708 rt.execute_query("CREATE TABLE claim_metric_locked (id INT, rank INT, status TEXT)")
2709 .expect("create table");
2710 rt.execute_query("CREATE INDEX idx_claim_metric_locked_rank ON claim_metric_locked (rank)")
2712 .expect("create index");
2713 rt.execute_query(
2714 "INSERT INTO claim_metric_locked (id, rank, status) VALUES \
2715 (1, 10, 'ready'), (2, 20, 'ready')",
2716 )
2717 .expect("insert rows");
2718
2719 let claim_lock = rt
2720 .inner
2721 .rmw_locks
2722 .lock_for("claim_metric_locked", "__table_claim_update__");
2723 let _guard = claim_lock.lock();
2724 let updated = rt
2725 .execute_query(
2726 "UPDATE claim_metric_locked SET status = 'claimed' WHERE status = 'ready' \
2727 CLAIM LIMIT 2 ORDER BY rank ASC",
2728 )
2729 .expect("claim skips locked candidates");
2730
2731 assert_eq!(updated.affected_rows, 0);
2732 let snapshot = rt.claim_telemetry_snapshot();
2733 assert_eq!(
2734 claim_metric_count(&snapshot, "attempts", "claim_metric_locked", "table"),
2735 1
2736 );
2737 assert_eq!(
2738 claim_metric_count(&snapshot, "skipped_locked", "claim_metric_locked", "table"),
2739 2
2740 );
2741 assert_eq!(
2742 claim_metric_count(&snapshot, "misses", "claim_metric_locked", "table"),
2743 0
2744 );
2745 assert_eq!(
2746 snapshot.skipped_locked.len(),
2747 1,
2748 "skipped-lock labels stay bounded to collection/model"
2749 );
2750 }
2751
2752 fn queue_payloads(rt: &RedDBRuntime, queue: &str) -> Vec<crate::json::Value> {
2753 let result = rt
2754 .execute_query(&format!("QUEUE PEEK {queue} 10"))
2755 .expect("peek queue");
2756 result
2757 .result
2758 .records
2759 .iter()
2760 .map(
2761 |record| match record.get("payload").expect("payload column") {
2762 Value::Json(bytes) => crate::json::from_slice(bytes).expect("json payload"),
2763 other => panic!("expected JSON queue payload, got {other:?}"),
2764 },
2765 )
2766 .collect()
2767 }
2768
2769 #[test]
2781 fn auto_index_id_fires_on_first_insert() {
2782 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2783 rt.execute_query("CREATE TABLE bench_users (id INT, score INT)")
2784 .unwrap();
2785
2786 assert!(
2788 rt.index_store_ref()
2789 .find_index_for_column("bench_users", "id")
2790 .is_none(),
2791 "freshly created collection should not have an `id` index"
2792 );
2793
2794 rt.execute_query("INSERT INTO bench_users (id, score) VALUES (1, 10)")
2796 .unwrap();
2797
2798 let registered = rt
2800 .index_store_ref()
2801 .find_index_for_column("bench_users", "id")
2802 .expect("auto-index hook should have registered idx_id on first insert");
2803 assert_eq!(registered.name, "idx_id");
2804 assert_eq!(registered.collection, "bench_users");
2805 assert_eq!(registered.columns, vec!["id".to_string()]);
2806 assert!(matches!(
2807 registered.method,
2808 super::super::index_store::IndexMethodKind::Hash
2809 ));
2810
2811 for id in 2..=5 {
2814 rt.execute_query(&format!(
2815 "INSERT INTO bench_users (id, score) VALUES ({id}, {})",
2816 id * 10
2817 ))
2818 .unwrap();
2819 }
2820 for id in 1..=5 {
2821 let result = rt
2822 .execute_query(&format!("SELECT score FROM bench_users WHERE id = {id}"))
2823 .unwrap();
2824 assert_eq!(
2825 result.result.records.len(),
2826 1,
2827 "id={id} should match one row"
2828 );
2829 }
2830
2831 let deleted = rt
2836 .execute_query("DELETE FROM bench_users WHERE id = 3")
2837 .unwrap();
2838 assert_eq!(deleted.affected_rows, 1);
2839 }
2840
2841 #[test]
2846 fn auto_index_id_fires_on_first_bulk_insert() {
2847 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2848 rt.execute_query("CREATE TABLE bench_bulk (id INT, score INT)")
2849 .unwrap();
2850
2851 rt.execute_query("INSERT INTO bench_bulk (id, score) VALUES (1, 10), (2, 20), (3, 30)")
2852 .unwrap();
2853
2854 let registered = rt
2855 .index_store_ref()
2856 .find_index_for_column("bench_bulk", "id")
2857 .expect("auto-index hook should fire on first bulk insert");
2858 assert_eq!(registered.name, "idx_id");
2859
2860 for id in 1..=3 {
2862 let result = rt
2863 .execute_query(&format!("SELECT score FROM bench_bulk WHERE id = {id}"))
2864 .unwrap();
2865 assert_eq!(result.result.records.len(), 1);
2866 }
2867 }
2868
2869 #[test]
2873 fn auto_index_id_skips_when_no_id_column() {
2874 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2875 rt.execute_query("CREATE TABLE plain (uid INT, label TEXT)")
2876 .unwrap();
2877 rt.execute_query("INSERT INTO plain (uid, label) VALUES (1, 'a')")
2878 .unwrap();
2879
2880 assert!(rt
2881 .index_store_ref()
2882 .find_index_for_column("plain", "id")
2883 .is_none());
2884 assert!(rt
2885 .index_store_ref()
2886 .find_index_for_column("plain", "uid")
2887 .is_none());
2888 }
2889
2890 #[test]
2895 fn auto_index_id_skips_when_index_already_exists() {
2896 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2897 rt.execute_query("CREATE TABLE pre (id INT, score INT)")
2898 .unwrap();
2899 rt.execute_query("CREATE INDEX user_idx ON pre (id) USING BTREE")
2901 .unwrap();
2902 rt.execute_query("INSERT INTO pre (id, score) VALUES (1, 10)")
2903 .unwrap();
2904
2905 let registered = rt
2906 .index_store_ref()
2907 .find_index_for_column("pre", "id")
2908 .expect("user index should still be there");
2909 assert_eq!(
2910 registered.name, "user_idx",
2911 "auto-index hook must not overwrite an existing index"
2912 );
2913 }
2914
2915 #[test]
2919 fn auto_index_id_dropped_with_collection() {
2920 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2921 rt.execute_query("CREATE TABLE ephemeral (id INT, score INT)")
2922 .unwrap();
2923 rt.execute_query("INSERT INTO ephemeral (id, score) VALUES (1, 10)")
2924 .unwrap();
2925 assert!(rt
2926 .index_store_ref()
2927 .find_index_for_column("ephemeral", "id")
2928 .is_some());
2929
2930 rt.execute_query("DROP TABLE ephemeral").unwrap();
2931
2932 assert!(
2933 rt.index_store_ref()
2934 .find_index_for_column("ephemeral", "id")
2935 .is_none(),
2936 "implicit `idx_id` must be reaped when its collection drops"
2937 );
2938 }
2939
2940 #[test]
2945 fn auto_index_id_disabled_by_config() {
2946 let opts = RedDBOptions::in_memory().with_auto_index_id(false);
2947 let rt = RedDBRuntime::with_options(opts).unwrap();
2948
2949 rt.execute_query("CREATE TABLE off (id INT, score INT)")
2950 .unwrap();
2951 rt.execute_query("INSERT INTO off (id, score) VALUES (1, 10)")
2952 .unwrap();
2953
2954 assert!(
2955 rt.index_store_ref()
2956 .find_index_for_column("off", "id")
2957 .is_none(),
2958 "with auto_index_id=false, no implicit index should be created"
2959 );
2960 }
2961
2962 #[test]
2965 fn update_single_row_emits_update_event() {
2966 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
2967 rt.execute_query(
2968 "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO audit_log",
2969 )
2970 .unwrap();
2971 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
2972 .unwrap();
2973
2974 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
2975 .unwrap();
2976
2977 let events = queue_payloads(&rt, "audit_log");
2978 assert_eq!(events.len(), 1, "expected exactly 1 update event");
2979 let event = events[0].as_object().expect("event payload object");
2980 assert_eq!(
2981 event.get("op").and_then(crate::json::Value::as_str),
2982 Some("update")
2983 );
2984 assert_eq!(
2985 event.get("collection").and_then(crate::json::Value::as_str),
2986 Some("users")
2987 );
2988 assert!(event
2989 .get("event_id")
2990 .and_then(crate::json::Value::as_str)
2991 .is_some_and(|v| !v.is_empty()));
2992 let before = event
2993 .get("before")
2994 .and_then(crate::json::Value::as_object)
2995 .expect("before must be an object");
2996 let after = event
2997 .get("after")
2998 .and_then(crate::json::Value::as_object)
2999 .expect("after must be an object");
3000 assert_eq!(
3001 before.get("name").and_then(crate::json::Value::as_str),
3002 Some("Alice"),
3003 "before.name should be the old value"
3004 );
3005 assert_eq!(
3006 after.get("name").and_then(crate::json::Value::as_str),
3007 Some("Bob"),
3008 "after.name should be the new value"
3009 );
3010 }
3011
3012 #[test]
3013 fn update_event_only_includes_changed_fields() {
3014 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3015 rt.execute_query(
3016 "CREATE TABLE users (id INT, name TEXT, email TEXT) WITH EVENTS (UPDATE) TO evts",
3017 )
3018 .unwrap();
3019 rt.execute_query("INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'a@x.com')")
3020 .unwrap();
3021
3022 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
3023 .unwrap();
3024
3025 let events = queue_payloads(&rt, "evts");
3026 assert_eq!(events.len(), 1);
3027 let event = events[0].as_object().unwrap();
3028 let before = event
3029 .get("before")
3030 .and_then(crate::json::Value::as_object)
3031 .unwrap();
3032 let after = event
3033 .get("after")
3034 .and_then(crate::json::Value::as_object)
3035 .unwrap();
3036 assert!(
3038 before.contains_key("name"),
3039 "before must include changed field"
3040 );
3041 assert!(
3042 after.contains_key("name"),
3043 "after must include changed field"
3044 );
3045 assert!(
3047 !before.contains_key("email"),
3048 "before must not include unchanged email"
3049 );
3050 assert!(
3051 !after.contains_key("email"),
3052 "after must not include unchanged email"
3053 );
3054 }
3055
3056 #[test]
3057 fn multi_row_update_emits_one_event_per_row() {
3058 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3059 rt.execute_query("CREATE TABLE items (id INT, status TEXT) WITH EVENTS (UPDATE) TO evts")
3060 .unwrap();
3061 rt.execute_query(
3062 "INSERT INTO items (id, status) VALUES (1, 'new'), (2, 'new'), (3, 'new')",
3063 )
3064 .unwrap();
3065
3066 rt.execute_query("UPDATE items SET status = 'done'")
3067 .unwrap();
3068
3069 let events = queue_payloads(&rt, "evts");
3070 assert_eq!(events.len(), 3, "expected one update event per row");
3071 for event in &events {
3072 let obj = event.as_object().unwrap();
3073 assert_eq!(
3074 obj.get("op").and_then(crate::json::Value::as_str),
3075 Some("update")
3076 );
3077 }
3078 }
3079
3080 #[test]
3081 fn delete_single_row_emits_delete_event() {
3082 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3083 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (DELETE) TO del_log")
3084 .unwrap();
3085 rt.execute_query("INSERT INTO users (id, name) VALUES (42, 'Alice')")
3086 .unwrap();
3087
3088 rt.execute_query("DELETE FROM users WHERE id = 42").unwrap();
3089
3090 let events = queue_payloads(&rt, "del_log");
3091 assert_eq!(events.len(), 1);
3092 let event = events[0].as_object().expect("event payload object");
3093 assert_eq!(
3094 event.get("op").and_then(crate::json::Value::as_str),
3095 Some("delete")
3096 );
3097 assert_eq!(
3098 event.get("collection").and_then(crate::json::Value::as_str),
3099 Some("users")
3100 );
3101 assert!(event
3102 .get("event_id")
3103 .and_then(crate::json::Value::as_str)
3104 .is_some_and(|v| !v.is_empty()));
3105 let before = event
3106 .get("before")
3107 .and_then(crate::json::Value::as_object)
3108 .expect("before must be an object for delete");
3109 assert_eq!(
3110 before.get("id").and_then(crate::json::Value::as_u64),
3111 Some(42)
3112 );
3113 assert_eq!(
3114 before.get("name").and_then(crate::json::Value::as_str),
3115 Some("Alice")
3116 );
3117 assert!(matches!(event.get("after"), Some(crate::json::Value::Null)));
3118 }
3119
3120 #[test]
3121 fn multi_row_delete_emits_one_event_per_row() {
3122 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3123 rt.execute_query("CREATE TABLE items (id INT, val INT) WITH EVENTS (DELETE) TO del_log")
3124 .unwrap();
3125 rt.execute_query("INSERT INTO items (id, val) VALUES (1, 10), (2, 20), (3, 30)")
3126 .unwrap();
3127
3128 rt.execute_query("DELETE FROM items").unwrap();
3129
3130 let events = queue_payloads(&rt, "del_log");
3131 assert_eq!(events.len(), 3, "expected one delete event per deleted row");
3132 for event in &events {
3133 let obj = event.as_object().unwrap();
3134 assert_eq!(
3135 obj.get("op").and_then(crate::json::Value::as_str),
3136 Some("delete")
3137 );
3138 assert!(matches!(obj.get("after"), Some(crate::json::Value::Null)));
3139 }
3140 }
3141
3142 #[test]
3143 fn ops_filter_update_does_not_emit_on_insert_or_delete() {
3144 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3145 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO evts")
3146 .unwrap();
3147
3148 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
3149 .unwrap();
3150 rt.execute_query("DELETE FROM users WHERE id = 1").unwrap();
3151
3152 let events = queue_payloads(&rt, "evts");
3153 assert!(
3154 events.is_empty(),
3155 "UPDATE-only filter must not emit INSERT or DELETE events"
3156 );
3157 }
3158
3159 #[test]
3162 fn suppress_events_on_insert_emits_no_events() {
3163 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3164 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
3165 .unwrap();
3166
3167 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
3168 .unwrap();
3169
3170 let events = queue_payloads(&rt, "evts");
3171 assert!(
3172 events.is_empty(),
3173 "SUPPRESS EVENTS must prevent INSERT events"
3174 );
3175 }
3176
3177 #[test]
3178 fn suppress_events_on_update_emits_no_events() {
3179 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3180 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
3181 .unwrap();
3182 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
3183 .unwrap();
3184 let _ = queue_payloads(&rt, "evts");
3186 rt.execute_query("QUEUE PURGE evts").unwrap();
3188
3189 rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1 SUPPRESS EVENTS")
3190 .unwrap();
3191
3192 let events = queue_payloads(&rt, "evts");
3193 assert!(
3194 events.is_empty(),
3195 "SUPPRESS EVENTS must prevent UPDATE events"
3196 );
3197 }
3198
3199 #[test]
3200 fn suppress_events_on_delete_emits_no_events() {
3201 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3202 rt.execute_query(
3203 "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (INSERT, DELETE) TO evts",
3204 )
3205 .unwrap();
3206 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
3207 .unwrap();
3208
3209 rt.execute_query("DELETE FROM users WHERE id = 1 SUPPRESS EVENTS")
3210 .unwrap();
3211
3212 let events = queue_payloads(&rt, "evts");
3213 assert!(
3214 events.is_empty(),
3215 "SUPPRESS EVENTS must prevent DELETE events"
3216 );
3217 }
3218
3219 #[test]
3220 fn normal_insert_after_suppress_still_emits() {
3221 let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
3222 rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
3223 .unwrap();
3224
3225 rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
3226 .unwrap();
3227 rt.execute_query("INSERT INTO users (id, name) VALUES (2, 'Bob')")
3228 .unwrap();
3229
3230 let events = queue_payloads(&rt, "evts");
3231 assert_eq!(
3232 events.len(),
3233 1,
3234 "only the non-suppressed INSERT should emit"
3235 );
3236 assert_eq!(
3237 events[0].get("id").and_then(crate::json::Value::as_u64),
3238 Some(2)
3239 );
3240 }
3241}