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