#[cfg(feature = "openapi")]
mod openapi;
use std::collections::HashMap;
use std::future::Future;
#[cfg(feature = "serializer")]
use std::marker::PhantomData;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::{header, StatusCode};
use axum::response::IntoResponse as _;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::{json, Value};
use crate::core::{
Assignment, CountQuery, DeleteQuery, FieldType, Filter, InsertQuery, ModelSchema, Op,
SearchClause, SelectQuery, SqlValue, UpdateQuery, WhereExpr,
};
use crate::forms::{
absent_takes_default, collect_insert_values, parse_form_value, parse_pk_string, Encoding,
FormError,
};
use crate::sql::Pool;
#[derive(Clone, Default)]
pub struct ViewSetPerms {
pub list: Vec<String>,
pub retrieve: Vec<String>,
pub create: Vec<String>,
pub update: Vec<String>,
pub destroy: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ThrottleRule {
pub max: u32,
pub window_secs: u64,
}
impl ThrottleRule {
#[must_use]
pub const fn new(max: u32, window_secs: u64) -> Self {
Self { max, window_secs }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ViewSetThrottle {
pub list: Option<ThrottleRule>,
pub retrieve: Option<ThrottleRule>,
pub create: Option<ThrottleRule>,
pub update: Option<ThrottleRule>,
pub destroy: Option<ThrottleRule>,
}
impl ViewSetThrottle {
#[must_use]
pub const fn all(max: u32, window_secs: u64) -> Self {
let r = Some(ThrottleRule::new(max, window_secs));
Self {
list: r,
retrieve: r,
create: r,
update: r,
destroy: r,
}
}
#[must_use]
pub fn for_action(&self, action: &str) -> Option<ThrottleRule> {
match action {
"list" => self.list,
"retrieve" => self.retrieve,
"create" => self.create,
"update" => self.update,
"destroy" => self.destroy,
_ => None,
}
}
}
#[derive(Clone, Debug)]
pub enum PaginationStyle {
PageNumber,
Cursor {
field: &'static str,
desc: bool,
},
LimitOffset,
}
impl PaginationStyle {
#[must_use]
pub const fn page_number() -> Self {
Self::PageNumber
}
#[must_use]
pub const fn cursor(field: &'static str) -> Self {
Self::Cursor { field, desc: false }
}
#[must_use]
pub const fn cursor_desc(field: &'static str) -> Self {
Self::Cursor { field, desc: true }
}
#[must_use]
pub const fn limit_offset() -> Self {
Self::LimitOffset
}
}
type PatchCheck<'a> = Pin<
Box<
dyn Future<Output = Result<Result<(), crate::forms::FormErrors>, crate::sql::ExecError>>
+ Send
+ 'a,
>,
>;
trait SerializerBridge: Send + Sync {
fn render_rows<'a>(
&'a self,
acq: &'a mut AcquiredConn,
q: &'a SelectQuery,
) -> Pin<Box<dyn Future<Output = Result<Vec<Value>, crate::sql::ExecError>> + Send + 'a>>;
fn render_one<'a>(
&'a self,
acq: &'a mut AcquiredConn,
q: &'a SelectQuery,
) -> Pin<Box<dyn Future<Output = Result<Option<Value>, crate::sql::ExecError>> + Send + 'a>>;
fn validate_body(&self, body: &Value) -> Result<(), crate::forms::FormErrors>;
fn validate_patch<'a>(
&'a self,
tx: &'a mut crate::sql::PoolTx<'static>,
q: &'a SelectQuery,
body: &'a Value,
) -> PatchCheck<'a>;
fn writable_model_fields(&self) -> &'static [&'static str];
fn writable_field_names(&self) -> &'static [&'static str];
fn readable_model_fields(&self) -> &'static [&'static str];
}
#[cfg(feature = "serializer")]
struct Bridge<S>(PhantomData<S>);
#[cfg(feature = "serializer")]
impl<S> SerializerBridge for Bridge<S>
where
S: crate::serializer::ModelSerializer + Send + Sync + 'static,
S::Model: crate::sql::MaybePgFromRow
+ crate::sql::MaybeMyFromRow
+ crate::sql::MaybeSqliteFromRow
+ crate::sql::LoadRelated
+ crate::sql::MaybeMyLoadRelated
+ crate::sql::MaybeSqliteLoadRelated
+ Send
+ Unpin,
{
fn render_rows<'a>(
&'a self,
acq: &'a mut AcquiredConn,
q: &'a SelectQuery,
) -> Pin<Box<dyn Future<Output = Result<Vec<Value>, crate::sql::ExecError>> + Send + 'a>> {
Box::pin(async move {
let models = acq.select_rows_typed::<S::Model>(q).await?;
Ok(models.iter().map(|m| S::from_model(m).to_value()).collect())
})
}
fn render_one<'a>(
&'a self,
acq: &'a mut AcquiredConn,
q: &'a SelectQuery,
) -> Pin<Box<dyn Future<Output = Result<Option<Value>, crate::sql::ExecError>> + Send + 'a>>
{
Box::pin(async move {
let models = acq.select_rows_typed::<S::Model>(q).await?;
Ok(models.first().map(|m| S::from_model(m).to_value()))
})
}
fn validate_body(&self, body: &Value) -> Result<(), crate::forms::FormErrors> {
let s = S::from_writable_json(body)?;
s.validate()
}
fn validate_patch<'a>(
&'a self,
tx: &'a mut crate::sql::PoolTx<'static>,
q: &'a SelectQuery,
body: &'a Value,
) -> PatchCheck<'a> {
Box::pin(async move {
let models = crate::sql::select_rows_tx_with_related::<S::Model>(tx, q).await?;
Ok(models
.first()
.map_or(Ok(()), |m| S::validate_patch(m, body)))
})
}
fn writable_model_fields(&self) -> &'static [&'static str] {
S::writable_source_fields()
}
fn writable_field_names(&self) -> &'static [&'static str] {
S::writable_fields()
}
fn readable_model_fields(&self) -> &'static [&'static str] {
S::readable_source_fields()
}
}
pub trait ViewSetFilter: Send + Sync + 'static {
fn filter(
&self,
params: &HashMap<String, String>,
schema: &'static ModelSchema,
) -> Vec<WhereExpr>;
fn filter_with(
&self,
_parts: &axum::http::request::Parts,
params: &HashMap<String, String>,
schema: &'static ModelSchema,
) -> Vec<WhereExpr> {
self.filter(params, schema)
}
fn write_pins(
&self,
_parts: &axum::http::request::Parts,
_schema: &'static ModelSchema,
) -> Vec<WritePin> {
Vec::new()
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum WritePin {
Field {
field: &'static str,
value: SqlValue,
},
Deny,
}
impl WritePin {
#[must_use]
pub fn field(field: &'static str, value: impl Into<SqlValue>) -> Self {
Self::Field {
field,
value: value.into(),
}
}
}
#[cfg(feature = "tenancy")]
#[derive(Clone, Copy, Debug)]
pub struct OwnedBy {
column: &'static str,
superuser_sees_all: bool,
}
#[cfg(feature = "tenancy")]
impl OwnedBy {
#[must_use]
pub const fn column(column: &'static str) -> Self {
Self {
column,
superuser_sees_all: false,
}
}
#[must_use]
pub const fn superuser_sees_all(self) -> Self {
Self {
superuser_sees_all: true,
..self
}
}
}
#[cfg(feature = "tenancy")]
impl ViewSetFilter for OwnedBy {
fn filter(
&self,
_params: &HashMap<String, String>,
schema: &'static ModelSchema,
) -> Vec<WhereExpr> {
vec![match_nothing(schema)]
}
fn filter_with(
&self,
parts: &axum::http::request::Parts,
_params: &HashMap<String, String>,
schema: &'static ModelSchema,
) -> Vec<WhereExpr> {
let Some(principal) = crate::tenancy::Principal::from_parts(parts) else {
return vec![match_nothing(schema)];
};
if self.superuser_sees_all && principal.is_superuser {
return Vec::new();
}
let Some(field) = schema.field(self.column) else {
tracing::error!(
model = schema.table,
column = self.column,
"OwnedBy names a column this model does not have — denying every row"
);
return vec![match_nothing(schema)];
};
vec![WhereExpr::Predicate(Filter {
column: field.column,
op: Op::Eq,
value: SqlValue::from(principal.user_id),
})]
}
fn write_pins(
&self,
parts: &axum::http::request::Parts,
_schema: &'static ModelSchema,
) -> Vec<WritePin> {
let Some(principal) = crate::tenancy::Principal::from_parts(parts) else {
return vec![WritePin::Deny];
};
if self.superuser_sees_all && principal.is_superuser {
return Vec::new();
}
vec![WritePin::field(self.column, principal.user_id)]
}
}
pub fn match_nothing(schema: &'static ModelSchema) -> WhereExpr {
let column = schema
.primary_key()
.or_else(|| schema.scalar_fields().next())
.map_or("id", |f| f.column);
WhereExpr::And(vec![
WhereExpr::Predicate(Filter {
column,
op: Op::IsNull,
value: SqlValue::Bool(true),
}),
WhereExpr::Predicate(Filter {
column,
op: Op::IsNull,
value: SqlValue::Bool(false),
}),
])
}
impl<F> ViewSetFilter for F
where
F: Fn(&HashMap<String, String>, &'static ModelSchema) -> Vec<WhereExpr> + Send + Sync + 'static,
{
fn filter(
&self,
params: &HashMap<String, String>,
schema: &'static ModelSchema,
) -> Vec<WhereExpr> {
self(params, schema)
}
}
#[derive(Clone)]
pub struct ViewSet {
schema: &'static ModelSchema,
fields: Option<Vec<String>>,
filter_fields: Vec<String>,
search_fields: Vec<String>,
ordering_fields: Option<Vec<String>>,
default_page_size: usize,
default_ordering: Vec<(String, bool)>,
perms: ViewSetPerms,
read_only: bool,
allow_anonymous: bool,
openapi_query: bool,
pagination: PaginationStyle,
filter_backends: Vec<std::sync::Arc<dyn ViewSetFilter>>,
throttle: ViewSetThrottle,
serializer: Option<Arc<dyn SerializerBridge>>,
max_page_size: usize,
pk_param: String,
max_bulk_create: usize,
}
impl ViewSet {
pub fn for_model(schema: &'static ModelSchema) -> Self {
Self {
schema,
fields: None,
filter_fields: Vec::new(),
search_fields: Vec::new(),
ordering_fields: None,
default_page_size: 20,
default_ordering: Vec::new(),
perms: ViewSetPerms::default(),
read_only: false,
allow_anonymous: false,
openapi_query: false,
pagination: PaginationStyle::PageNumber,
filter_backends: Vec::new(),
throttle: ViewSetThrottle::default(),
serializer: None,
max_page_size: 100,
pk_param: "pk".to_owned(),
max_bulk_create: 1000,
}
}
#[cfg(feature = "serializer")]
#[must_use]
pub fn serializer<S>(mut self) -> Self
where
S: crate::serializer::ModelSerializer + Send + Sync + 'static,
S::Model: crate::sql::MaybePgFromRow
+ crate::sql::MaybeMyFromRow
+ crate::sql::MaybeSqliteFromRow
+ crate::sql::LoadRelated
+ crate::sql::MaybeMyLoadRelated
+ crate::sql::MaybeSqliteLoadRelated
+ Send
+ Unpin,
{
self.serializer = Some(Arc::new(Bridge::<S>(PhantomData)));
self
}
#[must_use]
pub fn cursor_pagination(mut self, field: &'static str) -> Self {
self.assert_cursor_field(field);
self.assert_cursor_is_projected(field);
self.pagination = PaginationStyle::Cursor { field, desc: false };
self
}
#[must_use]
pub fn cursor_pagination_desc(mut self, field: &'static str) -> Self {
self.assert_cursor_field(field);
self.assert_cursor_is_projected(field);
self.pagination = PaginationStyle::Cursor { field, desc: true };
self
}
fn assert_cursor_field(&self, field: &'static str) {
let table = self.schema.table;
let Some(f) = self.schema.field(field) else {
let known: Vec<&str> = self
.schema
.fields
.iter()
.filter(|f| cursor_field_supported(f.ty) && !f.nullable)
.map(|f| f.name)
.collect();
panic!(
"cursor_pagination(\"{field}\"): `{table}` has no field `{field}`. \
Usable cursor fields on this model: {known:?}"
);
};
assert!(
cursor_field_supported(f.ty),
"cursor_pagination(\"{field}\"): `{table}.{field}` is {:?}, which cannot \
be a cursor — the value has to round-trip through a token and order \
totally. Use an integer, timestamp, date, uuid or string column.",
f.ty
);
if f.nullable {
tracing::error!(
target: "rustango::viewset",
"cursor_pagination(\"{field}\"): `{table}.{field}` is nullable; NULL rows \
are skipped or fail the page. Use a NOT NULL column; 0.61.0 refuses this."
);
}
}
fn assert_cursor_is_projected(&self, field: &str) {
let Some(projection) = self.fields.as_ref() else {
return; };
let table = self.schema.table;
assert!(
projection.iter().any(|f| f == field),
"cursor_pagination(\"{field}\") with .fields({projection:?}): the cursor \
column is projected away, so no `next` token can be built from a rendered \
row and pagination would stop after one page. Add `{field}` to the field \
list, or paginate on a field that is in it."
);
if let Some(pk) = self.schema.fields.iter().find(|f| f.primary_key) {
assert!(
pk.name == field || projection.iter().any(|f| f == pk.name),
"cursor_pagination(\"{field}\") with .fields({projection:?}): the primary \
key `{table}.{}` is projected away. It breaks ties between rows sharing \
a `{field}`, and without it a page boundary inside a run of equal values \
skips the rest of the run. Add `{}` to the field list.",
pk.name,
pk.name
);
}
}
#[must_use]
pub fn limit_offset_pagination(mut self) -> Self {
self.pagination = PaginationStyle::LimitOffset;
self
}
#[must_use]
pub fn pagination(mut self, style: PaginationStyle) -> Self {
self.pagination = style;
self
}
#[must_use]
pub fn filter_backend(mut self, backend: impl ViewSetFilter) -> Self {
self.filter_backends.push(std::sync::Arc::new(backend));
self
}
#[must_use]
pub fn throttle(mut self, throttle: ViewSetThrottle) -> Self {
self.throttle = throttle;
self
}
#[must_use]
pub fn throttle_all(mut self, max: u32, window_secs: u64) -> Self {
self.throttle = ViewSetThrottle::all(max, window_secs);
self
}
pub fn fields(mut self, fields: &[&str]) -> Self {
self.fields = Some(fields.iter().map(|&s| s.to_owned()).collect());
if let PaginationStyle::Cursor { field, .. } = self.pagination {
self.assert_cursor_is_projected(field);
}
self
}
pub fn filter_fields(mut self, fields: &[&str]) -> Self {
self.filter_fields = fields.iter().map(|&s| s.to_owned()).collect();
self
}
pub fn search_fields(mut self, fields: &[&str]) -> Self {
self.search_fields = fields.iter().map(|&s| s.to_owned()).collect();
self
}
pub fn ordering_fields(mut self, fields: &[&str]) -> Self {
self.ordering_fields = Some(fields.iter().map(|&s| s.to_owned()).collect());
self
}
pub fn page_size(mut self, n: usize) -> Self {
self.default_page_size = n.max(1);
self
}
#[must_use]
pub fn max_page_size(mut self, n: usize) -> Self {
self.max_page_size = n.max(1);
self
}
#[must_use]
pub fn max_bulk_create(mut self, n: usize) -> Self {
self.max_bulk_create = n.max(1);
self
}
pub fn ordering(mut self, ordering: &[(&str, bool)]) -> Self {
self.default_ordering = ordering.iter().map(|&(f, d)| (f.to_owned(), d)).collect();
self
}
pub fn permissions(mut self, perms: ViewSetPerms) -> Self {
self.perms = perms;
self
}
#[cfg(feature = "tenancy")]
pub fn permissions_for_model<T: crate::core::Model>(mut self) -> Self {
let cn = |action: &str| crate::permissions::codename_for::<T>(action);
self.perms = ViewSetPerms {
list: vec![cn("view")],
retrieve: vec![cn("view")],
create: vec![cn("add")],
update: vec![cn("change")],
destroy: vec![cn("delete")],
};
self
}
#[must_use]
pub fn pk_param(mut self, name: impl Into<String>) -> Self {
self.pk_param = name.into();
self
}
#[must_use]
pub fn pk_param_name(&self) -> &str {
&self.pk_param
}
pub fn read_only(mut self) -> Self {
self.read_only = true;
self
}
#[must_use]
pub fn allow_anonymous(mut self) -> Self {
self.allow_anonymous = true;
self
}
fn open_write_actions(&self) -> Vec<&'static str> {
if self.read_only || self.allow_anonymous {
return Vec::new();
}
let p = &self.perms;
[
("create", &p.create),
("update", &p.update),
("destroy", &p.destroy),
]
.into_iter()
.filter(|(_, codenames)| codenames.is_empty())
.map(|(action, _)| action)
.collect()
}
#[must_use]
pub fn openapi_query(mut self, on: bool) -> Self {
self.openapi_query = on;
self
}
#[cfg(feature = "postgres")]
pub fn router(self, prefix: &str, pool: crate::sql::sqlx::PgPool) -> Router {
Self::router_with_source(self, prefix, PoolSource::Static(pool))
}
pub fn router_pool(self, prefix: &str, pool: crate::sql::Pool) -> Router {
Self::router_with_source(self, prefix, PoolSource::StaticPool(pool))
}
#[cfg(feature = "tenancy")]
#[must_use]
pub fn tenant_router(self, prefix: &str) -> Router {
Self::router_with_source(self, prefix, PoolSource::Tenant)
}
fn router_with_source(self, prefix: &str, pool_source: PoolSource) -> Router {
let open = self.open_write_actions();
if !open.is_empty() {
tracing::warn!(
target: "rustango::viewset",
model = self.schema.table,
prefix,
actions = ?open,
"ViewSet write actions need no permission, so any caller that reaches this \
router can run them; set .permissions_for_model(), .read_only(), or \
.allow_anonymous() if this is intended"
);
}
let state = Arc::new(ViewSetState {
pool_source,
vs: self.clone(),
throttle_store: Arc::new(ThrottleStore::new(MAX_THROTTLE_KEYS)),
});
let prefix = prefix.trim_end_matches('/').to_owned();
let collection = prefix.clone();
let item = format!("{prefix}/{{{}}}", self.pk_param);
let collection_route = if self.read_only {
get(handle_list)
} else {
get(handle_list).post(handle_create)
};
#[cfg(feature = "_http_layers")]
let collection_route = {
use crate::http_query::QueryRouterExt as _;
collection_route.query(handle_query)
};
let item_route = if self.read_only {
axum::routing::MethodRouter::new().get(handle_retrieve)
} else {
axum::routing::MethodRouter::new()
.get(handle_retrieve)
.put(handle_update)
.patch(handle_partial_update)
.delete(handle_destroy)
};
Router::new()
.route(&collection, collection_route)
.route(&item, item_route)
.with_state(state)
}
}
#[derive(Clone)]
enum PoolSource {
#[cfg(feature = "postgres")]
Static(crate::sql::sqlx::PgPool),
StaticPool(crate::sql::Pool),
#[cfg(feature = "tenancy")]
Tenant,
}
#[derive(Clone)]
struct ViewSetState {
pool_source: PoolSource,
vs: ViewSet,
throttle_store: Arc<ThrottleStore>,
}
const MAX_THROTTLE_KEYS: usize = 100_000;
#[derive(Clone, Copy, Debug)]
struct ThrottleWindow {
count: u32,
max: u32,
start: Instant,
window: std::time::Duration,
}
impl ThrottleWindow {
fn ended(&self, now: Instant) -> bool {
now.duration_since(self.start) >= self.window
}
fn spent(&self) -> u64 {
(u64::from(self.count) << 32) / u64::from(self.max.max(1))
}
}
struct ThrottleStore {
cap: usize,
windows: Mutex<HashMap<String, ThrottleWindow>>,
}
impl ThrottleStore {
fn new(cap: usize) -> Self {
Self {
cap: cap.max(1),
windows: Mutex::new(HashMap::new()),
}
}
fn spend(&self, key: String, rule: ThrottleRule, cost: u32, now: Instant) -> Result<(), u64> {
let Ok(mut windows) = self.windows.lock() else {
return Ok(());
};
if !windows.contains_key(&key) {
self.make_room(&mut windows, now);
}
let fresh = ThrottleWindow {
count: 0,
max: rule.max,
start: now,
window: std::time::Duration::from_secs(rule.window_secs),
};
let w = windows.entry(key).or_insert(fresh);
if w.ended(now) {
*w = fresh;
}
let count = w.count.saturating_add(cost);
if count > rule.max {
let left = w.window.saturating_sub(now.duration_since(w.start));
return Err(left.as_secs().max(1));
}
w.count = count;
Ok(())
}
fn make_room(&self, windows: &mut HashMap<String, ThrottleWindow>, now: Instant) {
if windows.len() < self.cap {
return;
}
windows.retain(|_, w| !w.ended(now));
if windows.len() < self.cap {
return;
}
let mut by_spent: Vec<(u64, String)> = windows
.iter()
.map(|(k, w)| (w.spent(), k.clone()))
.collect();
by_spent.sort_unstable_by_key(|(n, _)| *n);
for (_, k) in by_spent.into_iter().take((self.cap / 8).max(1)) {
windows.remove(&k);
}
}
}
enum Scope {
Static,
#[cfg(feature = "tenancy")]
Tenant(Box<crate::tenancy::Org>),
#[cfg(feature = "tenancy")]
Unknown,
}
impl Scope {
fn throttle_label(&self) -> &str {
match self {
Self::Static => "",
#[cfg(feature = "tenancy")]
Self::Tenant(org) => &org.slug,
#[cfg(feature = "tenancy")]
Self::Unknown => "?",
}
}
}
struct AcquiredConn {
pool: Pool,
scope: Scope,
}
impl AcquiredConn {
async fn select_rows_as_json(
&mut self,
q: &SelectQuery,
fields: &[&'static crate::core::FieldSchema],
) -> Result<Vec<Value>, crate::sql::ExecError> {
crate::sql::select_rows_as_json(&self.pool, q, fields).await
}
async fn count_rows(&mut self, q: &CountQuery) -> Result<i64, crate::sql::ExecError> {
crate::sql::count_rows_pool(&self.pool, q).await
}
async fn select_one_as_json(
&mut self,
q: &SelectQuery,
fields: &[&'static crate::core::FieldSchema],
) -> Result<Option<Value>, crate::sql::ExecError> {
let mut rows = crate::sql::select_rows_as_json(&self.pool, q, fields).await?;
Ok(rows.pop())
}
#[cfg(feature = "serializer")]
async fn select_rows_typed<T>(
&mut self,
q: &SelectQuery,
) -> Result<Vec<T>, crate::sql::ExecError>
where
T: crate::sql::MaybePgFromRow
+ crate::sql::MaybeMyFromRow
+ crate::sql::MaybeSqliteFromRow
+ crate::sql::LoadRelated
+ crate::sql::MaybeMyLoadRelated
+ crate::sql::MaybeSqliteLoadRelated
+ Send
+ Unpin,
{
crate::sql::select_rows_pool_with_related::<T>(&self.pool, q).await
}
async fn insert_returning_pk(
&mut self,
q: &InsertQuery,
pk_field: &crate::core::FieldSchema,
) -> Result<SqlValue, crate::sql::ExecError> {
crate::audit::insert(&self.pool, q, pk_field).await
}
}
impl AcquiredConn {
async fn update(&mut self, q: &UpdateQuery) -> Result<u64, crate::sql::ExecError> {
crate::audit::update(&self.pool, q).await
}
async fn delete(&mut self, q: &DeleteQuery) -> Result<u64, crate::sql::ExecError> {
crate::audit::delete(&self.pool, q).await
}
async fn soft_delete(&mut self, q: &UpdateQuery) -> Result<u64, crate::sql::ExecError> {
crate::audit::update_as(&self.pool, q, crate::audit::AuditOp::SoftDelete).await
}
#[cfg(feature = "tenancy")]
async fn has_perm(&mut self, uid: i64, codename: &str) -> bool {
crate::tenancy::permissions::has_perm_pool(uid, codename, &self.pool)
.await
.unwrap_or(false)
}
}
impl ViewSet {
fn effective_fields(&self) -> Vec<&'static crate::core::FieldSchema> {
match &self.fields {
Some(names) => names.iter().filter_map(|n| self.schema.field(n)).collect(),
None => self.schema.scalar_fields().collect(),
}
}
fn body_fields(&self) -> Vec<&'static crate::core::FieldSchema> {
let exposed = self.effective_fields();
let serializer = self.serializer.as_ref().map(|b| b.writable_model_fields());
self.schema
.scalar_fields()
.filter(|f| f.primary_key || exposed.iter().any(|e| e.name == f.name))
.filter(|f| serializer.map_or(true, |w| w.contains(&f.name)))
.filter(|f| self.schema.soft_delete_column != Some(f.column))
.collect()
}
#[cfg(feature = "openapi")]
fn body_key(&self, model_field: &'static str) -> &'static str {
self.serializer
.as_ref()
.and_then(|b| {
b.writable_field_names()
.iter()
.zip(b.writable_model_fields())
.find(|(_, m)| **m == model_field)
.map(|(n, _)| *n)
})
.unwrap_or(model_field)
}
}
impl ViewSetState {
fn effective_fields(&self) -> Vec<&'static crate::core::FieldSchema> {
self.vs.effective_fields()
}
fn rendered_fields(&self) -> Vec<&'static crate::core::FieldSchema> {
let mut fields = self.effective_fields();
if let Some(bridge) = &self.vs.serializer {
let readable = bridge.readable_model_fields();
fields.retain(|f| readable.contains(&f.name));
}
fields
}
#[cfg_attr(not(feature = "tenancy"), allow(unused_variables))]
async fn resolve(&self, parts: &axum::http::request::Parts) -> Result<Scope, Response> {
match &self.pool_source {
#[cfg(feature = "postgres")]
PoolSource::Static(_) => Ok(Scope::Static),
PoolSource::StaticPool(_) => Ok(Scope::Static),
#[cfg(feature = "tenancy")]
PoolSource::Tenant => {
use crate::extractors::TenantRejection;
match crate::tenancy::middleware::request_org(parts, &parts.extensions).await {
Some(Ok(Some(org))) => Ok(Scope::Tenant(Box::new(org))),
Some(Ok(None)) => Ok(Scope::Unknown),
Some(Err(e)) => Err(TenantRejection::Internal(e.to_string()).into_response()),
None => Err(TenantRejection::MissingContext.into_response()),
}
}
}
}
#[cfg_attr(not(feature = "tenancy"), allow(unused_variables))]
async fn connect(
&self,
scope: Scope,
parts: &axum::http::request::Parts,
) -> Result<AcquiredConn, Response> {
let pool = match (&self.pool_source, &scope) {
#[cfg(feature = "postgres")]
(PoolSource::Static(pool), _) => Pool::from(pool.clone()),
(PoolSource::StaticPool(pool), _) => pool.clone(),
#[cfg(feature = "tenancy")]
(PoolSource::Tenant, scope) => {
use crate::extractors::TenantRejection;
let Scope::Tenant(org) = scope else {
return Err(TenantRejection::NotFound.into_response());
};
match crate::tenancy::middleware::request_pool(&parts.extensions, org).await {
Some(Ok(pool)) => pool,
Some(Err(e)) => {
return Err(TenantRejection::Internal(e.to_string()).into_response())
}
None => return Err(TenantRejection::MissingContext.into_response()),
}
}
};
Ok(AcquiredConn { pool, scope })
}
async fn check_perm(
&self,
codenames: &[String],
parts: &axum::http::request::Parts,
conn: &mut AcquiredConn,
) -> PermOutcome {
if codenames.is_empty() {
return PermOutcome::Allow;
}
#[cfg(feature = "tenancy")]
{
let Some(auth) = parts
.extensions
.get::<crate::tenancy::middleware::AuthenticatedUser>()
else {
return PermOutcome::Unauthenticated;
};
if auth.is_superuser {
return PermOutcome::Allow;
}
for cn in codenames {
if !conn.has_perm(auth.id, cn).await {
return PermOutcome::Forbidden;
}
}
PermOutcome::Allow
}
#[cfg(not(feature = "tenancy"))]
{
let _ = (parts, conn);
PermOutcome::Forbidden
}
}
fn pk_field(&self) -> Option<&'static crate::core::FieldSchema> {
self.vs.schema.primary_key()
}
}
fn json_with_status(status: StatusCode, body: Value) -> Response {
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}
fn json_response(body: Value) -> Response {
json_with_status(StatusCode::OK, body)
}
fn json_error(status: StatusCode, msg: &str) -> Response {
crate::api_errors::ApiError::logged(status, "viewset", msg).into_response()
}
fn json_server_error(context: &str, e: &dyn std::fmt::Display) -> Response {
crate::api_errors::ApiError::logged(StatusCode::INTERNAL_SERVER_ERROR, context, e)
.into_response()
}
fn json_form_errors(errs: &crate::forms::FormErrors) -> Response {
json_form_errors_msg("invalid input", errs)
}
fn json_form_errors_msg(message: &str, errs: &crate::forms::FormErrors) -> Response {
let mut map = serde_json::Map::new();
for (field, msgs) in errs.fields() {
map.insert(field.clone(), json!(msgs));
}
if !errs.non_field().is_empty() {
map.insert("non_field_errors".to_owned(), json!(errs.non_field()));
}
crate::api_errors::ApiError::validation(message)
.with_details(Value::Object(map))
.into_response()
}
fn serializer_input_renamed_form(
state: &ViewSetState,
form: &HashMap<String, String>,
) -> Option<HashMap<String, String>> {
let bridge = state.vs.serializer.as_ref()?;
let names = bridge.writable_field_names();
let renames: Vec<(&'static str, &'static str)> = names
.iter()
.zip(bridge.writable_model_fields().iter())
.filter(|(name, col)| name != col)
.filter(|(name, col)| form.contains_key(**name) || form.contains_key(**col))
.map(|(name, col)| (*name, *col))
.collect();
if renames.is_empty() {
return None;
}
let out = form
.iter()
.filter_map(|(k, v)| match renames.iter().find(|(n, _)| n == k) {
Some((_, col)) => Some(((*col).to_owned(), v.clone())),
None if renames.iter().any(|(_, c)| c == k) && !names.contains(&k.as_str()) => None,
None => Some((k.clone(), v.clone())),
})
.collect();
Some(out)
}
fn serializer_public_field_name(state: &ViewSetState, model_field: &str) -> Option<&'static str> {
let bridge = state.vs.serializer.as_ref()?;
bridge
.writable_field_names()
.iter()
.zip(bridge.writable_model_fields().iter())
.find(|(name, col)| **col == model_field && *name != *col)
.map(|(name, _)| *name)
}
fn public_form_error(state: &ViewSetState, e: FormError) -> FormError {
match e {
FormError::Missing { field } => FormError::Missing {
field: serializer_public_field_name(state, &field).map_or(field, ToOwned::to_owned),
},
FormError::Parse {
field,
ty,
value,
detail,
} => FormError::Parse {
field: serializer_public_field_name(state, &field).map_or(field, ToOwned::to_owned),
ty,
value,
detail,
},
other @ FormError::UnsupportedPk { .. } => other,
}
}
fn serializer_validate(state: &ViewSetState, json: &Value) -> Result<(), Response> {
if let Some(bridge) = &state.vs.serializer {
bridge
.validate_body(json)
.map_err(|errs| json_form_errors(&errs))?;
}
Ok(())
}
fn write_json(state: &ViewSetState, form: &HashMap<String, String>, json: Option<Value>) -> Value {
if let Some(json) = json {
return json;
}
let Some(b) = state.vs.serializer.as_ref() else {
return Value::Null;
};
let model_name = |key: &str| -> Option<&'static str> {
b.writable_field_names()
.iter()
.zip(b.writable_model_fields())
.find(|(n, _)| **n == key)
.map(|(_, m)| *m)
};
let typed = |key: &str, raw: &String| {
model_name(key)
.and_then(|m| state.vs.schema.field(m))
.and_then(|f| parse_form_value(f, Some(raw)).ok())
.and_then(sql_value_json)
.unwrap_or_else(|| Value::String(raw.clone()))
};
Value::Object(form.iter().map(|(k, v)| (k.clone(), typed(k, v))).collect())
}
fn sql_value_json(v: SqlValue) -> Option<Value> {
match v {
SqlValue::Null => Some(Value::Null),
SqlValue::I16(n) => Some(n.into()),
SqlValue::I32(n) => Some(n.into()),
SqlValue::I64(n) => Some(n.into()),
SqlValue::F32(n) => serde_json::to_value(n).ok(),
SqlValue::F64(n) => serde_json::to_value(n).ok(),
SqlValue::Bool(b) => Some(b.into()),
SqlValue::String(s) | SqlValue::RangeLiteral(s) => Some(s.into()),
SqlValue::Json(j) => Some(j),
SqlValue::DateTime(d) => serde_json::to_value(d).ok(),
SqlValue::Date(d) => serde_json::to_value(d).ok(),
SqlValue::Time(t) => serde_json::to_value(t).ok(),
SqlValue::Uuid(u) => serde_json::to_value(u).ok(),
SqlValue::Decimal(d) => serde_json::to_value(d).ok(),
SqlValue::Binary(bytes) => serde_json::to_value(bytes).ok(),
SqlValue::Vector(v) => serde_json::to_value(v).ok(),
SqlValue::HStore(pairs) => Some(Value::Object(
pairs
.into_iter()
.map(|(k, v)| (k, v.map_or(Value::Null, Value::String)))
.collect(),
)),
SqlValue::Array(items) | SqlValue::List(items) => items
.into_iter()
.map(sql_value_json)
.collect::<Option<Vec<_>>>()
.map(Value::Array),
SqlValue::Geometry { x, y, srid } => {
Some(serde_json::json!({"x": x, "y": y, "srid": srid}))
}
}
}
struct WriteSet {
schema: &'static ModelSchema,
writable: Vec<&'static crate::core::FieldSchema>,
pinned: Vec<(&'static crate::core::FieldSchema, SqlValue)>,
}
impl WriteSet {
fn for_request(
state: &ViewSetState,
parts: &axum::http::request::Parts,
) -> Result<Self, Response> {
let schema = state.vs.schema;
let mut pinned: Vec<(&'static crate::core::FieldSchema, SqlValue)> = Vec::new();
for backend in &state.vs.filter_backends {
for pin in backend.write_pins(parts, schema) {
match pin {
WritePin::Field { field, value } => match schema.field(field) {
Some(f) => match pinned.iter().find(|(p, _)| p.name == f.name) {
None => pinned.push((f, value)),
Some((_, v)) if *v == value => {}
Some(_) => {
tracing::error!(
model = schema.table,
field,
"two write pins disagree on one field — denying"
);
return Err(json_error(StatusCode::FORBIDDEN, "forbidden"));
}
},
None => {
tracing::error!(
model = schema.table,
field,
"write pin names a field this model does not have — denying"
);
return Err(json_error(StatusCode::FORBIDDEN, "forbidden"));
}
},
WritePin::Deny => return Err(json_error(StatusCode::FORBIDDEN, "forbidden")),
}
}
}
let writable = state
.vs
.body_fields()
.into_iter()
.filter(|f| !pinned.iter().any(|(p, _)| p.name == f.name))
.filter(|f| schema.soft_delete_column != Some(f.column))
.collect();
Ok(Self {
schema,
writable,
pinned,
})
}
fn is_writable(&self, name: &str) -> bool {
self.writable.iter().any(|f| f.name == name)
}
fn updatable(&self) -> impl Iterator<Item = &'static crate::core::FieldSchema> + '_ {
self.writable
.iter()
.copied()
.filter(|f| f.accepts_input(crate::core::WriteKind::Update))
}
fn patch_body(&self, bridge: &dyn SerializerBridge, body: &Value) -> Value {
let Some(obj) = body.as_object() else {
return body.clone();
};
let written = |key: &str| {
bridge
.writable_field_names()
.iter()
.zip(bridge.writable_model_fields())
.any(|(n, m)| *n == key && self.updatable().any(|f| f.name == *m))
};
Value::Object(
obj.iter()
.filter(|(k, _)| written(k))
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
)
}
fn insert_values(
&self,
form: &HashMap<String, String>,
encoding: Encoding,
) -> Result<Vec<(&'static str, SqlValue)>, Refusal> {
let skip: Vec<&str> = self
.schema
.scalar_fields()
.filter(|f| {
!self.is_writable(f.name)
|| (absent_takes_default(f, encoding) && !form.contains_key(f.name))
})
.map(|f| f.name)
.collect();
let mut out = collect_insert_values(self.schema, form, &skip)?;
out.retain(|(c, _)| !self.pinned.iter().any(|(f, _)| f.column == *c));
out.extend(self.pinned.iter().map(|(f, v)| (f.column, v.clone())));
self.check(&out)?;
Ok(out)
}
fn update_assignments(
&self,
form: &HashMap<String, String>,
partial: bool,
) -> Result<Vec<Assignment>, Refusal> {
let mut out = Vec::new();
for field in self.updatable() {
if partial && !form.contains_key(field.name) {
continue;
}
match parse_form_value(field, form.get(field.name).map(String::as_str)) {
Ok(v) => out.push((field.column, v)),
Err(FormError::Missing { .. }) if partial => {}
Err(e) => return Err(e.into()),
}
}
self.check(&out)?;
if !out.is_empty() {
crate::forms::stamp_auto_now(self.schema, &mut out);
}
Ok(out
.into_iter()
.map(|(column, v)| Assignment {
column,
value: v.into(),
})
.collect())
}
fn check(&self, values: &[(&'static str, SqlValue)]) -> Result<(), Refusal> {
for (column, value) in values {
let Some(field) = self.schema.scalar_fields().find(|f| f.column == *column) else {
continue;
};
if let Err(e) = crate::core::validate_value(self.schema.name, field, value) {
if let Some((_, message)) = e.value_rejection() {
return Err(Refusal::Invalid {
field: field.name,
message,
});
}
}
}
Ok(())
}
}
enum Refusal {
Form(FormError),
Invalid {
field: &'static str,
message: String,
},
}
impl From<FormError> for Refusal {
fn from(e: FormError) -> Self {
Self::Form(e)
}
}
impl Refusal {
fn into_response(self, state: &ViewSetState, entry: Option<usize>) -> Response {
let prefix = entry
.map(|i| format!("bulk entry {i}: "))
.unwrap_or_default();
match self {
Self::Form(e) => json_error(
StatusCode::BAD_REQUEST,
&format!("{prefix}{}", public_form_error(state, e)),
),
Self::Invalid { field, message } => {
let mut errs = crate::forms::FormErrors::default();
errs.add(
serializer_public_field_name(state, field).unwrap_or(field),
message,
);
json_form_errors_msg(&format!("{prefix}invalid input"), &errs)
}
}
}
}
macro_rules! or_500 {
($expr:expr) => {
match $expr {
::core::result::Result::Ok(v) => v,
::core::result::Result::Err(e) => {
return json_error(
::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
&::std::string::ToString::to_string(&e),
);
}
}
};
}
fn scope_filters(
state: &ViewSetState,
parts: &axum::http::request::Parts,
params: &HashMap<String, String>,
) -> Vec<WhereExpr> {
let mut all = state.vs.schema.global_scope_exprs(&[]);
all.extend(crate::soft_delete::active_filter(state.vs.schema));
all.extend(
state
.vs
.filter_backends
.iter()
.flat_map(|b| b.filter_with(parts, params, state.vs.schema)),
);
all
}
fn narrow(expr: WhereExpr, extra: Vec<WhereExpr>) -> WhereExpr {
if extra.is_empty() {
return expr;
}
let mut all = vec![expr];
all.extend(extra);
WhereExpr::And(all)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PermOutcome {
Allow,
#[cfg_attr(not(feature = "tenancy"), allow(dead_code))]
Unauthenticated,
Forbidden,
}
async fn enter(
state: &Arc<ViewSetState>,
req: axum::extract::Request,
codenames: &[String],
action: &'static str,
) -> Result<(axum::http::request::Parts, Body, AcquiredConn), Response> {
let (parts, body) = req.into_parts();
let scope = state.resolve(&parts).await?;
if let Some(resp) = check_throttle(state, action, &parts, &scope) {
return Err(resp);
}
let mut acq = state.connect(scope, &parts).await?;
match state.check_perm(codenames, &parts, &mut acq).await {
PermOutcome::Allow => {}
PermOutcome::Unauthenticated => {
return Err(json_error(
StatusCode::UNAUTHORIZED,
"authentication required",
))
}
PermOutcome::Forbidden => {
return Err(json_error(StatusCode::FORBIDDEN, "permission denied"))
}
}
Ok((parts, body, acq))
}
fn check_throttle(
state: &ViewSetState,
action: &str,
parts: &axum::http::request::Parts,
scope: &Scope,
) -> Option<Response> {
state.vs.throttle.for_action(action)?;
spend_throttle(state, action, &ThrottleClient::new(parts, scope), 1)
}
fn spend_throttle(
state: &ViewSetState,
action: &str,
client: &ThrottleClient,
cost: u32,
) -> Option<Response> {
let rule = state.vs.throttle.for_action(action)?;
let key = format!("{}:{}:{}", state.vs.schema.table, action, client.0);
state
.throttle_store
.spend(key, rule, cost, Instant::now())
.err()
.map(throttled_response)
}
struct ThrottleClient(String);
impl ThrottleClient {
fn new(parts: &axum::http::request::Parts, scope: &Scope) -> Self {
let ip = crate::rate_limit::client_ip(&parts.extensions, &parts.headers).map_or_else(
|| {
crate::rate_limit::warn_missing_discriminator("IP (ConnectInfo missing)");
"global".to_owned()
},
crate::rate_limit::ip_bucket,
);
Self(format!("{}:{ip}", scope.throttle_label()))
}
}
fn throttled_response(retry_after_secs: u64) -> Response {
crate::api_errors::ApiError::rate_limited_response("request throttled", retry_after_secs)
}
fn parse_pk_or_400(
field: &'static crate::core::FieldSchema,
raw: &str,
) -> Result<crate::core::SqlValue, Response> {
parse_pk_string(field, raw).map_err(|e| json_error(StatusCode::BAD_REQUEST, &e.to_string()))
}
fn pk_field_or_500(state: &ViewSetState) -> Result<&'static crate::core::FieldSchema, Response> {
state.pk_field().ok_or_else(|| {
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"model has no primary key",
)
})
}
fn json_created(body: Value) -> Response {
json_with_status(StatusCode::CREATED, body)
}
fn no_content() -> Response {
Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap()
}
#[derive(Debug)]
enum FilterError {
UnknownLookup,
Invalid(String),
TooLong(crate::list_params::InListTooLong),
}
impl FilterError {
fn message(&self, param: &str) -> String {
match self {
Self::UnknownLookup => format!("filter `{param}`: unknown lookup"),
Self::Invalid(detail) => format!("filter `{param}`: {detail}"),
Self::TooLong(e) => e.to_string(),
}
}
}
fn build_lookup_filter(
field: &'static crate::core::FieldSchema,
lookup: Option<&str>,
raw: &str,
) -> Result<Option<WhereExpr>, FilterError> {
if raw.is_empty() {
return Ok(None);
}
let column = field.column;
let predicate = |op: Op, value: SqlValue| WhereExpr::Predicate(Filter { column, op, value });
let parse =
|s: &str| parse_form_value(field, Some(s)).map_err(|e| FilterError::Invalid(e.to_string()));
let escaped_like = |prefix: &str, suffix: &str, raw: &str, case_insensitive: bool| {
let escaped = crate::core::escape_like(raw);
let op = if case_insensitive {
Op::ILikeEscaped
} else {
Op::LikeEscaped
};
predicate(op, SqlValue::String(format!("{prefix}{escaped}{suffix}")))
};
let lookup = lookup.unwrap_or("exact");
let (token, trailing) = match lookup.split_once("__") {
Some((token, trailing)) => (token, Some(trailing)),
None => (lookup, None),
};
if let Some(transform) = crate::query::date_transform_fn(token) {
return date_part_filter(field, transform, trailing, raw).map(Some);
}
if let Some(op) = crate::query::date_compare_op(lookup) {
return match parse(raw) {
Ok(value) => Ok(Some(predicate(op, value))),
Err(e) => whole_day_bound(field, op, raw)
.map(|(op, value)| Some(predicate(op, value)))
.ok_or(e),
};
}
let parse_list = || -> Result<Vec<SqlValue>, FilterError> {
crate::list_params::split_in_list(raw)
.map_err(FilterError::TooLong)?
.into_iter()
.map(parse)
.collect()
};
Ok(Some(match lookup {
"in" | "not_in" => {
let parts = parse_list()?;
if parts.is_empty() {
return Err(FilterError::Invalid(
"expects comma-separated values".into(),
));
}
let op = if lookup == "not_in" {
Op::NotIn
} else {
Op::In
};
predicate(op, SqlValue::List(parts))
}
"range" | "between" => {
let parts = parse_list()?;
if parts.len() != 2 {
return Err(FilterError::Invalid("expects two values, `lo,hi`".into()));
}
predicate(Op::Between, SqlValue::List(parts))
}
"iexact" | "contains" | "icontains" | "startswith" | "istartswith" | "endswith"
| "iendswith"
if field.ty != FieldType::String =>
{
return Err(FilterError::Invalid(
"this lookup needs a string field".into(),
));
}
"iexact" => escaped_like("", "", raw, true),
"contains" => escaped_like("%", "%", raw, false),
"icontains" => escaped_like("%", "%", raw, true),
"startswith" => escaped_like("", "%", raw, false),
"istartswith" => escaped_like("", "%", raw, true),
"endswith" => escaped_like("%", "", raw, false),
"iendswith" => escaped_like("%", "", raw, true),
"isnull" => {
let is_null = match raw.to_ascii_lowercase().as_str() {
"true" | "1" | "yes" => true,
"false" | "0" | "no" => false,
_ => return Err(FilterError::Invalid("expects `true` or `false`".into())),
};
predicate(Op::IsNull, SqlValue::Bool(is_null))
}
_ => return Err(FilterError::UnknownLookup),
}))
}
fn date_part_filter(
field: &'static crate::core::FieldSchema,
transform: crate::core::ScalarFn,
trailing: Option<&str>,
raw: &str,
) -> Result<WhereExpr, FilterError> {
use crate::core::ScalarFn;
let op = match trailing {
None => Op::Eq,
Some(t) => crate::query::date_compare_op(t).ok_or(FilterError::UnknownLookup)?,
};
let time_part = matches!(
transform,
ScalarFn::ExtractHour | ScalarFn::ExtractMinute | ScalarFn::ExtractSecond
);
let fits = match field.ty {
FieldType::DateTime => true,
FieldType::Date => !time_part,
_ => false,
};
if !fits {
let needs = if time_part {
"a datetime"
} else {
"a date or datetime"
};
return Err(FilterError::Invalid(format!(
"this lookup needs {needs} field"
)));
}
let value = if transform == ScalarFn::TruncDate {
chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d")
.map(SqlValue::Date)
.map_err(|_| FilterError::Invalid(format!("`{raw}` is not a date (YYYY-MM-DD)")))?
} else {
raw.parse::<i64>()
.map(SqlValue::I64)
.map_err(|_| FilterError::Invalid(format!("`{raw}` is not an integer")))?
};
Ok(crate::query::date_transform_where(
field.column,
transform,
op,
value,
))
}
fn whole_day_bound(field: &crate::core::FieldSchema, op: Op, raw: &str) -> Option<(Op, SqlValue)> {
if field.ty != FieldType::DateTime {
return None;
}
let day = chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()?;
let (op, day) = match op {
Op::Gte => (Op::Gte, day),
Op::Lte => (Op::Lt, day.succ_opt()?),
_ => return None,
};
Some((
op,
SqlValue::DateTime(day.and_time(chrono::NaiveTime::MIN).and_utc()),
))
}
async fn handle_list(
State(state): State<Arc<ViewSetState>>,
Query(params): Query<HashMap<String, String>>,
req: axum::extract::Request,
) -> Response {
let (parts, _body, acq) = match enter(&state, req, &state.vs.perms.list, "list").await {
Ok(x) => x,
Err(resp) => return resp,
};
run_list(state, params, acq, &parts).await
}
async fn run_list(
state: Arc<ViewSetState>,
params: HashMap<String, String>,
mut acq: AcquiredConn,
parts: &axum::http::request::Parts,
) -> Response {
let page_size: i64 = params
.get("page_size")
.and_then(|p| p.parse().ok())
.unwrap_or(state.vs.default_page_size as i64)
.min(state.vs.max_page_size as i64)
.max(1);
let mut filters: Vec<WhereExpr> = Vec::new();
let in_budget = crate::list_params::in_values_budget(acq.pool.dialect().max_bind_params());
let mut in_values = 0_usize;
for (param_key, raw_val) in ¶ms {
if crate::list_params::is_reserved_list_key(param_key) {
continue;
}
let (field_name, lookup) = match param_key.split_once("__") {
Some((name, lk)) => (name, Some(lk)),
None => (param_key.as_str(), None),
};
if !state.vs.filter_fields.iter().any(|f| f == field_name) {
continue;
}
let Some(field) = state.vs.schema.field(field_name) else {
continue;
};
match build_lookup_filter(field, lookup, raw_val) {
Ok(Some(predicate)) => {
if let WhereExpr::Predicate(Filter {
value: SqlValue::List(items),
..
}) = &predicate
{
in_values += items.len();
}
filters.push(predicate);
}
Ok(None) => {}
Err(FilterError::UnknownLookup) if !state.vs.filter_backends.is_empty() => {
tracing::debug!(target: "rustango::viewset", param = %param_key, "lookup left to filter backends");
}
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e.message(param_key)),
}
}
if in_values > in_budget {
let e = crate::list_params::InListTooLong::Total(in_budget);
return json_error(StatusCode::BAD_REQUEST, &e.to_string());
}
filters.extend(scope_filters(&state, parts, ¶ms));
let where_clause = if filters.len() == 1 {
filters.remove(0)
} else if filters.is_empty() {
WhereExpr::And(vec![])
} else {
WhereExpr::And(filters)
};
let search = params
.get("search")
.filter(|s| !s.is_empty() && !state.vs.search_fields.is_empty())
.cloned();
let search_clause = search.map(|q| SearchClause {
query: q,
columns: state
.vs
.search_fields
.iter()
.filter_map(|n| state.vs.schema.field(n).map(|f| f.column))
.collect(),
});
let ordering_allowlist: Vec<String> = match &state.vs.ordering_fields {
Some(allowed) => allowed.clone(),
None => state
.rendered_fields()
.iter()
.map(|f| f.name.to_owned())
.collect(),
};
let order_by: Vec<crate::core::OrderItem> = params
.get("ordering")
.map(|raw| crate::list_params::parse_ordering(raw, &ordering_allowlist, state.vs.schema))
.filter(|v| !v.is_empty())
.unwrap_or_else(|| default_order_by(&state.vs));
let order_by = state.vs.schema.with_pk_tiebreak(order_by);
let fields = state.effective_fields();
match &state.vs.pagination {
PaginationStyle::PageNumber => {
let page = crate::list_params::parse_page(¶ms);
let offset = crate::list_params::page_offset(page, page_size);
let select_q = SelectQuery {
where_clause: where_clause.clone(),
search: search_clause.clone(),
order_by: order_by.clone(),
limit: Some(page_size),
offset: Some(offset),
..SelectQuery::new(state.vs.schema)
};
let count_q = CountQuery {
model: state.vs.schema,
where_clause,
search: search_clause.clone(),
source: None,
};
let results = or_500!(render_list(&state, &mut acq, &select_q, &fields).await);
let count = or_500!(acq.count_rows(&count_q).await);
let last_page = ((count - 1).max(0) / page_size) + 1;
json_response(json!({
"count": count,
"page": page,
"page_size": page_size,
"last_page": last_page,
"results": results,
}))
}
PaginationStyle::Cursor {
field: cursor_field,
desc,
} => {
handle_list_cursor(
state.as_ref(),
&mut acq,
params,
where_clause,
search_clause,
fields,
page_size,
cursor_field,
*desc,
)
.await
}
PaginationStyle::LimitOffset => {
let limit: i64 = params
.get("limit")
.and_then(|p| p.parse().ok())
.unwrap_or(page_size)
.min(state.vs.max_page_size as i64)
.max(1);
let offset: i64 = params
.get("offset")
.and_then(|p| p.parse().ok())
.unwrap_or(0)
.max(0);
let select_q = SelectQuery {
where_clause: where_clause.clone(),
search: search_clause.clone(),
order_by,
limit: Some(limit),
offset: Some(offset),
..SelectQuery::new(state.vs.schema)
};
let count_q = CountQuery {
model: state.vs.schema,
where_clause,
search: search_clause,
source: None,
};
let results = or_500!(render_list(&state, &mut acq, &select_q, &fields).await);
let count = or_500!(acq.count_rows(&count_q).await);
json_response(json!({
"count": count,
"limit": limit,
"offset": offset,
"results": results,
}))
}
}
}
fn default_order_by(vs: &ViewSet) -> Vec<crate::core::OrderItem> {
let schema = vs.schema;
let builder: Vec<(&str, bool)> = vs
.default_ordering
.iter()
.map(|(n, d)| (n.as_str(), *d))
.collect();
let spec = if builder.is_empty() {
schema.default_order
} else {
&builder[..]
};
spec.iter()
.filter_map(|(name, desc)| {
schema
.field(name)
.or_else(|| schema.field_by_column(name))
.map(|f| crate::core::OrderItem::column(f.column, *desc))
})
.collect()
}
#[cfg(feature = "admin")]
async fn handle_query(
State(state): State<Arc<ViewSetState>>,
req: axum::extract::Request,
) -> Response {
let (parts, body, acq) = match enter(&state, req, &state.vs.perms.list, "list").await {
Ok(x) => x,
Err(resp) => return resp,
};
let params = match parse_query_body_params(&parts, body).await {
Ok(p) => p,
Err(resp) => return resp,
};
run_list(state, params, acq, &parts).await
}
#[cfg(feature = "admin")]
async fn parse_query_body_params(
parts: &axum::http::request::Parts,
body: Body,
) -> Result<HashMap<String, String>, Response> {
const CAP: usize = 1 << 20;
let essence = parts
.headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
let bytes = match axum::body::to_bytes(body, CAP).await {
Ok(b) => b,
Err(_) => {
return Err(json_error(
StatusCode::PAYLOAD_TOO_LARGE,
"QUERY body too large",
))
}
};
if essence == "application/json" || essence.ends_with("+json") {
let value: Value = serde_json::from_slice(&bytes).map_err(|e| {
json_error(
StatusCode::UNPROCESSABLE_ENTITY,
&format!("invalid JSON QUERY body: {e}"),
)
})?;
let Value::Object(obj) = value else {
return Err(json_error(
StatusCode::UNPROCESSABLE_ENTITY,
"QUERY JSON body must be an object of criteria",
));
};
Ok(obj
.iter()
.map(|(k, v)| (k.clone(), json_value_to_param(v)))
.collect())
} else if essence.is_empty() || essence == "application/x-www-form-urlencoded" {
serde_urlencoded::from_bytes::<HashMap<String, String>>(&bytes)
.map_err(|e| json_error(StatusCode::BAD_REQUEST, &format!("invalid QUERY body: {e}")))
} else {
Err(json_error(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"QUERY body must be application/x-www-form-urlencoded or application/json",
))
}
}
#[cfg(feature = "admin")]
fn json_value_to_param(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Array(items) => items
.iter()
.map(json_value_to_param)
.collect::<Vec<_>>()
.join(","),
Value::Null => String::new(),
other => other.to_string(),
}
}
async fn handle_list_cursor(
state: &ViewSetState,
acq: &mut AcquiredConn,
params: HashMap<String, String>,
where_clause: WhereExpr,
search_clause: Option<SearchClause>,
fields: Vec<&'static crate::core::FieldSchema>,
page_size: i64,
cursor_field: &str,
desc: bool,
) -> Response {
let Some(cursor_schema) = state.vs.schema.field(cursor_field) else {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("cursor field `{cursor_field}` not found on model"),
);
};
if !cursor_field_supported(cursor_schema.ty) {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!(
"cursor pagination needs a totally-ordered column; `{cursor_field}` is \
{:?}, which does not round-trip through a cursor token. Use an \
integer, timestamp, date, uuid or string column.",
cursor_schema.ty
),
);
}
let pk_schema = state.vs.schema.fields.iter().find(|f| f.primary_key);
let tiebreak = pk_schema.filter(|pk| pk.column != cursor_schema.column);
let cursor_pos: Option<(SqlValue, Option<SqlValue>)> = match params.get("cursor") {
Some(c) if !c.is_empty() => {
let pk_ty = tiebreak.map_or(cursor_schema.ty, |pk| pk.ty);
match decode_cursor(c, cursor_schema.ty, pk_ty) {
Some(v) => Some(v),
None => return json_error(StatusCode::BAD_REQUEST, "invalid cursor"),
}
}
_ => None,
};
let final_where = match cursor_pos {
Some((v, pk_v)) => {
let op = if desc { Op::Lt } else { Op::Gt };
let cursor_pred = match (tiebreak, pk_v) {
(Some(pk), Some(pk_v)) => WhereExpr::Or(vec![
WhereExpr::Predicate(Filter {
column: cursor_schema.column,
op,
value: v.clone(),
}),
WhereExpr::And(vec![
WhereExpr::Predicate(Filter {
column: cursor_schema.column,
op: Op::Eq,
value: v,
}),
WhereExpr::Predicate(Filter {
column: pk.column,
op,
value: pk_v,
}),
]),
]),
_ => WhereExpr::Predicate(Filter {
column: cursor_schema.column,
op,
value: v,
}),
};
match where_clause {
WhereExpr::And(v) if v.is_empty() => cursor_pred,
WhereExpr::And(mut v) => {
v.push(cursor_pred);
WhereExpr::And(v)
}
other => WhereExpr::And(vec![other, cursor_pred]),
}
}
None => where_clause,
};
let mut order_by = vec![crate::core::OrderItem::column(cursor_schema.column, desc)];
if let Some(pk) = tiebreak {
order_by.push(crate::core::OrderItem::column(pk.column, desc));
}
let select_q = SelectQuery {
where_clause: final_where,
search: search_clause,
order_by,
limit: Some(page_size + 1),
..SelectQuery::new(state.vs.schema)
};
let rows = or_500!(render_list(state, acq, &select_q, &fields).await);
let has_more = rows.len() as i64 > page_size;
let page_rows: &[Value] = if has_more {
&rows[..page_size as usize]
} else {
&rows[..]
};
let next_cursor = if has_more {
let last = page_rows.last().expect("non-empty page");
let pk_part = match tiebreak {
Some(pk) => match cursor_value_of(last, pk.name) {
Some(v) => Some(v),
None => {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!(
"cursor pagination on `{}` needs the primary key `{}` in the \
rendered rows to break ties, and it is not projected. Two rows \
sharing a `{}` would otherwise be split across a page boundary \
and all but one skipped. Include `{}` in `.fields([..])` / the \
serializer.",
cursor_schema.name, pk.name, cursor_schema.name, pk.name,
),
);
}
},
None => None,
};
match cursor_value_of(last, cursor_schema.name) {
Some(v) => Some(encode_cursor(&v, pk_part.as_deref())),
None if last.get(cursor_schema.name).is_some_and(Value::is_null) => {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!(
"cursor field `{}` is NULL in this row, so no `next` token can \
be issued. Paginate on a NOT NULL column.",
cursor_schema.name
),
);
}
None => {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!(
"cursor field `{}` is not present in the rendered rows, so no \
`next` token can be issued and pagination would silently stop \
after one page. Include it in `.fields([..])` / the \
serializer, or paginate on a field that is projected.",
cursor_schema.name
),
);
}
}
} else {
None
};
let results: Vec<Value> = page_rows.to_vec();
json_response(json!({
"page_size": page_size,
"next": next_cursor,
"results": results,
}))
}
pub(crate) fn cursor_field_supported(ty: FieldType) -> bool {
matches!(
ty,
FieldType::I16
| FieldType::I32
| FieldType::I64
| FieldType::DateTime
| FieldType::Date
| FieldType::Uuid
| FieldType::String
)
}
fn encode_cursor(value: &str, tiebreak: Option<&str>) -> String {
use base64::Engine;
let payload = match tiebreak {
Some(pk) => serde_json::json!([value, pk]).to_string(),
None => value.to_owned(),
};
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload.as_bytes())
}
fn decode_cursor(
token: &str,
ty: FieldType,
pk_ty: FieldType,
) -> Option<(SqlValue, Option<SqlValue>)> {
use base64::Engine;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(token.as_bytes())
.ok()?;
let s = std::str::from_utf8(&bytes).ok()?;
if let Some([a, b]) = serde_json::from_str::<Vec<String>>(s)
.ok()
.filter(|v| v.len() == 2)
.as_deref()
{
return Some((
parse_cursor_scalar(a, ty)?,
Some(parse_cursor_scalar(b, pk_ty)?),
));
}
Some((parse_cursor_scalar(s, ty)?, None))
}
fn parse_cursor_scalar(s: &str, ty: FieldType) -> Option<SqlValue> {
match ty {
FieldType::I16 | FieldType::I32 | FieldType::I64 => {
s.parse::<i64>().ok().map(SqlValue::I64)
}
FieldType::DateTime => chrono::DateTime::parse_from_rfc3339(s)
.ok()
.map(|dt| SqlValue::DateTime(dt.with_timezone(&chrono::Utc))),
FieldType::Date => chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
.ok()
.map(SqlValue::Date),
FieldType::Uuid => s.parse::<uuid::Uuid>().ok().map(SqlValue::Uuid),
FieldType::String => Some(SqlValue::String(s.to_owned())),
_ => None,
}
}
fn cursor_value_of(row: &Value, field_name: &str) -> Option<String> {
let v = row.get(field_name)?;
v.as_i64()
.map(|n| n.to_string())
.or_else(|| v.as_str().map(std::borrow::ToOwned::to_owned))
}
async fn handle_retrieve(
State(state): State<Arc<ViewSetState>>,
Path(pk_raw): Path<String>,
req: axum::extract::Request,
) -> Response {
let (parts, _body, mut acq) =
match enter(&state, req, &state.vs.perms.retrieve, "retrieve").await {
Ok(x) => x,
Err(resp) => return resp,
};
let pk_field = match pk_field_or_500(&state) {
Ok(f) => f,
Err(resp) => return resp,
};
let pk_val = match parse_pk_or_400(pk_field, &pk_raw) {
Ok(v) => v,
Err(resp) => return resp,
};
let mut select_q = SelectQuery::by_pk(state.vs.schema, pk_field.column, pk_val);
let scope = scope_filters(&state, &parts, &HashMap::new());
select_q.where_clause = narrow(select_q.where_clause, scope);
let fields = state.effective_fields();
match render_single(&state, &mut acq, &select_q, &fields).await {
Ok(Some(row)) => json_response(row),
Ok(None) => json_error(StatusCode::NOT_FOUND, "not found"),
Err(e) => json_server_error("viewset::retrieve", &e),
}
}
async fn handle_create(
State(state): State<Arc<ViewSetState>>,
req: axum::extract::Request,
) -> Response {
let (parts, body, mut acq) = match enter(&state, req, &state.vs.perms.create, "create").await {
Ok(x) => x,
Err(resp) => return resp,
};
let scope = scope_filters(&state, &parts, &HashMap::new());
let write_set = match WriteSet::for_request(&state, &parts) {
Ok(w) => w,
Err(resp) => return resp,
};
let client = state
.vs
.throttle
.create
.map(|_| ThrottleClient::new(&parts, &acq.scope));
let create_body = match extract_create_body(parts, body, typed_schema(&state)).await {
Ok(b) => b,
Err(e) => return e.into_response(),
};
let pk_field = match pk_field_or_500(&state) {
Ok(f) => f,
Err(resp) => return resp,
};
match create_body {
CreateBody::Single(form, json) => {
let encoding = if json.is_some() {
Encoding::Json
} else {
Encoding::Form
};
let json = write_json(&state, &form, json);
create_one(
&state,
&mut acq,
&write_set,
(&form, &json, encoding),
pk_field,
&scope,
)
.await
}
CreateBody::Bulk(rows) => {
if rows.len() > state.vs.max_bulk_create {
let msg = format!(
"bulk create accepts at most {} rows",
state.vs.max_bulk_create
);
return json_error(StatusCode::PAYLOAD_TOO_LARGE, &msg);
}
if let Some(rule) = state.vs.throttle.create {
if rows.len() > rule.max as usize {
let msg = format!(
"bulk create of {} rows exceeds the create throttle of {} per window",
rows.len(),
rule.max
);
return json_error(StatusCode::PAYLOAD_TOO_LARGE, &msg);
}
}
let extra = u32::try_from(rows.len().saturating_sub(1)).unwrap_or(u32::MAX);
if let Some(resp) = client
.as_ref()
.and_then(|c| spend_throttle(&state, "create", c, extra))
{
return resp;
}
let rows: Vec<_> = rows
.into_iter()
.map(|(form, json)| {
let json = write_json(&state, &form, json);
(form, json)
})
.collect();
create_many(&state, &mut acq, &write_set, &rows, pk_field, &scope).await
}
}
}
async fn insert_and_fetch_one(
state: &Arc<ViewSetState>,
acq: &mut AcquiredConn,
columns: Vec<&'static str>,
values: Vec<SqlValue>,
pk_field: &'static crate::core::FieldSchema,
fields: &[&'static crate::core::FieldSchema],
scope: &[WhereExpr],
) -> Result<Option<Value>, (StatusCode, String)> {
let query = InsertQuery {
model: state.vs.schema,
columns,
values,
returning: vec![pk_field.column],
on_conflict: None,
};
let pk_val = acq
.insert_returning_pk(&query, pk_field)
.await
.map_err(|e| write_failure("viewset::create", &e))?;
fetch_by_pk_scoped(state, acq, pk_field, pk_val, fields, scope)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
crate::error::client_error_body("viewset::create::read_back", &e, false),
)
})
}
fn write_failure(context: &str, e: &crate::sql::ExecError) -> (StatusCode, String) {
let client_caused = matches!(e, crate::sql::ExecError::Driver(sqlx::Error::Database(_)));
let body = crate::error::client_error_body(context, e, client_caused);
let status = if e.is_unique_violation() {
StatusCode::CONFLICT
} else if client_caused {
StatusCode::BAD_REQUEST
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(status, body)
}
async fn create_one(
state: &Arc<ViewSetState>,
acq: &mut AcquiredConn,
write_set: &WriteSet,
(form, json, encoding): (&HashMap<String, String>, &Value, Encoding),
pk_field: &'static crate::core::FieldSchema,
scope: &[WhereExpr],
) -> Response {
if let Err(resp) = serializer_validate(state, json) {
return resp;
}
let renamed = serializer_input_renamed_form(state, form);
let form = renamed.as_ref().unwrap_or(form);
let collected = match write_set.insert_values(form, encoding) {
Ok(v) => v,
Err(e) => return e.into_response(state, None),
};
let (columns, values): (Vec<_>, Vec<_>) = collected.into_iter().unzip();
let fields = state.effective_fields();
match insert_and_fetch_one(state, acq, columns, values, pk_field, &fields, scope).await {
Ok(Some(obj)) => json_created(obj),
Ok(None) => StatusCode::CREATED.into_response(),
Err((code, msg)) => json_error(code, &msg),
}
}
async fn create_many(
state: &Arc<ViewSetState>,
acq: &mut AcquiredConn,
write_set: &WriteSet,
rows: &[(HashMap<String, String>, Value)],
pk_field: &'static crate::core::FieldSchema,
scope: &[WhereExpr],
) -> Response {
if rows.is_empty() {
return json_created(Value::Array(Vec::new()));
}
let mut prepared: Vec<(Vec<&'static str>, Vec<SqlValue>)> = Vec::with_capacity(rows.len());
for (i, (row, json)) in rows.iter().enumerate() {
if let Err(resp) = serializer_validate(state, json) {
return resp;
}
let renamed = serializer_input_renamed_form(state, row);
let row = renamed.as_ref().unwrap_or(row);
let collected = match write_set.insert_values(row, Encoding::Json) {
Ok(v) => v,
Err(e) => return e.into_response(state, Some(i)),
};
prepared.push(collected.into_iter().unzip());
}
let fields = state.effective_fields();
let mut tx = match crate::sql::transaction_pool(&acq.pool).await {
Ok(tx) => tx,
Err(e) => {
return json_server_error("viewset::bulk_create::begin", &e);
}
};
let mut pks: Vec<SqlValue> = Vec::with_capacity(prepared.len());
for (i, (columns, values)) in prepared.into_iter().enumerate() {
let query = InsertQuery {
model: state.vs.schema,
columns,
values,
returning: vec![pk_field.column],
on_conflict: None,
};
match crate::audit::insert_tx(&mut tx, &query, pk_field).await {
Ok(pk) => pks.push(pk),
Err(e) => {
let _ = tx.rollback().await;
let (status, detail) = write_failure("viewset::bulk_create::entry", &e);
return json_error(status, &format!("bulk entry {i}: {detail}"));
}
}
}
if let Err(e) = tx.commit().await {
return json_server_error("viewset::bulk_create::commit", &e);
}
let mut created: Vec<Value> = Vec::with_capacity(pks.len());
for pk_val in pks {
match fetch_by_pk_scoped(state, acq, pk_field, pk_val, &fields, scope).await {
Ok(obj) => created.push(obj.unwrap_or(Value::Null)),
Err(e) => return json_server_error("viewset::bulk_create::read_back", &e),
}
}
json_created(Value::Array(created))
}
async fn handle_update(
State(state): State<Arc<ViewSetState>>,
Path(pk_raw): Path<String>,
req: axum::extract::Request,
) -> Response {
update_inner(state, pk_raw, req, false).await
}
async fn handle_partial_update(
State(state): State<Arc<ViewSetState>>,
Path(pk_raw): Path<String>,
req: axum::extract::Request,
) -> Response {
update_inner(state, pk_raw, req, true).await
}
async fn update_inner(
state: Arc<ViewSetState>,
pk_raw: String,
req: axum::extract::Request,
partial: bool,
) -> Response {
let (parts, body, mut acq) = match enter(&state, req, &state.vs.perms.update, "update").await {
Ok(x) => x,
Err(resp) => return resp,
};
let scope = scope_filters(&state, &parts, &HashMap::new());
let write_set = match WriteSet::for_request(&state, &parts) {
Ok(w) => w,
Err(resp) => return resp,
};
let pk_field = match pk_field_or_500(&state) {
Ok(f) => f,
Err(resp) => return resp,
};
let pk_val = match parse_pk_or_400(pk_field, &pk_raw) {
Ok(v) => v,
Err(resp) => return resp,
};
let (form, json) = match extract_form_body(parts, body, typed_schema(&state)).await {
Ok(b) => b,
Err(e) => return e.into_response(),
};
let json = write_json(&state, &form, json);
let mut locked = None;
let validated = match &state.vs.serializer {
Some(bridge) if partial => {
let mut q = SelectQuery::by_pk(state.vs.schema, pk_field.column, pk_val.clone());
q.where_clause = narrow(q.where_clause, scope.clone());
q.lock_mode = Some(crate::core::LockMode {
silent_on_sqlite: true,
..crate::core::LockMode::default()
});
let mut tx = match crate::sql::write_transaction_pool(&acq.pool).await {
Ok(tx) => tx,
Err(e) => return json_server_error("viewset::update::begin", &e),
};
let body = write_set.patch_body(bridge.as_ref(), &json);
let checked = match bridge.validate_patch(&mut tx, &q, &body).await {
Ok(r) => r.map_err(|errs| json_form_errors(&errs)),
Err(e) => return json_server_error("viewset::update::validate", &e),
};
locked = crate::audit::TxUpdate::for_model(state.vs.schema).map(|u| (u, tx));
checked
}
_ => serializer_validate(&state, &json),
};
if let Err(resp) = validated {
return resp;
}
let renamed = serializer_input_renamed_form(&state, &form);
let form = renamed.as_ref().unwrap_or(&form);
let assignments = match write_set.update_assignments(form, partial) {
Ok(a) => a,
Err(e) => return e.into_response(&state, None),
};
if assignments.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "no fields to update");
}
let query = UpdateQuery {
model: state.vs.schema,
set: assignments,
where_clause: narrow(
WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: pk_val.clone(),
}),
scope.clone(),
),
};
let updated = match locked.as_mut() {
Some((u, tx)) => u.run(tx, &acq.pool, &query, pk_val.clone()).await,
None => acq.update(&query).await,
};
match updated {
Ok(0) => return json_error(StatusCode::NOT_FOUND, "not found"),
Ok(_) => {}
Err(e) => {
let (status, msg) = write_failure("viewset::update", &e);
return json_error(status, &msg);
}
}
if let Some((_, tx)) = locked {
if let Err(e) = tx.commit().await {
return json_server_error("viewset::update::commit", &e);
}
}
let fields = state.effective_fields();
match fetch_by_pk_scoped(&state, &mut acq, pk_field, pk_val, &fields, &scope).await {
Ok(Some(obj)) => json_response(obj),
Ok(None) => no_content(),
Err(e) => json_server_error("viewset::update::read_back", &e),
}
}
async fn handle_destroy(
State(state): State<Arc<ViewSetState>>,
Path(pk_raw): Path<String>,
req: axum::extract::Request,
) -> Response {
let (parts, _body, mut acq) = match enter(&state, req, &state.vs.perms.destroy, "destroy").await
{
Ok(x) => x,
Err(resp) => return resp,
};
let scope = scope_filters(&state, &parts, &HashMap::new());
let pk_field = match pk_field_or_500(&state) {
Ok(f) => f,
Err(resp) => return resp,
};
let pk_val = match parse_pk_or_400(pk_field, &pk_raw) {
Ok(v) => v,
Err(resp) => return resp,
};
let deleted = if let Some(col) = state.vs.schema.soft_delete_column {
let mut query = crate::soft_delete::__mark_query(
state.vs.schema,
col,
pk_field.column,
pk_val,
Some(chrono::Utc::now()),
);
query.where_clause = narrow(query.where_clause, scope);
acq.soft_delete(&query).await
} else {
let query = DeleteQuery {
model: state.vs.schema,
where_clause: narrow(
WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: pk_val,
}),
scope,
),
};
acq.delete(&query).await
};
match deleted {
Ok(0) => json_error(StatusCode::NOT_FOUND, "not found"),
Ok(_) => no_content(),
Err(e) => json_server_error("viewset::destroy", &e),
}
}
async fn fetch_by_pk_scoped(
state: &ViewSetState,
acq: &mut AcquiredConn,
pk_field: &'static crate::core::FieldSchema,
pk_val: SqlValue,
fields: &[&'static crate::core::FieldSchema],
scope: &[WhereExpr],
) -> Result<Option<Value>, crate::sql::ExecError> {
let mut select_q = SelectQuery::by_pk(state.vs.schema, pk_field.column, pk_val);
select_q.where_clause = narrow(select_q.where_clause, scope.to_vec());
render_single(state, acq, &select_q, fields).await
}
async fn render_list(
state: &ViewSetState,
acq: &mut AcquiredConn,
select_q: &SelectQuery,
fields: &[&'static crate::core::FieldSchema],
) -> Result<Vec<Value>, crate::sql::ExecError> {
match &state.vs.serializer {
Some(bridge) => bridge.render_rows(acq, select_q).await,
None => acq.select_rows_as_json(select_q, fields).await,
}
}
async fn render_single(
state: &ViewSetState,
acq: &mut AcquiredConn,
select_q: &SelectQuery,
fields: &[&'static crate::core::FieldSchema],
) -> Result<Option<Value>, crate::sql::ExecError> {
match &state.vs.serializer {
Some(bridge) => bridge.render_one(acq, select_q).await,
None => acq.select_one_as_json(select_q, fields).await,
}
}
pub(crate) enum BodyError {
TooLarge,
Bad(String),
}
impl From<String> for BodyError {
fn from(msg: String) -> Self {
Self::Bad(msg)
}
}
impl From<&str> for BodyError {
fn from(msg: &str) -> Self {
Self::Bad(msg.to_owned())
}
}
impl BodyError {
fn into_response(self) -> Response {
match self {
Self::TooLarge => json_error(StatusCode::PAYLOAD_TOO_LARGE, "request body too large"),
Self::Bad(msg) => json_error(StatusCode::BAD_REQUEST, &msg),
}
}
}
async fn extract_form_body(
parts: axum::http::request::Parts,
body: Body,
typed: Option<&'static ModelSchema>,
) -> Result<(HashMap<String, String>, Option<Value>), BodyError> {
match extract_create_body(parts, body, typed).await? {
CreateBody::Single(form, json) => Ok((form, json)),
CreateBody::Bulk(_) => Err("expected a JSON object; got an array".into()),
}
}
pub(crate) enum CreateBody {
Single(HashMap<String, String>, Option<Value>),
Bulk(Vec<(HashMap<String, String>, Option<Value>)>),
}
pub(crate) async fn extract_create_body(
parts: axum::http::request::Parts,
body: Body,
typed: Option<&'static ModelSchema>,
) -> Result<CreateBody, BodyError> {
use axum::body::to_bytes;
let content_type = parts
.headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let bytes = to_bytes(body, 4 * 1024 * 1024).await.map_err(|e| {
#[cfg(feature = "_http_layers")]
if crate::body_limit::over_cap(&e) {
return BodyError::TooLarge;
}
BodyError::Bad(e.to_string())
})?;
if content_type.contains("application/json") {
let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
if let Some(array) = value.as_array() {
let mut bulk: Vec<(HashMap<String, String>, Option<Value>)> =
Vec::with_capacity(array.len());
for (i, entry) in array.iter().enumerate() {
let obj = entry
.as_object()
.ok_or_else(|| format!("bulk entry {i} is not a JSON object"))?;
let form =
json_object_to_form(obj, typed).map_err(|e| format!("bulk entry {i}: {e}"))?;
bulk.push((form, Some(entry.clone())));
}
return Ok(CreateBody::Bulk(bulk));
}
let obj = value
.as_object()
.ok_or("expected a JSON object or array of objects")?;
Ok(CreateBody::Single(
json_object_to_form(obj, typed)?,
Some(value),
))
} else {
let form = serde_urlencoded::from_bytes::<HashMap<String, String>>(&bytes)
.map_err(|e| e.to_string())?;
Ok(CreateBody::Single(form, None))
}
}
fn json_object_to_form(
obj: &serde_json::Map<String, Value>,
typed: Option<&'static ModelSchema>,
) -> Result<HashMap<String, String>, String> {
let mut form = HashMap::with_capacity(obj.len());
for (k, v) in obj {
let field = typed
.and_then(|s| s.field(k))
.filter(|f| !f.auto && f.generated_as.is_none());
let s = match (v, field) {
(Value::Null, Some(f)) if !f.nullable => {
return Err(format!("field `{k}` may not be null"));
}
(Value::Null, _) => String::new(),
(other, Some(f)) if f.ty == FieldType::Json => other.to_string(),
(Value::String(s), _) => s.clone(),
(Value::Number(n), _) => n.to_string(),
(Value::Bool(b), _) => b.to_string(),
(other, _) => other.to_string(),
};
form.insert(k.clone(), s);
}
Ok(form)
}
fn typed_schema(state: &ViewSetState) -> Option<&'static ModelSchema> {
state.vs.serializer.is_none().then_some(state.vs.schema)
}
#[cfg(test)]
mod envelope_tests {
use super::{json_error, json_server_error, throttled_response, StatusCode};
async fn body(r: axum::response::Response) -> serde_json::Value {
let b = axum::body::to_bytes(r.into_body(), 1 << 16).await.unwrap();
serde_json::from_slice(&b).unwrap()
}
#[tokio::test]
async fn a_404_is_the_envelope() {
let v = body(json_error(StatusCode::NOT_FOUND, "not found")).await;
assert_eq!(
(v["error"].as_str(), v["message"].as_str()),
(Some("not_found"), Some("not found"))
);
assert_eq!(v["status"], 404);
}
#[tokio::test]
async fn a_500_withholds_the_driver_text() {
let _env = crate::error::test_env::lock();
let (a, b) = (
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"relation \"users\" missing",
),
json_server_error("t", &"relation \"users\" missing"),
);
for v in [body(a).await, body(b).await] {
assert_eq!(v["error"], "internal_error");
assert!(!v.to_string().contains("users"), "{v}");
}
}
#[tokio::test]
async fn a_throttle_carries_retry_after() {
let r = throttled_response(5);
assert_eq!(r.headers()[axum::http::header::RETRY_AFTER], "5");
let v = body(r).await;
assert_eq!(
(v["error"].as_str(), v["details"]["retry_after"].as_u64()),
(Some("rate_limited"), Some(5))
);
}
}
#[cfg(all(test, feature = "admin"))]
mod body_tests {
use super::{extract_create_body, Body, BodyError, StatusCode};
fn json_parts() -> axum::http::request::Parts {
let req = axum::http::Request::builder()
.header("content-type", "application/json")
.body(())
.unwrap();
req.into_parts().0
}
#[tokio::test]
async fn an_over_cap_body_is_413() {
let body = Body::new(http_body_util::Limited::new(Body::from("[1,2,3,4]"), 4));
let Err(err) = extract_create_body(json_parts(), body, None).await else {
panic!("an over-cap body was accepted");
};
assert!(matches!(err, BodyError::TooLarge));
assert_eq!(err.into_response().status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn a_malformed_body_is_still_400() {
let Err(err) = extract_create_body(json_parts(), Body::from("{not json"), None).await
else {
panic!("malformed JSON was accepted");
};
assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
}
}
#[cfg(test)]
mod throttle_store_tests {
use super::{ThrottleRule, ThrottleStore};
use std::time::{Duration, Instant};
fn len(store: &ThrottleStore) -> usize {
store.windows.lock().unwrap().len()
}
#[test]
fn a_full_store_drops_ended_windows() {
let store = ThrottleStore::new(4);
let rule = ThrottleRule::new(5, 1);
let then = Instant::now();
for n in 0..4 {
store.spend(format!("k{n}"), rule, 1, then).unwrap();
}
let later = then + Duration::from_secs(2);
store.spend("fresh".into(), rule, 1, later).unwrap();
assert_eq!(len(&store), 1, "every ended window swept");
}
#[test]
fn a_full_live_store_keeps_spent_windows() {
let store = ThrottleStore::new(4);
let rule = ThrottleRule::new(2, 60);
let now = Instant::now();
store.spend("spent".into(), rule, 2, now).unwrap();
for n in 0..3 {
store.spend(format!("k{n}"), rule, 1, now).unwrap();
}
store.spend("new".into(), rule, 1, now).unwrap();
assert!(len(&store) <= 4);
assert!(
store.spend("spent".into(), rule, 1, now).is_err(),
"no reset"
);
}
#[test]
fn a_full_store_evicts_the_least_spent_share() {
let store = ThrottleStore::new(4);
let now = Instant::now();
store
.spend("big".into(), ThrottleRule::new(100, 60), 10, now)
.unwrap();
for n in 0..3 {
let rule = ThrottleRule::new(2, 60);
store.spend(format!("s{n}"), rule, 1, now).unwrap();
}
store
.spend("new".into(), ThrottleRule::new(2, 60), 1, now)
.unwrap();
let windows = store.windows.lock().unwrap();
assert!(!windows.contains_key("big"), "10% spent is the cheapest");
assert!((0..3).all(|n| windows.contains_key(&format!("s{n}"))));
}
#[test]
fn a_rejected_spend_is_free() {
let store = ThrottleStore::new(4);
let rule = ThrottleRule::new(3, 60);
let now = Instant::now();
assert!(store.spend("k".into(), rule, 50, now).is_err());
assert!(store.spend("k".into(), rule, 2, now).is_ok());
assert!(store.spend("k".into(), rule, 2, now).is_err());
assert!(store.spend("k".into(), rule, 1, now).is_ok());
}
#[test]
fn cost_spends_several_units() {
let store = ThrottleStore::new(4);
let rule = ThrottleRule::new(3, 60);
let now = Instant::now();
assert!(store.spend("k".into(), rule, 3, now).is_ok());
assert!(store.spend("k".into(), rule, 1, now).is_err());
}
}
#[cfg(test)]
mod cursor_tests {
use super::{cursor_field_supported, decode_cursor, encode_cursor, FieldType, SqlValue};
fn int_token(v: i64) -> String {
encode_cursor(&v.to_string(), None)
}
fn decode_value(token: &str, ty: FieldType) -> Option<SqlValue> {
decode_cursor(token, ty, FieldType::I64).map(|(v, _)| v)
}
#[test]
fn cursor_roundtrip_positive() {
let token = int_token(12345);
assert_eq!(
decode_value(&token, FieldType::I64),
Some(SqlValue::I64(12345))
);
}
#[test]
fn cursor_roundtrip_zero() {
let token = int_token(0);
assert_eq!(decode_value(&token, FieldType::I64), Some(SqlValue::I64(0)));
}
#[test]
fn cursor_roundtrip_max() {
let token = int_token(i64::MAX);
assert_eq!(
decode_value(&token, FieldType::I64),
Some(SqlValue::I64(i64::MAX))
);
}
#[test]
fn cursor_decode_invalid_base64_returns_none() {
assert!(decode_value("not!valid!base64@@", FieldType::I64).is_none());
}
#[test]
fn cursor_decode_non_numeric_payload_returns_none() {
use base64::Engine;
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode("not_a_number");
assert!(decode_value(&token, FieldType::I64).is_none());
}
#[test]
fn integer_tokens_are_unchanged_by_the_generalisation() {
use base64::Engine;
let legacy = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"4242");
assert_eq!(int_token(4242), legacy);
assert_eq!(
decode_value(&legacy, FieldType::I64),
Some(SqlValue::I64(4242))
);
}
#[test]
fn composite_tokens_round_trip_both_components() {
let iso = "2026-09-14T22:41:59Z";
let token = encode_cursor(iso, Some("4242"));
let (value, tie) =
decode_cursor(&token, FieldType::DateTime, FieldType::I64).expect("decodes");
assert!(matches!(value, SqlValue::DateTime(_)), "got {value:?}");
assert_eq!(tie, Some(SqlValue::I64(4242)));
}
#[test]
fn a_bare_integer_token_is_not_read_as_composite() {
let (value, tie) =
decode_cursor(&int_token(4242), FieldType::I64, FieldType::I64).expect("decodes");
assert_eq!(value, SqlValue::I64(4242));
assert_eq!(
tie, None,
"a pre-tiebreaker token carries no second component"
);
}
#[test]
fn timestamp_cursors_round_trip() {
let iso = "2026-09-14T22:41:59Z";
let token = encode_cursor(iso, None);
let decoded = decode_value(&token, FieldType::DateTime);
match decoded {
Some(SqlValue::DateTime(dt)) => {
assert_eq!(dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), iso);
}
other => panic!("expected a DateTime cursor, got {other:?}"),
}
}
#[test]
fn uuid_and_string_cursors_round_trip() {
let u = "0199c1f4-0000-7000-8000-000000000000";
assert!(matches!(
decode_value(&encode_cursor(u, None), FieldType::Uuid),
Some(SqlValue::Uuid(_))
));
assert_eq!(
decode_value(&encode_cursor("01J8Z", None), FieldType::String),
Some(SqlValue::String("01J8Z".into()))
);
}
#[test]
fn only_totally_ordered_types_are_accepted() {
for ok in [
FieldType::I16,
FieldType::I32,
FieldType::I64,
FieldType::DateTime,
FieldType::Date,
FieldType::Uuid,
FieldType::String,
] {
assert!(cursor_field_supported(ok), "{ok:?} should be usable");
}
for bad in [
FieldType::F32,
FieldType::F64,
FieldType::Bool,
FieldType::Json,
FieldType::Binary,
] {
assert!(!cursor_field_supported(bad), "{bad:?} must not be usable");
}
}
}
#[cfg(all(test, feature = "tenancy"))]
mod tenant_router_tests {
use super::*;
#[test]
fn tenant_router_builds_for_a_basic_model() {
use crate::core::Model as _;
let _r = ViewSet::for_model(crate::tenancy::auth::User::SCHEMA)
.read_only()
.tenant_router("/api/users");
}
#[test]
fn tenant_router_carries_over_full_builder_chain() {
use crate::core::Model as _;
let _r = ViewSet::for_model(crate::tenancy::auth::User::SCHEMA)
.filter_fields(&["username"])
.search_fields(&["username"])
.ordering(&[("id", true)])
.page_size(50)
.tenant_router("/api/users");
}
#[test]
fn router_and_tenant_router_set_distinct_pool_sources() {
use crate::core::Model as _;
let static_state = ViewSet::for_model(crate::tenancy::auth::User::SCHEMA);
let vs = static_state.read_only();
let _r = vs.clone().tenant_router("/api/users");
}
}
#[cfg(all(test, feature = "mysql", feature = "tenancy"))]
mod created_pk_tests {
use super::*;
use crate::core::Model as _;
#[test]
fn a_submitted_string_pk_is_not_replaced_by_last_insert_id() {
let schema = crate::tenancy::auth::User::SCHEMA;
let pk = schema
.fields
.iter()
.find(|f| f.ty == FieldType::String)
.expect("a String field");
let q = InsertQuery::new(
schema,
vec![pk.column],
vec![SqlValue::String("rust".into())],
);
let got = crate::sql::inserted_pk(&q, &crate::sql::InsertReturningPool::MySqlAutoId(0), pk)
.expect("submitted pk");
assert!(
matches!(got, SqlValue::String(ref s) if s == "rust"),
"{got:?}"
);
}
}
#[cfg(test)]
mod lookup_tests {
use super::*;
use crate::core::{FieldSchema, FieldType};
fn int_field() -> &'static FieldSchema {
&FieldSchema {
name: "author_id",
column: "author_id",
ty: FieldType::I64,
nullable: false,
primary_key: false,
relation: None,
max_length: None,
min: None,
max: None,
default: None,
auto: false,
auto_now: false,
unique: false,
generated_as: None,
help_text: None,
choices: None,
db_comment: None,
verbose_name: None,
editable: true,
blank: false,
case_insensitive: false,
fk_on_delete: None,
validators: &[],
}
}
fn string_field() -> &'static FieldSchema {
&FieldSchema {
name: "title",
column: "title",
ty: FieldType::String,
nullable: true,
primary_key: false,
relation: None,
max_length: None,
min: None,
max: None,
default: None,
auto: false,
auto_now: false,
unique: false,
generated_as: None,
help_text: None,
choices: None,
db_comment: None,
verbose_name: None,
editable: true,
blank: false,
case_insensitive: false,
fk_on_delete: None,
validators: &[],
}
}
fn extract_pred(expr: WhereExpr) -> Filter {
match expr {
WhereExpr::Predicate(f) => f,
_ => panic!("expected Predicate"),
}
}
#[test]
fn no_lookup_means_eq() {
let f = extract_pred(
build_lookup_filter(int_field(), None, "42")
.unwrap()
.unwrap(),
);
assert_eq!(f.op, Op::Eq);
assert!(matches!(f.value, SqlValue::I64(42)));
}
#[test]
fn explicit_exact_means_eq() {
let f = extract_pred(
build_lookup_filter(int_field(), Some("exact"), "42")
.unwrap()
.unwrap(),
);
assert_eq!(f.op, Op::Eq);
}
#[test]
fn comparison_lookups() {
for (lk, expected) in [
("gt", Op::Gt),
("gte", Op::Gte),
("lt", Op::Lt),
("lte", Op::Lte),
("ne", Op::Ne),
] {
let f = extract_pred(
build_lookup_filter(int_field(), Some(lk), "10")
.unwrap()
.unwrap(),
);
assert_eq!(f.op, expected, "lookup {lk}");
}
}
#[test]
fn in_lookup_parses_csv() {
let f = extract_pred(
build_lookup_filter(int_field(), Some("in"), "1,2,3")
.unwrap()
.unwrap(),
);
assert_eq!(f.op, Op::In);
match f.value {
SqlValue::List(v) => assert_eq!(v.len(), 3),
_ => panic!("expected List"),
}
}
#[test]
fn not_in_lookup_parses_csv() {
let f = extract_pred(
build_lookup_filter(int_field(), Some("not_in"), "1,2")
.unwrap()
.unwrap(),
);
assert_eq!(f.op, Op::NotIn);
}
#[test]
fn in_lookup_drops_empty_entries() {
let f = extract_pred(
build_lookup_filter(int_field(), Some("in"), "1,,2,")
.unwrap()
.unwrap(),
);
match f.value {
SqlValue::List(v) => assert_eq!(v.len(), 2),
_ => panic!("expected List"),
}
}
#[test]
fn contains_wraps_with_percents_and_uses_like() {
let f = extract_pred(
build_lookup_filter(string_field(), Some("contains"), "hello")
.unwrap()
.unwrap(),
);
assert_eq!(f.op, Op::LikeEscaped);
assert!(matches!(f.value, SqlValue::String(ref s) if s == "%hello%"));
}
#[test]
fn icontains_uses_ilike() {
let f = extract_pred(
build_lookup_filter(string_field(), Some("icontains"), "hi")
.unwrap()
.unwrap(),
);
assert_eq!(f.op, Op::ILikeEscaped);
assert!(matches!(f.value, SqlValue::String(ref s) if s == "%hi%"));
}
#[test]
fn startswith_only_trailing_percent() {
let f = extract_pred(
build_lookup_filter(string_field(), Some("startswith"), "pre")
.unwrap()
.unwrap(),
);
assert!(matches!(f.value, SqlValue::String(ref s) if s == "pre%"));
}
#[test]
fn endswith_only_leading_percent() {
let f = extract_pred(
build_lookup_filter(string_field(), Some("endswith"), "fix")
.unwrap()
.unwrap(),
);
assert!(matches!(f.value, SqlValue::String(ref s) if s == "%fix"));
}
#[test]
fn isnull_true() {
let f = extract_pred(
build_lookup_filter(string_field(), Some("isnull"), "true")
.unwrap()
.unwrap(),
);
assert_eq!(f.op, Op::IsNull);
assert!(matches!(f.value, SqlValue::Bool(true)));
}
#[test]
fn isnull_false() {
let f = extract_pred(
build_lookup_filter(string_field(), Some("isnull"), "false")
.unwrap()
.unwrap(),
);
assert!(matches!(f.value, SqlValue::Bool(false)));
}
#[test]
fn an_empty_value_is_no_filter() {
for lk in [None, Some("ne"), Some("in"), Some("isnull")] {
assert!(build_lookup_filter(string_field(), lk, "")
.unwrap()
.is_none());
}
}
#[test]
fn an_unknown_lookup_or_a_bad_value_is_an_error() {
static DATE: FieldSchema = FieldSchema::new("on", "on", FieldType::Date);
let date = &DATE;
for (field, lk, raw) in [
(int_field(), Some("frobulate"), "1"),
(int_field(), Some("iexact"), "1"),
(int_field(), Some("contains"), "1"),
(int_field(), Some("gt"), "not-a-number"),
(int_field(), None, "abc"),
(int_field(), Some("in"), "1,abc"),
(int_field(), Some("in"), ","),
(int_field(), Some("range"), "1"),
(int_field(), Some("year"), "2024"),
(string_field(), Some("isnull"), "maybe"),
(date, Some("year"), "twenty"),
(date, Some("hour"), "1"),
(date, Some("year__contains"), "1"),
] {
let r = build_lookup_filter(field, lk, raw);
assert!(r.is_err(), "{lk:?}={raw} must be refused: {r:?}");
}
}
#[test]
fn date_parts_build_the_orm_shape() {
static DATE: FieldSchema = FieldSchema::new("on", "on", FieldType::Date);
let date = &DATE;
let r = build_lookup_filter(date, Some("year__gte"), "2024").unwrap();
assert!(
matches!(
r,
Some(WhereExpr::ExprCompare {
op: Op::Gte,
rhs: crate::core::Expr::Literal(SqlValue::I64(2024)),
..
})
),
"{r:?}"
);
}
#[test]
fn a_plain_date_bounds_a_datetime_by_the_whole_day() {
static AT: FieldSchema = FieldSchema::new("at", "at", FieldType::DateTime);
let at = &AT;
let day = |d: u32| {
chrono::NaiveDate::from_ymd_opt(2024, 1, d)
.unwrap()
.and_time(chrono::NaiveTime::MIN)
.and_utc()
};
let f = extract_pred(
build_lookup_filter(at, Some("gte"), "2024-01-01")
.unwrap()
.unwrap(),
);
assert_eq!(f.op, Op::Gte);
assert!(matches!(f.value, SqlValue::DateTime(v) if v == day(1)));
let f = extract_pred(
build_lookup_filter(at, Some("lte"), "2024-01-01")
.unwrap()
.unwrap(),
);
assert_eq!(f.op, Op::Lt);
assert!(matches!(f.value, SqlValue::DateTime(v) if v == day(2)));
assert!(build_lookup_filter(at, Some("gt"), "2024-01-01").is_err());
}
}
#[cfg(all(test, feature = "tenancy"))]
mod typed_perms_tests {
use super::*;
use crate::sql::Auto;
#[derive(crate::Model)]
#[rustango(table = "vs_typed_perm_post")]
#[allow(dead_code)]
pub struct PermPost {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(max_length = 200)]
pub title: String,
}
#[test]
fn permissions_for_model_fills_all_four_crud_codenames() {
use crate::core::Model;
let vs =
ViewSet::for_model(<PermPost as Model>::SCHEMA).permissions_for_model::<PermPost>();
assert_eq!(vs.perms.list, vec!["vs_typed_perm_post.view"]);
assert_eq!(vs.perms.retrieve, vec!["vs_typed_perm_post.view"]);
assert_eq!(vs.perms.create, vec!["vs_typed_perm_post.add"]);
assert_eq!(vs.perms.update, vec!["vs_typed_perm_post.change"]);
assert_eq!(vs.perms.destroy, vec!["vs_typed_perm_post.delete"]);
}
}
#[cfg(test)]
mod default_order_tests {
use super::*;
use crate::core::{FieldSchema, FieldType};
use crate::sql::Dialect as _;
static FIELDS: &[FieldSchema] = &[
FieldSchema {
primary_key: true,
..FieldSchema::new("id", "id", FieldType::I64)
},
FieldSchema::new("title", "title", FieldType::String),
];
#[test]
fn the_list_orders_by_default_order_then_the_pk() {
let mut schema = ModelSchema::new("vs_do", "vs_do");
schema.fields = FIELDS;
schema.default_order = &[("title", true)];
let schema: &'static ModelSchema = Box::leak(Box::new(schema));
let mut q = SelectQuery::new(schema);
q.order_by = schema.with_pk_tiebreak(default_order_by(&ViewSet::for_model(schema)));
let sql = crate::sql::Sqlite.compile_select(&q).unwrap().sql;
assert!(sql.ends_with(r#"ORDER BY "title" DESC, "id""#), "{sql}");
}
}