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