Overhaul schema metadata handling in preparation for geometry columns.

This commit is contained in:
Sebastian Jeltsch
2026-02-16 22:36:16 +01:00
parent 530f4aba6d
commit 5ffefa7b60
27 changed files with 613 additions and 544 deletions
+1
View File
@@ -48,6 +48,7 @@ ed25519-dalek = { version = "2.1.1", features = ["pkcs8", "pem", "rand_core"] }
fallible-iterator = "0.3.0"
form_urlencoded = "1.2.1"
futures-util = { version = "0.3", default-features = false, features = ["alloc"] }
# geos = { version = "10.0.0", default-features = false, features = ["geo", "json"] }
http-body-util = "0.1.3"
hyper = "1.6.0"
hyper-util = "0.1.7"
+1 -1
View File
@@ -55,7 +55,7 @@ pub async fn list_logs_handler(
&[table],
)?;
build_filter_where_clause(TABLE_ALIAS, &table_metadata.schema.columns, filter_params)?
build_filter_where_clause(TABLE_ALIAS, &table_metadata.column_metadata, filter_params)?
};
let total_row_count: i64 = conn
+1 -1
View File
@@ -52,7 +52,7 @@ pub async fn fetch_stats_handler(
&[table],
)?;
build_filter_where_clause(TABLE_ALIAS, &table_metadata.schema.columns, filter_params)?
build_filter_where_clause(TABLE_ALIAS, &table_metadata.column_metadata, filter_params)?
};
let now = Utc::now();
+6 -3
View File
@@ -5,7 +5,7 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use trailbase_schema::{QualifiedName, QualifiedNameEscaped};
use trailbase_schema::{QualifiedName, QualifiedNameEscaped, metadata::find_file_column_indexes};
use trailbase_sqlvalue::SqlValue;
use ts_rs::TS;
@@ -54,21 +54,24 @@ pub(crate) async fn delete_row(
)));
};
let Some((_index, column)) = table_metadata.column_by_name(pk_col) else {
let Some(meta) = table_metadata.column_by_name(pk_col) else {
return Err(Error::Precondition(format!("Missing column: {pk_col}")));
};
let column = &meta.column;
if !column.is_primary() {
return Err(Error::Precondition(format!("Not a primary key: {pk_col}")));
}
let has_file_columns = !find_file_column_indexes(&table_metadata.column_metadata).is_empty();
run_delete_query(
&conn,
state.objectstore(),
&QualifiedNameEscaped::from(&table_metadata.schema.name),
pk_col,
pk_value.try_into()?,
table_metadata.json_metadata.has_file_columns(),
has_file_columns,
)
.await?;
+9 -6
View File
@@ -90,7 +90,7 @@ pub async fn list_rows_handler(
let cursor_column = table_or_view.record_pk_column();
let cursor = match (cursor, cursor_column) {
(Some(cursor), Some((_idx, c))) => Some(parse_cursor(&cursor, c)?),
(Some(cursor), Some(meta)) => Some(parse_cursor(&cursor, &meta.column)?),
_ => None,
};
let (rows, columns) = fetch_rows(
@@ -99,7 +99,7 @@ pub async fn list_rows_handler(
filter_where_clause,
&order,
Pagination {
cursor_column: cursor_column.map(|(_idx, c)| c),
cursor_column: cursor_column.map(|meta| &meta.column),
cursor,
offset,
limit: limit_or_default(limit, None).map_err(|err| Error::BadRequest(err.into()))?,
@@ -108,10 +108,10 @@ pub async fn list_rows_handler(
.await?;
let next_cursor = if order.is_none() {
cursor_column.and_then(|(col_idx, _col)| {
cursor_column.and_then(|meta| {
let row = rows.last()?;
assert!(row.len() > col_idx);
match &row[col_idx] {
assert!(row.len() > meta.index);
match &row[meta.index] {
SqlValue::Integer(n) => Some(n.to_string()),
SqlValue::Blob(b) => {
// Should be a base64 encoded [u8; 16] id.
@@ -130,7 +130,10 @@ pub async fn list_rows_handler(
// NOTE: in the view case we don't have a good way of extracting the columns from the "CREATE
// VIEW" query so we fall back to columns constructed from the returned data.
columns: match table_or_view.columns() {
Some(schema_columns) if !schema_columns.is_empty() => schema_columns.to_vec(),
Some(schema_columns) if !schema_columns.is_empty() => schema_columns
.iter()
.map(|meta| meta.column.clone())
.collect(),
_ => {
// VIRTUAL TABLE or VIEW case.
debug!("Falling back to inferred cols for view: {table_name:?}");
+4 -19
View File
@@ -3,7 +3,6 @@ use axum::{
response::Response,
};
use serde::Deserialize;
use trailbase_schema::metadata::TableOrViewMetadata;
use trailbase_schema::{FileUploads, QualifiedName};
use ts_rs::TS;
@@ -39,45 +38,31 @@ pub async fn read_files_handler(
"Table {table_name:?} not found"
)));
};
let Some((_index, pk_col)) = table_or_view.column_by_name(&query.pk_column) else {
let Some(pk_meta) = table_or_view.column_by_name(&query.pk_column) else {
return Err(Error::Precondition(format!(
"Missing PK column: {}",
query.pk_column
)));
};
let pk_col = &pk_meta.column;
if !pk_col.is_primary() {
return Err(Error::Precondition(format!(
"Not a primary key: {pk_col:?}"
)));
}
let Some((index, file_col_metadata)) = table_or_view.column_by_name(&query.file_column_name)
else {
let Some(file_column_meta) = table_or_view.column_by_name(&query.file_column_name) else {
return Err(Error::Precondition(format!(
"Missing file column: {}",
query.file_column_name
)));
};
let Some(file_col_json_metadata) = (match table_or_view {
TableOrViewMetadata::Table(t) => t.json_metadata.columns[index].as_ref(),
TableOrViewMetadata::View(v) => v
.json_metadata
.as_ref()
.and_then(|j| j.columns[index].as_ref()),
}) else {
return Err(Error::Precondition(format!(
"Not a JSON column: {}",
query.file_column_name
)));
};
let FileUploads(mut file_uploads) = run_get_files_query(
&conn,
&table_name.into(),
file_col_metadata,
file_col_json_metadata,
file_column_meta,
&query.pk_column,
trailbase_schema::json::parse_string_to_sqlite_value(pk_col.data_type, query.pk_value)?,
)
+3 -2
View File
@@ -46,16 +46,17 @@ pub async fn update_row_handler(
};
let pk_col = &request.primary_key_column;
let Some((index, column)) = table_metadata.column_by_name(pk_col) else {
let Some(meta) = table_metadata.column_by_name(pk_col) else {
return Err(Error::Precondition(format!("Missing column: {pk_col}")));
};
if let Some(pk_index) = table_metadata.record_pk_column
&& index != pk_index
&& meta.index != pk_index
{
return Err(Error::Precondition(format!("Pk column mismatch: {pk_col}")));
}
let column = &meta.column;
if !column.is_primary() {
return Err(Error::Precondition(format!("Not a primary key: {pk_col}")));
}
+1 -1
View File
@@ -81,7 +81,7 @@ pub async fn list_users_handler(
// Where clause contains column filters and offset depending on what's present in the url query
// string.
let filter_where_clause =
build_filter_where_clause("_ROW_", &table_metadata.schema.columns, filter_params)?;
build_filter_where_clause("_ROW_", &table_metadata.column_metadata, filter_params)?;
let total_row_count: i64 = conn
.read_query_row_f(
+2 -7
View File
@@ -39,19 +39,14 @@ pub async fn get_avatar_handler(
return Err(AuthError::Internal("missing table".into()));
};
let Some((index, file_column)) = table_metadata.column_by_name("file") else {
let Some(file_column_meta) = table_metadata.column_by_name("file") else {
return Err(AuthError::Internal("missing column".into()));
};
let Some(ref column_json_metadata) = table_metadata.json_metadata.columns[index] else {
return Err(AuthError::Internal("missing metadata".into()));
};
let file_upload = run_get_file_query(
&conn,
&trailbase_schema::QualifiedNameEscaped::new(&AVATAR_TABLE_NAME),
file_column,
column_json_metadata,
file_column_meta,
"user",
rusqlite::types::Value::Blob(user_id.into()),
)
+6 -3
View File
@@ -462,7 +462,11 @@ fn setup_file_deletion_triggers_sync(
metadata: &ConnectionMetadata,
) -> Result<(), trailbase_sqlite::Error> {
for metadata in metadata.tables.values() {
for idx in metadata.json_metadata.file_column_indexes() {
for column_meta in &metadata.column_metadata {
if !column_meta.is_file {
continue;
}
let table_name = &metadata.schema.name;
let unqualified_name = &metadata.schema.name.name;
let db = metadata
@@ -472,8 +476,7 @@ fn setup_file_deletion_triggers_sync(
.as_deref()
.unwrap_or("main");
let col = &metadata.schema.columns[*idx];
let column_name = &col.name;
let column_name = &column_meta.column.name;
conn.execute_batch(&format!(
"\
+7 -4
View File
@@ -1,7 +1,7 @@
use std::borrow::Cow;
use thiserror::Error;
use trailbase_qs::ValueOrComposite;
use trailbase_schema::sqlite::Column;
use trailbase_schema::metadata::ColumnMetadata;
#[derive(Debug, Error)]
pub enum WhereClauseError {
@@ -23,7 +23,7 @@ pub struct WhereClause {
pub(crate) fn build_filter_where_clause(
table_name: &str,
columns: &[Column],
column_metadata: &[ColumnMetadata],
filter_params: Option<ValueOrComposite>,
) -> Result<WhereClause, WhereClauseError> {
let Some(filter_params) = filter_params else {
@@ -42,14 +42,17 @@ pub(crate) fn build_filter_where_clause(
)));
}
let Some(column) = columns.iter().find(|c| c.name == column_name) else {
let Some(meta) = column_metadata
.iter()
.find(|meta| meta.column.name == column_name)
else {
return Err(WhereClauseError::UnrecognizedParam(format!(
"Unrecognized parameter: {column_name}"
)));
};
// TODO: Improve hacky error handling.
return crate::records::filter::qs_value_to_sql_with_constraints(column, value)
return crate::records::filter::qs_value_to_sql_with_constraints(&meta.column, value)
.map_err(|err| WhereClauseError::UnrecognizedParam(err.to_string()));
};
+4 -4
View File
@@ -105,7 +105,7 @@ pub async fn create_record_handler(
&& let Some(ref user) = user
{
for column_index in api.user_id_columns() {
let col_name = &api.columns()[*column_index].name;
let col_name = &api.columns()[*column_index].column.name;
if !record.contains_key(col_name) {
record.insert(
col_name.to_owned(),
@@ -136,7 +136,7 @@ pub async fn create_record_handler(
);
}
let (_index, pk_column) = api.record_pk_column();
let pk_meta = api.record_pk_column();
let record_ids: Vec<String> = match params_list.len() {
0 => {
return Err(RecordError::BadRequest("no values provided"));
@@ -147,7 +147,7 @@ pub async fn create_record_handler(
state.objectstore(),
api.table_name(),
api.insert_conflict_resolution_strategy(),
&pk_column.name,
&pk_meta.column.name,
params_list.swap_remove(0),
)
.await?;
@@ -161,7 +161,7 @@ pub async fn create_record_handler(
let table_name: QualifiedNameEscaped = api.table_name().clone();
let (query, files) = WriteQuery::new_insert(
&table_name,
&pk_column.name,
&pk_meta.column.name,
api.insert_conflict_resolution_strategy(),
params,
)
+2 -2
View File
@@ -36,13 +36,13 @@ pub async fn delete_record_handler(
.check_record_level_access(Permission::Delete, Some(&record_id), None, user.as_ref())
.await?;
let (_index, pk_column) = api.record_pk_column();
let pk_meta = api.record_pk_column();
run_delete_query(
api.conn(),
state.objectstore(),
api.table_name(),
&pk_column.name,
&pk_meta.column.name,
record_id,
api.has_file_columns(),
)
+71 -72
View File
@@ -4,7 +4,8 @@ use std::collections::HashMap;
use thiserror::Error;
use trailbase_schema::QualifiedName;
use trailbase_schema::json::value_to_flat_json;
use trailbase_schema::sqlite::{Column, ColumnOption};
use trailbase_schema::metadata::ColumnMetadata;
use trailbase_schema::sqlite::ColumnOption;
use crate::records::RecordError;
use crate::records::record_api::RecordApi;
@@ -56,79 +57,84 @@ fn is_foreign_key(options: &[ColumnOption]) -> bool {
/// Serialize SQL row to json.
pub(crate) fn row_to_json_expand(
columns: &[Column],
json_metadata: &[Option<JsonColumnMetadata>],
column_metadata: &[ColumnMetadata],
row: &trailbase_sqlite::Row,
column_filter: fn(&str) -> bool,
expand: Option<&HashMap<String, serde_json::Value>>,
) -> Result<serde_json::Value, JsonError> {
// Row may contain extra columns like trailing "_rowid_".
assert!(columns.len() <= row.column_count());
assert_eq!(columns.len(), json_metadata.len());
// Row may contain extra columns like trailing "_rowid_" or excluded columns.
if column_metadata.len() > row.column_count() {
return Err(JsonError::NotSupported);
}
return Ok(serde_json::Value::Object(
(0..columns.len())
.filter(|i| column_filter(&columns[*i].name))
.map(|i| -> Result<(String, serde_json::Value), JsonError> {
let column = &columns[i];
column_metadata
.iter()
.enumerate()
.filter(|(_i, meta)| column_filter(&meta.column.name))
.map(
|(i, meta)| -> Result<(String, serde_json::Value), JsonError> {
let column = &meta.column;
if column.name.as_str() != row.column_name(i).unwrap_or_default() {
return Err(JsonError::NotSupported);
}
assert_eq!(Some(column.name.as_str()), row.column_name(i));
let value = row.get_value(i).ok_or(JsonError::ValueNotFound)?;
if matches!(value, types::Value::Null) {
return Ok((column.name.clone(), serde_json::Value::Null));
}
let value = row.get_value(i).ok_or(JsonError::ValueNotFound)?;
if matches!(value, types::Value::Null) {
return Ok((column.name.clone(), serde_json::Value::Null));
}
if let Some(foreign_value) = expand.and_then(|e| e.get(&column.name))
&& is_foreign_key(&column.options)
{
let id = value_to_flat_json(value)?;
if let Some(foreign_value) = expand.and_then(|e| e.get(&column.name))
&& is_foreign_key(&column.options)
{
let id = value_to_flat_json(value)?;
return Ok(match foreign_value {
serde_json::Value::Null => (
column.name.clone(),
serde_json::json!({
"id": id,
}),
),
value => (
column.name.clone(),
serde_json::json!({
"id": id,
"data": value,
}),
),
});
}
return Ok(match foreign_value {
serde_json::Value::Null => (
column.name.clone(),
serde_json::json!({
"id": id,
}),
),
value => (
column.name.clone(),
serde_json::json!({
"id": id,
"data": value,
}),
),
});
}
// Deserialize JSON.
if let types::Value::Text(str) = value {
match json_metadata[i].as_ref() {
Some(JsonColumnMetadata::SchemaName(x)) if x == "std.FileUpload" => {
#[allow(unused_mut)]
let mut value: serde_json::Value = serde_json::from_str(str)?;
#[cfg(not(test))]
value.as_object_mut().map(|o| o.remove("id"));
return Ok((column.name.clone(), value));
}
Some(JsonColumnMetadata::SchemaName(x)) if x == "std.FileUploads" => {
#[allow(unused_mut)]
let mut values: Vec<serde_json::Value> = serde_json::from_str(str)?;
#[cfg(not(test))]
for value in &mut values {
// Deserialize JSON.
if let types::Value::Text(str) = value {
match meta.json.as_ref() {
Some(JsonColumnMetadata::SchemaName(x)) if x == "std.FileUpload" => {
#[allow(unused_mut)]
let mut value: serde_json::Value = serde_json::from_str(str)?;
#[cfg(not(test))]
value.as_object_mut().map(|o| o.remove("id"));
return Ok((column.name.clone(), value));
}
return Ok((column.name.clone(), serde_json::Value::Array(values)));
}
Some(JsonColumnMetadata::SchemaName(_)) | Some(JsonColumnMetadata::Pattern(_)) => {
return Ok((column.name.clone(), serde_json::from_str(str)?));
}
None => {}
};
}
Some(JsonColumnMetadata::SchemaName(x)) if x == "std.FileUploads" => {
#[allow(unused_mut)]
let mut values: Vec<serde_json::Value> = serde_json::from_str(str)?;
#[cfg(not(test))]
for value in &mut values {
value.as_object_mut().map(|o| o.remove("id"));
}
return Ok((column.name.clone(), serde_json::Value::Array(values)));
}
Some(JsonColumnMetadata::SchemaName(_)) | Some(JsonColumnMetadata::Pattern(_)) => {
return Ok((column.name.clone(), serde_json::from_str(str)?));
}
None => {}
};
}
return Ok((column.name.clone(), value_to_flat_json(value)?));
})
return Ok((column.name.clone(), value_to_flat_json(value)?));
},
)
.collect::<Result<_, JsonError>>()?,
));
}
@@ -155,7 +161,7 @@ pub(crate) fn expand_tables<'s, T: AsRef<str>>(
continue;
}
let Some(column) = record_api
let Some(meta) = record_api
.column_index_by_name(col_name)
.map(|idx| &record_api.columns()[idx])
else {
@@ -167,7 +173,8 @@ pub(crate) fn expand_tables<'s, T: AsRef<str>>(
foreign_table: foreign_table_name,
referred_columns: _,
..
}) = column
}) = meta
.column
.options
.iter()
.find_or_first(|o| matches!(o, ColumnOption::ForeignKey { .. }))
@@ -295,15 +302,7 @@ mod tests {
let parsed = rows
.iter()
.map(|row| {
row_to_json_expand(
&metadata.schema.columns,
&metadata.json_metadata.columns,
row,
|_| true,
None,
)
})
.map(|row| row_to_json_expand(&metadata.column_metadata, row, |_| true, None))
.collect::<Result<Vec<_>, _>>()
.unwrap();
+9 -6
View File
@@ -1,6 +1,9 @@
use regex::Regex;
use trailbase_qs::{Combiner, CompareOp};
use trailbase_schema::sqlite::{Column, ColumnDataType};
use trailbase_schema::{
metadata::ColumnMetadata,
sqlite::{Column, ColumnDataType},
};
use crate::records::RecordError;
@@ -76,20 +79,20 @@ pub(crate) fn qs_value_to_sql_with_constraints(
}
pub(crate) fn qs_filter_to_record_filter(
columns: &[Column],
column_metadata: &[ColumnMetadata],
filter: trailbase_qs::ValueOrComposite,
) -> Result<ValueOrComposite, RecordError> {
return match filter {
trailbase_qs::ValueOrComposite::Value(col_op_value) => {
let column = columns
let meta = column_metadata
.iter()
.find(|c| c.name == col_op_value.column)
.find(|meta| meta.column.name == col_op_value.column)
.ok_or_else(|| RecordError::BadRequest("Invalid query"))?;
Ok(ValueOrComposite::Value(ColumnOpValue {
column: col_op_value.column,
op: col_op_value.op,
value: qs_value_to_sql_with_constraints(column, col_op_value.value)?,
value: qs_value_to_sql_with_constraints(&meta.column, col_op_value.value)?,
}))
}
trailbase_qs::ValueOrComposite::Composite(combiner, expressions) => {
@@ -97,7 +100,7 @@ pub(crate) fn qs_filter_to_record_filter(
combiner,
expressions
.into_iter()
.map(|value_or_composite| qs_filter_to_record_filter(columns, value_or_composite))
.map(|value_or_composite| qs_filter_to_record_filter(column_metadata, value_or_composite))
.collect::<Result<Vec<_>, _>>()?,
))
}
+7 -21
View File
@@ -72,7 +72,8 @@ pub async fn list_records_handler(
api.check_table_level_access(Permission::Read, user.as_ref())?;
let table_name = api.table_name();
let (_pk_index, pk_column) = api.record_pk_column();
let pk_meta = api.record_pk_column();
let pk_column = &pk_meta.column;
let is_table = api.is_table();
let Query {
@@ -208,7 +209,7 @@ pub async fn list_records_handler(
column_names: &api
.columns()
.iter()
.map(|c| c.name.as_str())
.map(|meta| meta.column.name.as_str())
.collect::<Vec<_>>(),
// NOTE: We're using the read access rule to filter accessible rows as opposed to blocking
// access early as we do for READs.
@@ -278,15 +279,7 @@ pub async fn list_records_handler(
let records = if expanded_tables.is_empty() {
rows
.into_iter()
.map(|row| {
row_to_json_expand(
api.columns(),
api.json_column_metadata(),
&row,
column_filter,
api.expand(),
)
})
.map(|row| row_to_json_expand(api.columns(), &row, column_filter, api.expand()))
.collect::<Result<Vec<_>, JsonError>>()
.map_err(|err| RecordError::Internal(err.into()))?
} else {
@@ -306,8 +299,7 @@ pub async fn list_records_handler(
let next = curr.split_off(expanded.num_columns);
let foreign_value = row_to_json_expand(
&expanded.metadata.schema.columns,
&expanded.metadata.json_metadata.columns,
&expanded.metadata.column_metadata,
&curr,
column_filter,
None,
@@ -320,14 +312,8 @@ pub async fn list_records_handler(
curr = next;
}
return row_to_json_expand(
api.columns(),
api.json_column_metadata(),
&row,
column_filter,
Some(&expand),
)
.map_err(|err| RecordError::Internal(err.into()));
return row_to_json_expand(api.columns(), &row, column_filter, Some(&expand))
.map_err(|err| RecordError::Internal(err.into()));
})
.collect::<Result<Vec<_>, RecordError>>()?
};
+102 -53
View File
@@ -2,6 +2,7 @@ use parking_lot::RwLock;
use std::collections::HashSet;
use std::sync::Arc;
use trailbase_schema::json::flat_json_to_value;
use trailbase_schema::metadata::ColumnMetadata;
use trailbase_schema::registry::JsonSchemaRegistry;
use trailbase_schema::sqlite::{Column, ColumnDataType};
use trailbase_schema::{FileUpload, FileUploadInput, FileUploads};
@@ -41,6 +42,8 @@ pub enum ParamsError {
Storage(Arc<object_store::Error>),
#[error("SqlValueDecode: {0}")]
SqlValueDecode(#[from] trailbase_sqlvalue::DecodeError),
// #[error("Geos: {0}")]
// Geos(#[from] geos::Error),
}
impl From<serde_json::Error> for ParamsError {
@@ -76,40 +79,23 @@ pub(crate) type FileMetadataContents = Vec<(FileUpload, Vec<u8>)>;
pub(crate) type JsonRow = serde_json::Map<String, serde_json::Value>;
pub trait SchemaAccessor {
fn column_by_name(
&self,
field_name: &str,
) -> Option<(usize, &Column, Option<&JsonColumnMetadata>)>;
pub trait ColumnAccessor {
fn column_by_name(&self, field_name: &str) -> Option<&ColumnMetadata>;
}
/// Implementation to build insert/update Params for admin APIs.
impl SchemaAccessor for TableMetadata {
impl ColumnAccessor for TableMetadata {
#[inline]
fn column_by_name(
&self,
field_name: &str,
) -> Option<(usize, &Column, Option<&JsonColumnMetadata>)> {
return self
.column_by_name(field_name)
.map(|(index, col)| (index, col, self.json_metadata.columns[index].as_ref()));
fn column_by_name(&self, field_name: &str) -> Option<&ColumnMetadata> {
return self.column_by_name(field_name);
}
}
/// Implementation to build insert/update Params for record APIs.
impl SchemaAccessor for RecordApi {
impl ColumnAccessor for RecordApi {
#[inline]
fn column_by_name(
&self,
field_name: &str,
) -> Option<(usize, &Column, Option<&JsonColumnMetadata>)> {
return self.column_index_by_name(field_name).map(|index| {
return (
index,
&self.columns()[index],
self.json_column_metadata()[index].as_ref(),
);
});
fn column_by_name(&self, field_name: &str) -> Option<&ColumnMetadata> {
return self.column_metadata_by_name(field_name);
}
}
@@ -149,7 +135,7 @@ pub enum Params {
impl Params {
/// Converts a Json object + optional MultiPart files into trailbase_sqlite::Values and extracted
/// files.
pub fn for_insert<S: SchemaAccessor>(
pub fn for_insert<S: ColumnAccessor>(
accessor: &S,
json_schema_registry: &JsonSchemaRegistry,
row: JsonRow,
@@ -165,12 +151,24 @@ impl Params {
for (key, value) in row {
// We simply skip unknown columns, this could simply be malformed input or version skew. This
// is similar in spirit to protobuf's unknown fields behavior.
let Some((index, col, json_meta)) = accessor.column_by_name(&key) else {
let Some(ColumnMetadata {
index,
column,
json,
is_file: _,
is_geometry,
}) = accessor.column_by_name(&key)
else {
continue;
};
let (param, json_files) =
extract_params_and_files_from_json(json_schema_registry, col, json_meta, value)?;
let (param, json_files) = extract_params_and_files_from_json(
json_schema_registry,
column,
json.as_ref(),
*is_geometry,
value,
)?;
if let Some(json_files) = json_files {
// Note: files provided as a multipart form upload are handled below. They need more
// special handling to establish the field.name to column mapping.
@@ -179,7 +177,7 @@ impl Params {
named_params.push((prefix_colon(&key).into(), param));
column_names.push(key);
column_indexes.push(index);
column_indexes.push(*index);
}
// Note: files provided as part of a JSON request are handled above.
@@ -201,7 +199,7 @@ impl Params {
});
}
pub fn for_admin_insert<S: SchemaAccessor>(
pub fn for_admin_insert<S: ColumnAccessor>(
accessor: &S,
row: indexmap::IndexMap<String, SqlValue>,
) -> Result<Self, ParamsError> {
@@ -213,13 +211,20 @@ impl Params {
for (key, value) in row {
// We simply skip unknown columns, this could simply be malformed input or version skew. This
// is similar in spirit to protobuf's unknown fields behavior.
let Some((index, _col, _json_meta)) = accessor.column_by_name(&key) else {
let Some(ColumnMetadata {
index,
column: _,
json: _,
is_file: _,
is_geometry: _,
}) = accessor.column_by_name(&key)
else {
continue;
};
named_params.push((prefix_colon(&key).into(), value.try_into()?));
column_names.push(key);
column_indexes.push(index);
column_indexes.push(*index);
}
return Ok(Params::Insert {
@@ -230,7 +235,7 @@ impl Params {
});
}
pub fn for_update<S: SchemaAccessor>(
pub fn for_update<S: ColumnAccessor>(
accessor: &S,
json_schema_registry: &JsonSchemaRegistry,
row: JsonRow,
@@ -248,12 +253,24 @@ impl Params {
for (key, value) in row {
// We simply skip unknown columns, this could simply be malformed input or version skew. This
// is similar in spirit to protobuf's unknown fields behavior.
let Some((index, col, json_meta)) = accessor.column_by_name(&key) else {
let Some(ColumnMetadata {
index,
column,
json,
is_file: _,
is_geometry,
}) = accessor.column_by_name(&key)
else {
continue;
};
let (param, json_files) =
extract_params_and_files_from_json(json_schema_registry, col, json_meta, value)?;
let (param, json_files) = extract_params_and_files_from_json(
json_schema_registry,
column,
json.as_ref(),
*is_geometry,
value,
)?;
if let Some(json_files) = json_files {
// Note: files provided as a multipart form upload are handled below. They need more
// special handling to establish the field.name to column mapping.
@@ -268,7 +285,7 @@ impl Params {
named_params.push((prefix_colon(&key).into(), param));
column_names.push(key);
column_indexes.push(index);
column_indexes.push(*index);
}
// Inject the pk_value. It may already be present, if redundantly provided both in the API path
@@ -295,7 +312,7 @@ impl Params {
});
}
pub fn for_admin_update<S: SchemaAccessor>(
pub fn for_admin_update<S: ColumnAccessor>(
accessor: &S,
row: indexmap::IndexMap<String, SqlValue>,
pk_column_name: String,
@@ -311,7 +328,14 @@ impl Params {
for (key, value) in row {
// We simply skip unknown columns, this could simply be malformed input or version skew. This
// is similar in spirit to protobuf's unknown fields behavior.
let Some((index, _col, _json_meta)) = accessor.column_by_name(&key) else {
let Some(ColumnMetadata {
index,
column: _,
json: _,
is_file: _,
is_geometry: _,
}) = accessor.column_by_name(&key)
else {
continue;
};
@@ -325,7 +349,7 @@ impl Params {
named_params.push((prefix_colon(&key).into(), param));
column_names.push(key);
column_indexes.push(index);
column_indexes.push(*index);
}
// Inject the pk_value. It may already be present, if redundantly provided both in the API path
@@ -354,7 +378,7 @@ pub enum LazyParams<'a> {
}
impl<'a> LazyParams<'a> {
pub fn for_insert<S: SchemaAccessor + Sync>(
pub fn for_insert<S: ColumnAccessor + Sync>(
accessor: &'a S,
json_schema_registry: Arc<RwLock<JsonSchemaRegistry>>,
json_row: JsonRow,
@@ -370,7 +394,7 @@ impl<'a> LazyParams<'a> {
})));
}
pub fn for_update<S: SchemaAccessor + Sync>(
pub fn for_update<S: ColumnAccessor + Sync>(
accessor: &'a S,
json_schema_registry: Arc<RwLock<JsonSchemaRegistry>>,
json_row: JsonRow,
@@ -418,7 +442,7 @@ impl<'a> LazyParams<'a> {
}
}
fn extract_files_from_multipart<S: SchemaAccessor>(
fn extract_files_from_multipart<S: ColumnAccessor>(
accessor: &S,
multipart_files: Vec<FileUploadInput>,
named_params: &mut NamedParams,
@@ -443,36 +467,43 @@ fn extract_files_from_multipart<S: SchemaAccessor>(
for (field_name, file_metadata, _content) in &files {
// We simply skip unknown columns, this could simply be malformed input or version skew. This
// is similar in spirit to protobuf's unknown fields behavior.
let Some((index, col, json_meta)) = accessor.column_by_name(field_name) else {
let Some(ColumnMetadata {
index,
column,
json,
is_file: _,
is_geometry: _,
}) = accessor.column_by_name(field_name)
else {
continue;
};
let Some(JsonColumnMetadata::SchemaName(schema_name)) = &json_meta else {
let Some(JsonColumnMetadata::SchemaName(schema_name)) = &json else {
return Err(ParamsError::Column("Expected json file column"));
};
match schema_name.as_str() {
"std.FileUpload" => {
if !uploaded_files.insert(&col.name) {
if !uploaded_files.insert(&column.name) {
return Err(ParamsError::Column(
"Collision: too many files for std.FileUpload",
));
}
named_params.push((
prefix_colon(&col.name).into(),
prefix_colon(&column.name).into(),
Value::Text(serde_json::to_string(&file_metadata)?),
));
column_names.push(col.name.to_string());
column_indexes.push(index);
column_names.push(column.name.to_string());
column_indexes.push(*index);
}
"std.FileUploads" => {
named_params.push((
prefix_colon(&col.name).into(),
prefix_colon(&column.name).into(),
Value::Text(serde_json::to_string(&file_metadata)?),
));
column_names.push(col.name.to_string());
column_indexes.push(index);
column_names.push(column.name.to_string());
column_indexes.push(*index);
}
_ => {
return Err(ParamsError::Column("Mismatching JSON schema"));
@@ -492,10 +523,28 @@ fn extract_params_and_files_from_json(
json_schema_registry: &JsonSchemaRegistry,
col: &Column,
json_metadata: Option<&JsonColumnMetadata>,
is_geometry: bool,
value: serde_json::Value,
) -> Result<(Value, Option<FileMetadataContents>), ParamsError> {
// If this is *not* a JSON column convert the value trivially.
let Some(json_metadata) = json_metadata else {
// if is_geometry && col.data_type == ColumnDataType::Blob {
// use geos::Geom;
//
// let json_geometry = geos::geojson::Geometry::from_json_value(value)
// .map_err(|err| ParamsError::UnexpectedType("", format!("GeoJSON: {err}")))?;
// let geometry: geos::Geometry = json_geometry.try_into()?;
//
// let mut writer = geos::WKBWriter::new()?;
// if let Some(_) = geometry.get_srid().ok() {
// writer.set_include_SRID(true);
// }
//
// return Ok((Value::Blob(writer.write_wkb(&geometry)?.into()), None));
// }
debug_assert!(!is_geometry);
return Ok((flat_json_to_value(col.data_type, value)?, None));
};
+11 -21
View File
@@ -1,5 +1,5 @@
use askama::Template;
use trailbase_schema::sqlite::Column;
use trailbase_schema::metadata::ColumnMetadata;
use trailbase_schema::{FileUpload, FileUploads, QualifiedNameEscaped};
use trailbase_sqlite::Value;
@@ -70,14 +70,13 @@ pub(crate) async fn run_expanded_select_query<'a>(
pub(crate) async fn run_get_file_query(
conn: &trailbase_sqlite::Connection,
table_name: &QualifiedNameEscaped,
file_column: &Column,
json_metadata: &JsonColumnMetadata,
file_column_meta: &ColumnMetadata,
pk_column: &str,
pk_value: Value,
) -> Result<FileUpload, RecordError> {
return match &json_metadata {
JsonColumnMetadata::SchemaName(name) if name == "std.FileUpload" => {
let column_name = &file_column.name;
return match &file_column_meta.json {
Some(JsonColumnMetadata::SchemaName(name)) if name == "std.FileUpload" => {
let column_name = &file_column_meta.column.name;
let Some(row) = conn
.read_query_row(
@@ -104,14 +103,13 @@ pub(crate) async fn run_get_file_query(
pub(crate) async fn run_get_files_query(
conn: &trailbase_sqlite::Connection,
table_name: &QualifiedNameEscaped,
file_column: &Column,
json_metadata: &JsonColumnMetadata,
file_column_meta: &ColumnMetadata,
pk_column: &str,
pk_value: Value,
) -> Result<FileUploads, RecordError> {
return match &json_metadata {
JsonColumnMetadata::SchemaName(name) if name == "std.FileUploads" => {
let column_name = &file_column.name;
return match &file_column_meta.json {
Some(JsonColumnMetadata::SchemaName(name)) if name == "std.FileUploads" => {
let column_name = &file_column_meta.column.name;
let Some(row) = conn
.read_query_row(
@@ -131,17 +129,9 @@ pub(crate) async fn run_get_files_query(
serde_json::from_str(&contents).map_err(|err| RecordError::Internal(err.into()))?;
Ok(file_uploads)
}
JsonColumnMetadata::SchemaName(name) if name == "std.FileUpload" => {
Some(JsonColumnMetadata::SchemaName(name)) if name == "std.FileUpload" => {
return Ok(FileUploads(vec![
run_get_file_query(
conn,
table_name,
file_column,
json_metadata,
pk_column,
pk_value,
)
.await?,
run_get_file_query(conn, table_name, file_column_meta, pk_column, pk_value).await?,
]));
}
_ => Err(RecordError::BadRequest("Not a files list")),
+71 -88
View File
@@ -11,7 +11,6 @@ use crate::auth::user::User;
use crate::records::expand::expand_tables;
use crate::records::expand::row_to_json_expand;
use crate::records::files::read_file_into_response;
use crate::records::params::SchemaAccessor;
use crate::records::read_queries::{
ExpandedSelectQueryResult, run_expanded_select_query, run_get_file_query, run_get_files_query,
run_select_query,
@@ -51,89 +50,80 @@ pub async fn read_record_handler(
.check_record_level_access(Permission::Read, Some(&record_id), None, user.as_ref())
.await?;
let (_index, pk_column) = api.record_pk_column();
let column_names: Vec<_> = api.columns().iter().map(|c| c.name.as_str()).collect();
let pk_meta = api.record_pk_column();
let column_names = || {
api
.columns()
.iter()
.map(|meta| meta.column.name.as_str())
.collect::<Vec<_>>()
};
return Ok(Json(match query.expand {
Some(query_expand) if !query_expand.is_empty() => {
let Some(expand) = api.expand() else {
if let Some(query_expand) = query.expand
&& !query_expand.is_empty()
{
let Some(expand) = api.expand() else {
return Err(RecordError::BadRequest("Invalid expansion"));
};
// Input validation, i.e. only accept columns that are also configured.
let query_expand: Vec<_> = query_expand.split(",").collect();
for col_name in &query_expand {
if !query_expand.contains(col_name) {
return Err(RecordError::BadRequest("Invalid expansion"));
};
// Input validation, i.e. only accept columns that are also configured.
let query_expand: Vec<_> = query_expand.split(",").collect();
for col_name in &query_expand {
if !query_expand.contains(col_name) {
return Err(RecordError::BadRequest("Invalid expansion"));
}
}
}
let metadata = api.connection_metadata();
let expanded_tables = expand_tables(&api, &metadata, &query_expand)?;
let metadata = api.connection_metadata();
let expanded_tables = expand_tables(&api, &metadata, &query_expand)?;
let Some(ExpandedSelectQueryResult { root, foreign_rows }) = run_expanded_select_query(
api.conn(),
api.table_name(),
&column_names,
&pk_column.name,
record_id,
&expanded_tables,
)
.await?
else {
return Err(RecordError::RecordNotFound);
};
let Some(ExpandedSelectQueryResult { root, foreign_rows }) = run_expanded_select_query(
api.conn(),
api.table_name(),
&column_names(),
&pk_meta.column.name,
record_id,
&expanded_tables,
)
.await?
else {
return Err(RecordError::RecordNotFound);
};
// Alloc a map from column name to value that's pre-filled with with Value::Null for all
// expandable columns.
let mut expand = expand.clone();
// Alloc a map from column name to value that's pre-filled with with Value::Null for all
// expandable columns.
let mut expand = expand.clone();
for (col_name, (metadata, row)) in std::iter::zip(query_expand, foreign_rows) {
let foreign_value = row_to_json_expand(
&metadata.schema.columns,
&metadata.json_metadata.columns,
&row,
prefix_filter,
None,
)
for (col_name, (metadata, row)) in std::iter::zip(query_expand, foreign_rows) {
let foreign_value = row_to_json_expand(&metadata.column_metadata, &row, prefix_filter, None)
.map_err(|err| RecordError::Internal(err.into()))?;
let result = expand.insert(col_name.to_string(), foreign_value);
assert!(result.is_some());
}
row_to_json_expand(
api.columns(),
api.json_column_metadata(),
&root,
prefix_filter,
Some(&expand),
)
.map_err(|err| RecordError::Internal(err.into()))?
let result = expand.insert(col_name.to_string(), foreign_value);
assert!(result.is_some());
}
Some(_) | None => {
let Some(row) = run_select_query(
api.conn(),
api.table_name(),
&column_names,
&pk_column.name,
record_id,
)
.await?
else {
return Err(RecordError::RecordNotFound);
};
row_to_json_expand(
api.columns(),
api.json_column_metadata(),
&row,
prefix_filter,
api.expand(),
)
.map_err(|err| RecordError::Internal(err.into()))?
}
}));
return Ok(Json(
row_to_json_expand(api.columns(), &root, prefix_filter, Some(&expand))
.map_err(|err| RecordError::Internal(err.into()))?,
));
}
let Some(row) = run_select_query(
api.conn(),
api.table_name(),
&column_names(),
&pk_meta.column.name,
record_id,
)
.await?
else {
return Err(RecordError::RecordNotFound);
};
return Ok(Json(
row_to_json_expand(api.columns(), &row, prefix_filter, api.expand())
.map_err(|err| RecordError::Internal(err.into()))?,
));
}
type GetUploadedFileFromRecordPath = Path<(
@@ -172,22 +162,17 @@ pub async fn get_uploaded_file_from_record_handler(
return Err(RecordError::Forbidden);
};
let (_index, pk_column) = api.record_pk_column();
let Some(index) = api.column_index_by_name(&column_name) else {
return Err(RecordError::BadRequest("Invalid field/column name"));
};
let pk_meta = api.record_pk_column();
let column = &api.columns()[index];
let Some(ref column_json_metadata) = api.json_column_metadata()[index] else {
return Err(RecordError::BadRequest("Invalid column"));
let Some(column_metadata) = api.column_metadata_by_name(&column_name) else {
return Err(RecordError::BadRequest("Invalid field/column name"));
};
let file_upload = run_get_file_query(
api.conn(),
api.table_name(),
column,
column_json_metadata,
&pk_column.name,
column_metadata,
&pk_meta.column.name,
record_id,
)
.await?;
@@ -229,16 +214,14 @@ pub async fn get_uploaded_files_from_record_handler(
.check_record_level_access(Permission::Read, Some(&record_id), None, user.as_ref())
.await?;
let Some((_index, column, Some(column_json_metadata))) = api.column_by_name(&column_name) else {
let Some(column_metadata) = api.column_metadata_by_name(&column_name) else {
return Err(RecordError::BadRequest("Invalid field/column name"));
};
let FileUploads(file_uploads) = run_get_files_query(
api.conn(),
api.table_name(),
column,
column_json_metadata,
&api.record_pk_column().1.name,
column_metadata,
&api.record_pk_column().column.name,
record_id,
)
.await?;
+87 -87
View File
@@ -5,10 +5,9 @@ use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use trailbase_schema::metadata::{
ConnectionMetadata, JsonColumnMetadata, TableMetadata, ViewMetadata, find_file_column_indexes,
ColumnMetadata, ConnectionMetadata, TableMetadata, ViewMetadata, find_file_column_indexes,
find_user_id_foreign_key_columns,
};
use trailbase_schema::sqlite::Column;
use trailbase_schema::{QualifiedName, QualifiedNameEscaped};
use trailbase_sqlite::{Connection, NamedParams, Params as _, Value};
@@ -30,15 +29,20 @@ struct RecordApiSchema {
attached_databases: Vec<String>,
is_table: bool,
record_pk_column: (usize, Column),
columns: Vec<Column>,
json_column_metadata: Vec<Option<JsonColumnMetadata>>,
record_pk_column: ColumnMetadata,
column_metadata: Vec<ColumnMetadata>,
has_file_columns: bool,
user_id_columns: Vec<usize>,
// Helpers
column_name_to_index: HashMap<String, usize>,
// Helpers:
// SQLite parameter emplate
named_params_template: NamedParams,
// Mapping form column name to *API column index*.
// NOTE: The index may be different from the column's index in the underlying TABLE or VIEW
// due to excluded columns.
column_name_to_index: HashMap<String, usize>,
}
type DeferredAclCheck = dyn (FnOnce(&rusqlite::Connection) -> Result<(), RecordError>) + Send;
@@ -47,32 +51,26 @@ impl RecordApiSchema {
fn from_table(table_metadata: &TableMetadata, config: &RecordApiConfig) -> Result<Self, String> {
assert_name(config, table_metadata.name());
let Some((pk_index, pk_column)) = table_metadata.record_pk_column() else {
let Some(record_pk_column) = table_metadata.record_pk_column() else {
return Err("RecordApi requires integer/UUIDv7 primary key column".into());
};
let record_pk_column = (pk_index, pk_column.clone());
let (columns, json_column_metadata) = filter_columns(
config,
&table_metadata.schema.columns,
&table_metadata.json_metadata.columns,
);
let has_file_columns = !find_file_column_indexes(&json_column_metadata).is_empty();
let user_id_columns = find_user_id_foreign_key_columns(&columns, USER_TABLE);
let column_metadata = filter_excluded_columns(config, &table_metadata.column_metadata);
let has_file_columns = !find_file_column_indexes(&column_metadata).is_empty();
let user_id_columns = find_user_id_foreign_key_columns(&column_metadata, USER_TABLE);
let column_name_to_index = HashMap::<String, usize>::from_iter(
columns
column_metadata
.iter()
.enumerate()
.map(|(index, col)| (col.name.clone(), index)),
.map(|(index, meta)| (meta.column.name.clone(), index)),
);
let named_params_template: NamedParams = columns
let named_params_template: NamedParams = column_metadata
.iter()
.map(|column| {
.map(|meta| {
(
Cow::Owned(prefix_colon(&column.name)),
Cow::Owned(prefix_colon(&meta.column.name)),
trailbase_sqlite::Value::Null,
)
})
@@ -83,9 +81,8 @@ impl RecordApiSchema {
table_name: QualifiedNameEscaped::new(&table_metadata.schema.name),
attached_databases: config.attached_databases.clone(),
is_table: true,
record_pk_column,
columns,
json_column_metadata,
record_pk_column: record_pk_column.clone(),
column_metadata,
has_file_columns,
user_id_columns,
column_name_to_index,
@@ -96,30 +93,25 @@ impl RecordApiSchema {
fn from_view(view_metadata: &ViewMetadata, config: &RecordApiConfig) -> Result<Self, String> {
assert_name(config, view_metadata.name());
let Some((pk_index, pk_column)) = view_metadata.record_pk_column() else {
let Some(record_pk_column) = view_metadata.record_pk_column() else {
return Err(format!(
"RecordApi requires integer/UUIDv7 primary key column: {config:?}"
));
};
let record_pk_column = (pk_index, pk_column.clone());
let Some(columns) = view_metadata.columns() else {
let Some(ref column_metadata) = view_metadata.column_metadata else {
return Err("RecordApi requires schema".to_string());
};
let Some(ref json_metadata) = view_metadata.json_metadata else {
return Err("RecordApi requires json metadata".to_string());
};
let (columns, json_column_metadata) = filter_columns(config, columns, &json_metadata.columns);
let has_file_columns = !find_file_column_indexes(&json_column_metadata).is_empty();
let user_id_columns = find_user_id_foreign_key_columns(&columns, USER_TABLE);
let column_metadata = filter_excluded_columns(config, column_metadata);
let has_file_columns = !find_file_column_indexes(&column_metadata).is_empty();
let user_id_columns = find_user_id_foreign_key_columns(&column_metadata, USER_TABLE);
let column_name_to_index = HashMap::<String, usize>::from_iter(
columns
column_metadata
.iter()
.enumerate()
.map(|(index, col)| (col.name.clone(), index)),
.map(|(index, meta)| (meta.column.name.clone(), index)),
);
return Ok(Self {
@@ -127,9 +119,8 @@ impl RecordApiSchema {
table_name: QualifiedNameEscaped::new(&view_metadata.schema.name),
attached_databases: config.attached_databases.clone(),
is_table: false,
record_pk_column,
columns,
json_column_metadata,
record_pk_column: record_pk_column.clone(),
column_metadata: column_metadata.clone(),
has_file_columns,
user_id_columns,
column_name_to_index,
@@ -224,22 +215,27 @@ impl RecordApi {
schema: RecordApiSchema,
config: RecordApiConfig,
) -> Result<Self, String> {
assert_eq!(schema.columns.len(), schema.json_column_metadata.len());
let Some(api_name) = config.name.clone() else {
return Err(format!("RecordApi misses name: {config:?}"));
};
let (read_access_query, subscription_read_access_query) = match &config.read_access_rule {
Some(rule) => {
let read_access_query =
build_read_delete_schema_query(&schema.table_name, &schema.record_pk_column.1.name, rule);
let read_access_query = build_read_delete_schema_query(
&schema.table_name,
&schema.record_pk_column.column.name,
rule,
);
let subscription_read_access_query = if schema.is_table {
Some(
SubscriptionRecordReadTemplate {
read_access_rule: rule,
column_names: schema.columns.iter().map(|c| c.name.as_str()).collect(),
column_names: schema
.column_metadata
.iter()
.map(|m| m.column.name.as_str())
.collect(),
}
.render()
.map_err(|err| err.to_string())?,
@@ -254,17 +250,25 @@ impl RecordApi {
};
let delete_access_query = config.delete_access_rule.as_ref().map(|rule| {
build_read_delete_schema_query(&schema.table_name, &schema.record_pk_column.1.name, rule)
build_read_delete_schema_query(
&schema.table_name,
&schema.record_pk_column.column.name,
rule,
)
});
let schema_access_query = config.schema_access_rule.as_ref().map(|rule| {
build_read_delete_schema_query(&schema.table_name, &schema.record_pk_column.1.name, rule)
build_read_delete_schema_query(
&schema.table_name,
&schema.record_pk_column.column.name,
rule,
)
});
let create_access_query = match &config.create_access_rule {
Some(rule) => {
if schema.is_table {
Some(build_create_access_query(&schema.columns, rule)?)
Some(build_create_access_query(&schema.column_metadata, rule)?)
} else {
None
}
@@ -277,8 +281,8 @@ impl RecordApi {
if schema.is_table {
Some(build_update_access_query(
&schema.table_name,
&schema.columns,
&schema.record_pk_column.1.name,
&schema.column_metadata,
&schema.record_pk_column.column.name,
rule,
)?)
} else {
@@ -397,18 +401,13 @@ impl RecordApi {
}
#[inline]
pub fn record_pk_column(&self) -> &(usize, Column) {
pub fn record_pk_column(&self) -> &ColumnMetadata {
return &self.state.schema.record_pk_column;
}
#[inline]
pub fn columns(&self) -> &[Column] {
return &self.state.schema.columns;
}
#[inline]
pub fn json_column_metadata(&self) -> &[Option<JsonColumnMetadata>] {
return &self.state.schema.json_column_metadata;
pub fn columns(&self) -> &[ColumnMetadata] {
return &self.state.schema.column_metadata;
}
#[inline]
@@ -417,14 +416,19 @@ impl RecordApi {
}
#[inline]
pub fn column_index_by_name(&self, key: &str) -> Option<usize> {
return self.state.schema.column_name_to_index.get(key).copied();
pub fn column_index_by_name(&self, name: &str) -> Option<usize> {
return self.state.schema.column_name_to_index.get(name).copied();
}
#[inline]
pub fn column_metadata_by_name(&self, name: &str) -> Option<&ColumnMetadata> {
return Some(&self.state.schema.column_metadata[self.column_index_by_name(name)?]);
}
pub fn primary_key_to_value(&self, pk: String) -> Result<Value, RecordError> {
// NOTE: loosly parse - will convert STRING to INT/REAL.
return trailbase_schema::json::parse_string_to_sqlite_value(
self.state.schema.record_pk_column.1.data_type,
self.state.schema.record_pk_column.column.data_type,
pk,
)
.map_err(|_| RecordError::BadRequest("Invalid id"));
@@ -769,10 +773,13 @@ struct CreateRecordAccessQueryTemplate<'a> {
///
/// Assumes access_rule is an expression: https://www.sqlite.org/syntax/expr.html
fn build_create_access_query(
columns: &[Column],
column_metadata: &[ColumnMetadata],
create_access_rule: &str,
) -> Result<Arc<str>, String> {
let column_names: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect();
let column_names: Vec<&str> = column_metadata
.iter()
.map(|m| m.column.name.as_str())
.collect();
return Ok(
CreateRecordAccessQueryTemplate {
@@ -803,11 +810,14 @@ struct UpdateRecordAccessQueryTemplate<'a> {
/// Assumes access_rule is an expression: https://www.sqlite.org/syntax/expr.html
fn build_update_access_query(
table_name: &QualifiedNameEscaped,
columns: &[Column],
column_metadata: &[ColumnMetadata],
pk_column_name: &str,
update_access_rule: &str,
) -> Result<Arc<str>, String> {
let column_names: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect();
let column_names: Vec<&str> = column_metadata
.iter()
.map(|m| m.column.name.as_str())
.collect();
return Ok(
UpdateRecordAccessQueryTemplate {
@@ -839,33 +849,23 @@ enum Entity {
Authenticated = 1,
}
fn filter_columns(
fn filter_excluded_columns(
config: &RecordApiConfig,
columns: &[Column],
json_column_metadata: &[Option<JsonColumnMetadata>],
) -> (Vec<Column>, Vec<Option<JsonColumnMetadata>>) {
assert_eq!(columns.len(), json_column_metadata.len());
column_metadata: &[ColumnMetadata],
) -> Vec<ColumnMetadata> {
if config.excluded_columns.is_empty() {
return (columns.to_vec(), json_column_metadata.to_vec());
return column_metadata.to_vec();
}
let excluded_indexes = config
.excluded_columns
return column_metadata
.iter()
.filter_map(|name| columns.iter().position(|col| col.name == *name));
assert_eq!(
excluded_indexes.clone().count(),
config.excluded_columns.len()
);
let mut columns_vec = columns.to_vec();
let mut json_column_metadata_vec = json_column_metadata.to_vec();
for idx in excluded_indexes.rev() {
columns_vec.remove(idx);
json_column_metadata_vec.remove(idx);
}
return (columns_vec, json_column_metadata_vec);
.filter_map(|meta| {
if config.excluded_columns.contains(&meta.column.name) {
return None;
}
return Some(meta.clone());
})
.collect();
}
#[inline]
+1 -1
View File
@@ -323,7 +323,7 @@ impl PerConnectionState {
let table_name = api.table_name();
let qualified_table_name = api.qualified_name().clone();
let pk_column = &api.record_pk_column().1.name;
let pk_column = &api.record_pk_column().column.name;
let Some(row_id): Option<i64> = api
.conn()
+10 -7
View File
@@ -105,7 +105,7 @@ pub async fn record_transactions_handler(
&& let Some(ref user) = user
{
for column_index in api.user_id_columns() {
let col_name = &api.columns()[*column_index].name;
let col_name = &api.columns()[*column_index].column.name;
if !record.contains_key(col_name) {
record.insert(
col_name.to_owned(),
@@ -126,7 +126,7 @@ pub async fn record_transactions_handler(
let (query, _files) = WriteQuery::new_insert(
api.table_name(),
&api.record_pk_column().1.name,
&api.record_pk_column().column.name,
api.insert_conflict_resolution_strategy(),
lazy_params
.consume()
@@ -154,14 +154,14 @@ pub async fn record_transactions_handler(
let api = get_api(&state, &api_name, idx)?;
let record = extract_record(value)?;
let record_id = api.primary_key_to_value(record_id)?;
let (_index, pk_column) = api.record_pk_column();
let pk_meta = api.record_pk_column();
let mut lazy_params = LazyParams::for_update(
&api,
state.json_schema_registry().clone(),
record,
None,
pk_column.name.clone(),
pk_meta.column.name.clone(),
record_id.clone(),
);
@@ -203,9 +203,12 @@ pub async fn record_transactions_handler(
user.as_ref(),
)?;
let query =
WriteQuery::new_delete(api.table_name(), &api.record_pk_column().1.name, record_id)
.map_err(|err| RecordError::Internal(err.into()))?;
let query = WriteQuery::new_delete(
api.table_name(),
&api.record_pk_column().column.name,
record_id,
)
.map_err(|err| RecordError::Internal(err.into()))?;
Ok(Box::new(move |conn| {
acl_check(conn)?;
+2 -2
View File
@@ -38,14 +38,14 @@ pub async fn update_record_handler(
};
let record_id = api.primary_key_to_value(record)?;
let (_index, pk_column) = api.record_pk_column();
let pk_meta = api.record_pk_column();
let mut lazy_params = LazyParams::for_update(
&api,
state.json_schema_registry().clone(),
request,
multipart_files,
pk_column.name.clone(),
pk_meta.column.name.clone(),
record_id.clone(),
);
+10 -9
View File
@@ -93,7 +93,7 @@ pub(crate) fn validate_record_api_config(
"Must be STRICT for strong end-to-end type-safety.",
));
}
table.schema.columns.as_slice()
table.column_metadata.as_slice()
}
TableOrViewMetadata::View(view) => {
prefix.entity = Entity::View;
@@ -111,7 +111,7 @@ pub(crate) fn validate_record_api_config(
}
};
let Some((pk_index, _)) = table_or_view.record_pk_column() else {
let Some(pk_meta) = table_or_view.record_pk_column() else {
return Err(invalid_prefixed(
&prefix,
"Does not have a suitable PRIMARY KEY column. At this point TrailBase requires PRIMARY KEYS to be of type INTEGER or UUID (i.e. BLOB with `is_uuid.*` CHECK constraint).",
@@ -121,7 +121,7 @@ pub(crate) fn validate_record_api_config(
for excluded_column_name in &api_config.excluded_columns {
let Some(excluded_index) = columns
.iter()
.position(|col| col.name == *excluded_column_name)
.position(|meta| meta.column.name == *excluded_column_name)
else {
return Err(invalid_prefixed(
&prefix,
@@ -129,14 +129,14 @@ pub(crate) fn validate_record_api_config(
));
};
if excluded_index == pk_index {
if excluded_index == pk_meta.index {
return Err(invalid_prefixed(
&prefix,
format!("PK column '{excluded_column_name}' must not be excluded.",),
));
}
let excluded_column = &columns[excluded_index];
let excluded_column = &columns[excluded_index].column;
if excluded_column.is_not_null() && !excluded_column.has_default() {
return Err(invalid_prefixed(
&prefix,
@@ -155,7 +155,7 @@ pub(crate) fn validate_record_api_config(
));
}
let Some(column) = columns.iter().find(|c| c.name == *expand) else {
let Some(meta) = columns.iter().find(|meta| meta.column.name == *expand) else {
return Err(invalid_prefixed(
&prefix,
format!("Expands unknown column '{expand}'."),
@@ -166,7 +166,8 @@ pub(crate) fn validate_record_api_config(
foreign_table: foreign_table_name,
referred_columns,
..
}) = column
}) = meta
.column
.options
.iter()
.find_or_first(|o| matches!(o, ColumnOption::ForeignKey { .. }))
@@ -192,7 +193,7 @@ pub(crate) fn validate_record_api_config(
));
};
let Some((_idx, foreign_pk_column)) = foreign_table.record_pk_column() else {
let Some(foreign_pk_meta) = foreign_table.record_pk_column() else {
return Err(invalid_prefixed(
&prefix,
format!("Expanded foreign table '{foreign_table_name}' lacks suitable PRIMARY KEY."),
@@ -202,7 +203,7 @@ pub(crate) fn validate_record_api_config(
match referred_columns.len() {
0 => {}
1 => {
if referred_columns[0] != foreign_pk_column.name {
if referred_columns[0] != foreign_pk_meta.column.name {
return Err(invalid_prefixed(
&prefix,
format!("Expanded column '{expand}' references non-PK."),
+1 -1
View File
@@ -275,7 +275,7 @@ mod tests {
let (validator, schema) = build_json_schema_expanded(
&state.json_schema_registry().read(),
&table_name.name,
&table_metadata.schema.columns,
&table_metadata.column_metadata,
JsonSchemaMode::Select,
Some(Expand {
tables: &metadata.tables.values().collect::<Vec<_>>(),
+9 -7
View File
@@ -5,9 +5,10 @@ use serde_json::Value;
use trailbase_extension::jsonschema::JsonSchemaRegistry;
use crate::metadata::{
JsonColumnMetadata, JsonSchemaError, TableMetadata, extract_json_metadata, is_pk_column,
ColumnMetadata, JsonColumnMetadata, JsonSchemaError, TableMetadata, extract_json_metadata,
is_pk_column,
};
use crate::sqlite::{Column, ColumnDataType, ColumnOption};
use crate::sqlite::{ColumnDataType, ColumnOption};
/// Influeces the generated JSON schema. In `Insert` mode columns with default values will be
/// optional.
@@ -32,7 +33,7 @@ pub enum JsonSchemaMode {
pub fn build_json_schema(
registry: &JsonSchemaRegistry,
title: &str,
columns: &[Column],
columns: &[ColumnMetadata],
mode: JsonSchemaMode,
) -> Result<(Validator, serde_json::Value), JsonSchemaError> {
return build_json_schema_expanded(registry, title, columns, mode, None);
@@ -49,7 +50,7 @@ pub struct Expand<'a> {
pub fn build_json_schema_expanded(
registry: &JsonSchemaRegistry,
title: &str,
columns: &[Column],
columns_metadata: &[ColumnMetadata],
mode: JsonSchemaMode,
expand: Option<Expand<'_>>,
) -> Result<(Validator, serde_json::Value), JsonSchemaError> {
@@ -57,7 +58,8 @@ pub fn build_json_schema_expanded(
let mut defs = serde_json::Map::new();
let mut required_cols: Vec<String> = vec![];
for col in columns {
for meta in columns_metadata {
let col = &meta.column;
let mut def_name: Option<String> = None;
let mut not_null = false;
let mut default = false;
@@ -158,7 +160,7 @@ pub fn build_json_schema_expanded(
};
let (_validator, schema) =
build_json_schema(registry, foreign_table, &table.schema.columns, mode)?;
build_json_schema(registry, foreign_table, &table.column_metadata, mode)?;
let new_def_name = foreign_table.clone();
defs.insert(
@@ -434,7 +436,7 @@ mod tests {
let (schema, _) = build_json_schema(
&registry,
&table_metadata.name().name,
&table_metadata.schema.columns,
&table_metadata.column_metadata,
JsonSchemaMode::Insert,
)
.unwrap();
+175 -116
View File
@@ -65,38 +65,19 @@ impl JsonColumnMetadata {
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct JsonMetadata {
pub columns: Vec<Option<JsonColumnMetadata>>,
// Contains both, 'std.FileUpload' and 'std.FileUpload'.
file_column_indexes: Vec<usize>,
}
impl JsonMetadata {
fn from_columns(
registry: &JsonSchemaRegistry,
columns: &[Column],
) -> Result<Self, JsonSchemaError> {
let columns = columns
.iter()
.map(|c| build_json_metadata(registry, c))
.collect::<Result<Vec<_>, _>>()?;
return Ok(Self {
file_column_indexes: find_file_column_indexes(&columns),
columns,
});
}
pub fn has_file_columns(&self) -> bool {
return !self.file_column_indexes.is_empty();
}
/// Contains both, 'std.FileUpload' and 'std.FileUpload'.
pub fn file_column_indexes(&self) -> &[usize] {
return &self.file_column_indexes;
}
#[derive(Debug, Clone)]
pub struct ColumnMetadata {
/// The original table or view index. May be different from column index in a RecordApi after
/// filtering out excluded columns.
pub index: usize,
/// Column schema.
pub column: Column,
/// JSON metadata if the column has a schema defined, i.e. `CHECK(jsonschema(_))`.
pub json: Option<JsonColumnMetadata>,
/// Whether the JSON schema happens to be `std.FileUpload[s]`.
pub is_file: bool,
/// Whether the column has an ST_Valid geometry check constaint.
pub is_geometry: bool,
}
/// A data class describing a sqlite Table and additional meta data useful for TrailBase.
@@ -108,8 +89,7 @@ pub struct TableMetadata {
/// If and which column on this table qualifies as a record PK column, i.e. integer or UUIDv7.
pub record_pk_column: Option<usize>,
/// Metadata for CHECK(jsonschema()) columns.
pub json_metadata: JsonMetadata,
pub column_metadata: Vec<ColumnMetadata>,
name_to_index: HashMap<String, usize>,
// TODO: Add triggers once sqlparser supports a sqlite "CREATE TRIGGER" statements.
@@ -125,16 +105,31 @@ impl TableMetadata {
table: Table,
tables: &[Table],
) -> Result<Self, JsonSchemaError> {
let column_metadata: Vec<_> = table
.columns
.iter()
.enumerate()
.map(|(i, c)| {
let json_metadata = build_json_metadata(registry, c)?;
return Ok(ColumnMetadata {
index: i,
is_file: is_file_column(&json_metadata),
json: json_metadata,
is_geometry: is_geometry_column(c),
column: c.clone(),
});
})
.collect::<Result<_, _>>()?;
return Ok(TableMetadata {
record_pk_column: find_record_pk_column_index_for_table(&table, tables),
json_metadata: JsonMetadata::from_columns(registry, &table.columns)?,
name_to_index: HashMap::<String, usize>::from_iter(
table
.columns
column_metadata
.iter()
.enumerate()
.map(|(index, col)| (col.name.clone(), index)),
.map(|meta| (meta.column.name.clone(), meta.index)),
),
column_metadata,
schema: table,
});
}
@@ -149,14 +144,14 @@ impl TableMetadata {
return self.name_to_index.get(key).copied();
}
pub fn column_by_name(&self, key: &str) -> Option<(usize, &Column)> {
pub fn column_by_name(&self, key: &str) -> Option<&ColumnMetadata> {
let index = self.column_index_by_name(key)?;
return Some((index, &self.schema.columns[index]));
return Some(&self.column_metadata[index]);
}
pub fn record_pk_column(&self) -> Option<(usize, &Column)> {
pub fn record_pk_column(&self) -> Option<&ColumnMetadata> {
let index = self.record_pk_column?;
return self.schema.columns.get(index).map(|c| (index, c));
return Some(&self.column_metadata[index]);
}
}
@@ -165,13 +160,11 @@ impl TableMetadata {
pub struct ViewMetadata {
pub schema: View,
// QUESTION: Why do we have copy of the columns here? Right now it's duplicate from `.schema`.
// This probably only exists because we have a trait impl that returns Option<&[Column]>.
columns: Option<Vec<Column>>,
/// If and which column on this table qualifies as a record PK column, i.e. integer or UUIDv7.
pub record_pk_column: Option<usize>,
pub column_metadata: Option<Vec<ColumnMetadata>>,
name_to_index: HashMap<String, usize>,
record_pk_column: Option<usize>,
pub json_metadata: Option<JsonMetadata>,
}
impl ViewMetadata {
@@ -187,9 +180,8 @@ impl ViewMetadata {
let Some(column_mapping) = &view.column_mapping else {
return Ok(ViewMetadata {
name_to_index: HashMap::<String, usize>::default(),
columns: None,
record_pk_column: None,
json_metadata: None,
column_metadata: None,
schema: view,
});
};
@@ -200,17 +192,31 @@ impl ViewMetadata {
.map(|m| m.column.clone())
.collect();
let column_metadata: Vec<_> = columns
.into_iter()
.enumerate()
.map(|(i, c)| {
let json_metadata = build_json_metadata(registry, &c)?;
return Ok(ColumnMetadata {
index: i,
is_file: is_file_column(&json_metadata),
json: json_metadata,
is_geometry: is_geometry_column(&c),
column: c,
});
})
.collect::<Result<_, _>>()?;
let name_to_index = HashMap::<String, usize>::from_iter(
columns
column_metadata
.iter()
.enumerate()
.map(|(index, col)| (col.name.clone(), index)),
.map(|meta| (meta.column.name.clone(), meta.index)),
);
return Ok(ViewMetadata {
name_to_index,
json_metadata: Some(JsonMetadata::from_columns(registry, &columns)?),
columns: Some(columns),
column_metadata: Some(column_metadata),
record_pk_column: find_record_pk_column_index_for_view(column_mapping, tables),
schema: view,
});
@@ -222,8 +228,8 @@ impl ViewMetadata {
}
#[inline]
pub fn columns(&self) -> Option<&[Column]> {
return self.columns.as_deref();
pub fn columns(&self) -> Option<&[ColumnMetadata]> {
return self.column_metadata.as_deref();
}
#[inline]
@@ -231,15 +237,16 @@ impl ViewMetadata {
self.name_to_index.get(key).copied()
}
pub fn column_by_name(&self, key: &str) -> Option<(usize, &Column)> {
pub fn column_by_name(&self, key: &str) -> Option<&ColumnMetadata> {
let index = self.column_index_by_name(key)?;
let mapping = self.schema.column_mapping.as_ref()?;
return Some((index, &mapping.columns[index].column));
let meta = self.column_metadata.as_ref()?;
return Some(&meta[index]);
}
pub fn record_pk_column(&self) -> Option<(usize, &Column)> {
pub fn record_pk_column(&self) -> Option<&ColumnMetadata> {
let index = self.record_pk_column?;
return self.columns.as_ref()?.get(index).map(|c| (index, c));
let meta = self.column_metadata.as_ref()?;
return Some(&meta[index]);
}
}
@@ -296,21 +303,21 @@ impl<'a> TableOrViewMetadata<'a> {
};
}
pub fn columns(&self) -> Option<&'a [Column]> {
pub fn columns(&self) -> Option<&'a [ColumnMetadata]> {
return match self {
Self::Table(t) => Some(&t.schema.columns),
Self::View(v) => v.columns(),
Self::Table(t) => Some(&t.column_metadata),
Self::View(v) => v.column_metadata.as_deref(),
};
}
pub fn record_pk_column(&self) -> Option<(usize, &Column)> {
pub fn record_pk_column(&self) -> Option<&ColumnMetadata> {
return match self {
Self::Table(t) => t.record_pk_column(),
Self::View(v) => v.record_pk_column(),
};
}
pub fn column_by_name(&self, name: &str) -> Option<(usize, &Column)> {
pub fn column_by_name(&self, name: &str) -> Option<&ColumnMetadata> {
return match self {
Self::Table(t) => t.column_by_name(name),
Self::View(v) => v.column_by_name(name),
@@ -434,44 +441,81 @@ pub(crate) fn extract_json_metadata(
return Ok(None);
}
pub fn find_file_column_indexes(json_column_metadata: &[Option<JsonColumnMetadata>]) -> Vec<usize> {
let mut indexes: Vec<usize> = vec![];
for (index, column) in json_column_metadata.iter().enumerate() {
if let Some(metadata) = column {
match metadata {
JsonColumnMetadata::SchemaName(name) if name == "std.FileUpload" => {
indexes.push(index);
}
JsonColumnMetadata::SchemaName(name) if name == "std.FileUploads" => {
indexes.push(index);
}
_ => {}
};
}
fn is_file_column(json: &Option<JsonColumnMetadata>) -> bool {
if let Some(metadata) = json
&& let JsonColumnMetadata::SchemaName(name) = metadata
&& (name == "std.FileUpload" || name == "std.FileUploads")
{
return true;
}
return indexes;
return false;
}
pub fn find_user_id_foreign_key_columns(columns: &[Column], user_table_name: &str) -> Vec<usize> {
let mut indexes: Vec<usize> = vec![];
for (index, col) in columns.iter().enumerate() {
for opt in &col.options {
if let ColumnOption::ForeignKey {
foreign_table,
referred_columns,
..
} = opt
&& foreign_table == user_table_name
&& referred_columns.len() == 1
&& referred_columns[0] == "id"
pub fn find_file_column_indexes(column_metadata: &[ColumnMetadata]) -> Vec<usize> {
return column_metadata
.iter()
.flat_map(|meta| {
if is_file_column(&meta.json) {
return Some(meta.index);
}
return None;
})
.collect();
}
fn is_geometry_column(column: &Column) -> bool {
lazy_static! {
static ref GEOMETRY_CHECK_RE: Regex = Regex::new(r"^ST_IsValid\s*\(").expect("infallible");
}
if column.data_type == ColumnDataType::Blob {
for opt in &column.options {
if let ColumnOption::Check(expr) = opt
&& GEOMETRY_CHECK_RE.is_match(expr)
{
indexes.push(index);
return true;
}
}
}
return indexes;
return false;
}
pub fn find_geometry_column_indexes(columns: &[Column]) -> Vec<usize> {
return columns
.iter()
.enumerate()
.flat_map(|(index, column)| {
if is_geometry_column(column) {
return Some(index);
}
return None;
})
.collect();
}
pub fn find_user_id_foreign_key_columns(
column_metadata: &[ColumnMetadata],
user_table_name: &str,
) -> Vec<usize> {
return column_metadata
.iter()
.flat_map(|meta| {
for opt in &meta.column.options {
if let ColumnOption::ForeignKey {
foreign_table,
referred_columns,
..
} = opt
&& foreign_table == user_table_name
&& referred_columns.len() == 1
&& referred_columns[0] == "id"
{
return Some(meta.index);
}
}
return None;
})
.collect();
}
pub(crate) fn is_pk_column(column: &Column) -> bool {
@@ -694,6 +738,21 @@ mod tests {
);
}
#[test]
fn test_find_geometry_columns() {
let table = parse_create_table(
"CREATE TABLE t (
id INT PRIMARY KEY,
not_geom TEXT,
geom0 BLOB CHECK(ST_IsValid(geom0)),
geom1 BLOB CHECK(ST_IsValid(geom1)) NOT NULL
) STRICT;",
);
let geometry_columns = find_geometry_column_indexes(&table.columns);
assert_eq!(vec![2, 3], geometry_columns);
}
#[test]
fn test_parse_create_view() {
let table = parse_create_table(
@@ -754,7 +813,7 @@ mod tests {
let uuidv7_col = view_metadata.record_pk_column().unwrap();
let columns = view_metadata.columns().unwrap();
assert_eq!(columns.len(), 3);
assert_eq!(columns[uuidv7_col.0].name, "id");
assert_eq!(columns[uuidv7_col.index].column.name, "id");
}
}
@@ -784,9 +843,9 @@ mod tests {
assert_eq!(view_columns[1].column.data_type, ColumnDataType::Text);
let metadata = ViewMetadata::new(&registry, view, &tables).unwrap();
let (pk_index, pk_col) = metadata.record_pk_column().unwrap();
assert_eq!(pk_index, 0);
assert_eq!(pk_col.name, "id");
let meta = metadata.record_pk_column().unwrap();
assert_eq!(meta.index, 0);
assert_eq!(meta.column.name, "id");
}
{
@@ -801,9 +860,9 @@ mod tests {
assert_eq!(view_columns[0].column.data_type, ColumnDataType::Integer);
let metadata = ViewMetadata::new(&registry, view, &tables).unwrap();
let (pk_index, pk_col) = metadata.record_pk_column().unwrap();
assert_eq!(pk_index, 0);
assert_eq!(pk_col.name, "id");
let meta = metadata.record_pk_column().unwrap();
assert_eq!(meta.index, 0);
assert_eq!(meta.column.name, "id");
}
{
@@ -818,9 +877,9 @@ mod tests {
assert_eq!(view_columns[0].column.data_type, ColumnDataType::Integer);
let metadata = ViewMetadata::new(&registry, view, &tables).unwrap();
let (pk_index, pk_col) = metadata.record_pk_column().unwrap();
assert_eq!(pk_index, 0);
assert_eq!(pk_col.name, "id");
let meta = metadata.record_pk_column().unwrap();
assert_eq!(meta.index, 0);
assert_eq!(meta.column.name, "id");
}
{
@@ -869,9 +928,9 @@ mod tests {
assert_eq!(view_columns[1].column.data_type, ColumnDataType::Integer);
let metadata = ViewMetadata::new(&registry, view, &tables).unwrap();
let (pk_index, pk_col) = metadata.record_pk_column().unwrap();
assert_eq!(pk_index, 2);
assert_eq!(pk_col.name, "id");
let meta = metadata.record_pk_column().unwrap();
assert_eq!(meta.index, 2);
assert_eq!(meta.column.name, "id");
}
{
@@ -893,7 +952,7 @@ mod tests {
let metadata = ViewMetadata::new(&registry, view, &tables).unwrap();
assert_eq!(
expected,
metadata.record_pk_column().map(|c| c.0),
metadata.record_pk_column().map(|c| c.index),
"{join_type}"
);
}
@@ -929,7 +988,7 @@ mod tests {
assert!(view.column_mapping.is_some(), "{i}: {sql}");
let metadata = ViewMetadata::new(&registry, view, &tables).unwrap();
assert_eq!(Some(1), metadata.record_pk_column().map(|c| c.0));
assert_eq!(Some(1), metadata.record_pk_column().map(|c| c.index));
}
}
@@ -941,7 +1000,7 @@ mod tests {
.unwrap();
let metadata = ViewMetadata::new(&registry, view, &tables).unwrap();
assert_eq!(Some(1), metadata.record_pk_column().map(|c| c.0));
assert_eq!(Some(1), metadata.record_pk_column().map(|c| c.index));
}
}
}
@@ -984,7 +1043,7 @@ mod tests {
.unwrap();
let metadata = ViewMetadata::new(&registry, view, &tables).unwrap();
assert_eq!(Some(0), metadata.record_pk_column().map(|c| c.0));
assert_eq!(Some(0), metadata.record_pk_column().map(|c| c.index));
}
{
@@ -1001,7 +1060,7 @@ mod tests {
.unwrap();
let metadata = ViewMetadata::new(&registry, view, &tables).unwrap();
assert_eq!(Some(0), metadata.record_pk_column().map(|c| c.0));
assert_eq!(Some(0), metadata.record_pk_column().map(|c| c.index));
}
}
}