1#[cfg(feature = "full")]
4use crate::catalog::codec::deserialize_collection_contracts;
5use crate::catalog::enforcement::{
6 INTERNAL_EXPIRES_AT_KEY, INTERNAL_TTL_MILLIS_KEY, INTERNAL_TTL_SECONDS_KEY,
7};
8use crate::catalog::{CollectionContract, CollectionModel};
9use crate::storage::{entity::EntityData, error::StoreError};
10#[cfg(feature = "full")]
11use crate::storage::{manager::ManagerConfig, resident_file::EmbeddedResidentStore};
12#[cfg(feature = "full")]
13use reddb_file::VfsFile;
14use std::collections::HashMap;
15#[cfg(feature = "full")]
16use std::path::PathBuf;
17
18use super::{
19 evaluator::EvalError,
20 planning::build_table_plan_with_host,
21 runtime::{
22 projection::projected_columns,
23 table_plan::{execute_table_plan_with_host, visit_table_plan_with_host},
24 },
25 scalar::ScalarResult,
26};
27
28pub(crate) mod admission;
29pub(crate) mod host;
30pub(crate) mod source;
31
32use source::StoreView;
33
34pub const LOCAL_READ_ROWS_MAX: usize = 100_000;
36
37pub struct ReadScope {
40 pub table: String,
41 pub field: String,
42 pub value: reddb_types::Value,
43}
44
45#[derive(Debug, PartialEq, Eq)]
46pub struct StreamQueryResult {
47 pub columns: Vec<String>,
48 pub rows: u64,
49 pub complete: bool,
50}
51
52#[derive(Debug)]
53pub enum LocalReadError {
54 Storage(StoreError),
55 Catalog(String),
56 Parse(reddb_rql::ParseError),
57 Unsupported(&'static str),
58 Evaluation(EvalError),
59 Query(String),
60 Parameters(super::parameters::UserParamError),
61 ResourceLimit,
62 Vault(&'static str),
63}
64
65impl LocalReadError {
66 pub fn code(&self) -> &'static str {
67 match self {
68 Self::Vault(code) => code,
69 Self::Storage(_) => "DATABASE_ERROR",
70 Self::Catalog(_) => "CATALOG_ERROR",
71 Self::Parse(_) => "QUERY_PARSE_ERROR",
72 Self::Unsupported(_) => "QUERY_UNSUPPORTED",
73 Self::Evaluation(_) | Self::Query(_) => "QUERY_EVALUATION_ERROR",
74 Self::ResourceLimit => "QUERY_RESOURCE_LIMIT",
75 Self::Parameters(_) => "INVALID_PARAMS",
76 }
77 }
78}
79
80impl std::fmt::Display for LocalReadError {
81 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 match self {
83 Self::Vault(code) => formatter.write_str(code),
84 Self::Storage(error) => error.fmt(formatter),
85 Self::Catalog(message) | Self::Query(message) => formatter.write_str(message),
86 Self::Parse(error) => error.fmt(formatter),
87 Self::Unsupported(message) => formatter.write_str(message),
88 Self::Evaluation(error) => error.fmt(formatter),
89 Self::Parameters(error) => error.fmt(formatter),
90 Self::ResourceLimit => {
91 formatter.write_str("local SELECT materialization exceeds 100000 rows")
92 }
93 }
94 }
95}
96impl std::error::Error for LocalReadError {}
97impl From<StoreError> for LocalReadError {
98 fn from(error: StoreError) -> Self {
99 Self::Storage(error)
100 }
101}
102impl From<EvalError> for LocalReadError {
103 fn from(error: EvalError) -> Self {
104 Self::Evaluation(error)
105 }
106}
107
108#[cfg(feature = "full")]
111pub struct LocalReadDatabase<F: VfsFile> {
112 store: EmbeddedResidentStore<F>,
113 contracts: HashMap<String, CollectionContract>,
114}
115
116#[cfg(feature = "full")]
117impl<F: VfsFile> LocalReadDatabase<F> {
118 pub fn open(file: F, name: impl Into<PathBuf>) -> Result<Self, LocalReadError> {
119 let store = EmbeddedResidentStore::open(file, name, ManagerConfig::default(), false, 0)?;
120 let metadata = store.committed_resident()?.aux_metadata();
121 let mut contracts = HashMap::new();
122 if !metadata.is_empty() {
123 for contract in deserialize_collection_contracts(&metadata)
124 .map_err(|error| LocalReadError::Catalog(error.to_string()))?
125 {
126 let name = contract.name.clone();
127 if contracts.insert(name.clone(), contract).is_some() {
128 return Err(LocalReadError::Catalog(format!(
129 "duplicate collection contract: {name}"
130 )));
131 }
132 }
133 }
134 Ok(Self { store, contracts })
135 }
136
137 pub fn query(&self, sql: &str) -> Result<ScalarResult, LocalReadError> {
138 query_resident(self.store.committed_resident()?, &self.contracts, sql)
139 }
140}
141
142#[cfg(feature = "full")]
143pub(crate) fn query_resident(
144 resident: &dyn StoreView,
145 contracts: &HashMap<String, CollectionContract>,
146 sql: &str,
147) -> Result<ScalarResult, LocalReadError> {
148 let query = admission::query(sql)?;
149 execute_resident_query(resident, contracts, query, None)
150}
151
152pub(crate) fn query_resident_bound(
153 resident: &dyn StoreView,
154 contracts: &HashMap<String, CollectionContract>,
155 query: reddb_rql::ast::TableQuery,
156) -> Result<ScalarResult, LocalReadError> {
157 execute_resident_query(resident, contracts, admission::table(query)?, None)
158}
159
160#[cfg(feature = "full")]
161pub(crate) fn query_resident_scoped(
162 resident: &dyn StoreView,
163 contracts: &HashMap<String, CollectionContract>,
164 query: reddb_rql::ast::TableQuery,
165 scope: &ReadScope,
166) -> Result<ScalarResult, LocalReadError> {
167 execute_resident_query(resident, contracts, admission::table(query)?, Some(scope))
168}
169
170pub(crate) fn query_store_stream(
171 store: &dyn StoreView,
172 contracts: &HashMap<String, CollectionContract>,
173 query: reddb_rql::ast::TableQuery,
174 visit: &mut dyn FnMut(&[reddb_types::Value]) -> Result<bool, LocalReadError>,
175) -> Result<StreamQueryResult, LocalReadError> {
176 let query = admission::table(query)?;
177 let contract = contracts.get(&query.table).ok_or_else(|| {
178 LocalReadError::Catalog(format!(
179 "no persisted collection contract for {}",
180 query.table
181 ))
182 })?;
183 if contract.declared_model != CollectionModel::Table
184 || contract.default_ttl_ms.is_some()
185 || contract.retention_duration_ms.is_some()
186 {
187 return Err(LocalReadError::Unsupported(
188 "local SELECT currently requires a table without TTL or retention",
189 ));
190 }
191 let columns = crate::catalog::schema::resolved_contract_columns(contract)
192 .map_err(|error| LocalReadError::Catalog(error.to_string()))?;
193 admission::expressions(&query, &columns)?;
194 let schema = std::sync::Arc::new(columns.iter().map(|column| column.name.clone()).collect());
195 let source = source::StoreRows {
196 store,
197 table: &query.table,
198 schema,
199 allow_materialization: false,
200 };
201 let projections = reddb_rql::sql_lowering::effective_table_projections(&query);
202 if projections
203 .iter()
204 .any(|projection| matches!(projection, reddb_rql::ast::Projection::All))
205 {
206 return Err(LocalReadError::Unsupported(
207 "streaming SELECT requires explicit output columns",
208 ));
209 }
210 let names = projected_columns(&[], &projections);
211 let host = host::ReadHost::with_source(&query, &columns, &projections, &source);
212 let plan = build_table_plan_with_host(&host, &query);
213 let mut rows = 0u64;
214 let mut complete = true;
215 visit_table_plan_with_host(&host, &plan, &query, &mut |record| {
216 let row = names
217 .iter()
218 .map(|name| {
219 record
220 .get(name)
221 .cloned()
222 .unwrap_or(reddb_types::Value::Null)
223 })
224 .collect::<Vec<_>>();
225 rows = rows.checked_add(1).ok_or(LocalReadError::ResourceLimit)?;
226 if !visit(&row)? {
227 complete = false;
228 return Ok(false);
229 }
230 Ok(true)
231 })?;
232 Ok(StreamQueryResult {
233 columns: names,
234 rows,
235 complete,
236 })
237}
238
239fn execute_resident_query(
240 resident: &dyn StoreView,
241 contracts: &HashMap<String, CollectionContract>,
242 query: reddb_rql::ast::TableQuery,
243 scope: Option<&ReadScope>,
244) -> Result<ScalarResult, LocalReadError> {
245 let contract = contracts.get(&query.table).ok_or_else(|| {
246 LocalReadError::Catalog(format!(
247 "no persisted collection contract for {}",
248 query.table
249 ))
250 })?;
251 if contract.declared_model != CollectionModel::Table
252 || contract.default_ttl_ms.is_some()
253 || contract.retention_duration_ms.is_some()
254 {
255 return Err(LocalReadError::Unsupported(
256 "local SELECT currently requires a table without TTL or retention",
257 ));
258 }
259 let columns = crate::catalog::schema::resolved_contract_columns(contract)
260 .map_err(|error| LocalReadError::Catalog(error.to_string()))?;
261 admission::expressions(&query, &columns)?;
262 if scope.is_some_and(|scope| !columns.iter().any(|column| column.name == scope.field)) {
263 return Err(LocalReadError::Catalog(
264 "inspection scope column is missing".into(),
265 ));
266 }
267 let schema = std::sync::Arc::new(columns.iter().map(|column| column.name.clone()).collect());
268 let mut entities = Vec::new();
269 let mut exceeded = false;
270 resident.scan_collection(&query.table, &mut |entity, metadata| {
271 if let Some(scope) = scope {
272 let Some(record) = super::records::runtime_table_record_from_entity_ref_with_schema(
273 entity,
274 Some(&schema),
275 ) else {
276 return true;
277 };
278 if record.get(&scope.field) != Some(&scope.value) {
279 return true;
280 }
281 }
282 if entities.len() == LOCAL_READ_ROWS_MAX {
283 exceeded = true;
284 return false;
285 }
286 let has_ttl = metadata.is_some_and(|metadata| {
287 metadata.has(INTERNAL_TTL_SECONDS_KEY)
288 || metadata.has(INTERNAL_TTL_MILLIS_KEY)
289 || metadata.has(INTERNAL_EXPIRES_AT_KEY)
290 });
291 entities.push((entity.clone(), has_ttl));
292 true
293 })?;
294 if exceeded {
295 return Err(LocalReadError::ResourceLimit);
296 }
297 let mut records = Vec::with_capacity(entities.len());
298 for (entity, has_ttl) in entities {
299 if !matches!(entity.data, EntityData::Row(_)) {
300 return Err(LocalReadError::Unsupported(
301 "local table contains non-row entities",
302 ));
303 }
304 if has_ttl {
305 return Err(LocalReadError::Unsupported(
306 "local SELECT does not yet admit row TTL metadata",
307 ));
308 }
309 if let Some(record) =
310 super::records::runtime_table_record_from_entity_ref_with_schema(&entity, Some(&schema))
311 {
312 records.push(record);
313 }
314 }
315 let projections = reddb_rql::sql_lowering::effective_table_projections(&query);
316 let host = host::ReadHost::new(&query, &columns, &projections, records);
317 let plan = build_table_plan_with_host(&host, &query);
318 let records = execute_table_plan_with_host(&host, &plan, &query)?;
319 let columns = projected_columns(&records, &projections);
320 let rows = records
321 .iter()
322 .map(|record| {
323 columns
324 .iter()
325 .map(|column| {
326 record
327 .get(column)
328 .cloned()
329 .unwrap_or(reddb_types::Value::Null)
330 })
331 .collect()
332 })
333 .collect();
334 Ok(ScalarResult { columns, rows })
335}