refactor QueryFragment into a dedicated module

This commit is contained in:
Brent Schroeter 2026-01-19 22:10:23 +00:00
parent daf0782625
commit 849f981243
4 changed files with 178 additions and 183 deletions

View file

@ -2,147 +2,8 @@ use std::fmt::Display;
use phono_backends::escape_identifier;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use crate::datum::Datum;
/// Representation of a partial, parameterized SQL query. Allows callers to
/// build queries iteratively and dynamically, handling parameter numbering
/// (`$1`, `$2`, `$3`, ...) automatically.
///
/// This is similar to [`sqlx::QueryBuilder`], except that [`QueryFragment`]
/// objects are composable and may be concatenated to each other.
#[derive(Clone, Debug, PartialEq)]
pub struct QueryFragment {
/// SQL string, split wherever there is a query parameter. For example,
/// `select * from foo where id = $1 and status = $2` is represented along
/// the lines of `["select * from foo where id = ", " and status = ", ""]`.
/// `plain_sql` should always have exactly one more element than `params`.
plain_sql: Vec<String>,
params: Vec<Datum>,
}
impl QueryFragment {
/// Validate invariants. Should be run immediately before returning any
/// useful output.
fn gut_checks(&self) {
assert!(self.plain_sql.len() == self.params.len() + 1);
}
pub fn to_sql(&self, first_param_idx: usize) -> String {
self.gut_checks();
self.plain_sql
.iter()
.cloned()
.zip((first_param_idx..).map(|n| format!("${n}")))
.fold(
Vec::with_capacity(2 * self.plain_sql.len()),
|mut acc, pair| {
acc.extend([pair.0, pair.1]);
acc
},
)
.get(0..(2 * self.plain_sql.len() - 1))
.expect("already asserted sufficient length")
.join("")
}
/// Returns only the parameterized values, in order.
pub fn to_params(&self) -> Vec<Datum> {
self.gut_checks();
self.params.clone()
}
/// Parse from a SQL string with no parameters.
pub fn from_sql(sql: &str) -> Self {
Self {
plain_sql: vec![sql.to_owned()],
params: vec![],
}
}
/// Convenience function to construct an empty value.
pub fn empty() -> Self {
Self::from_sql("")
}
/// Parse from a parameter value with no additional SQL. (Renders as `$n`,
/// where`n` is the appropriate parameter index.)
pub fn from_param(param: Datum) -> Self {
Self {
plain_sql: vec!["".to_owned(), "".to_owned()],
params: vec![param],
}
}
/// Append another query fragment to this one.
pub fn push(&mut self, mut other: QueryFragment) {
let tail = self
.plain_sql
.pop()
.expect("already asserted that vec contains at least 1 item");
let head = other
.plain_sql
.first()
.expect("already asserted that vec contains at least 1 item");
self.plain_sql.push(format!("{tail}{head}"));
for value in other.plain_sql.drain(1..) {
self.plain_sql.push(value);
}
self.params.append(&mut other.params);
}
/// Combine multiple QueryFragments with a separator, similar to
/// [`Vec::join`].
pub fn join<I: IntoIterator<Item = Self>>(fragments: I, sep: Self) -> Self {
let mut acc = QueryFragment::from_sql("");
let mut iter = fragments.into_iter();
let mut fragment = match iter.next() {
Some(value) => value,
None => return acc,
};
for next_fragment in iter {
acc.push(fragment);
acc.push(sep.clone());
fragment = next_fragment;
}
acc.push(fragment);
acc
}
/// Convenience method equivalent to:
/// `QueryFragment::concat(fragments, QueryFragment::from_sql(""))`
pub fn concat<I: IntoIterator<Item = Self>>(fragments: I) -> Self {
Self::join(fragments, Self::from_sql(""))
}
/// Checks whether value is empty. A value is considered empty if the
/// resulting SQL code is 0 characters long.
pub fn is_empty(&self) -> bool {
self.gut_checks();
self.plain_sql.len() == 1
&& self
.plain_sql
.first()
.expect("already checked that len == 1")
.is_empty()
}
}
impl From<QueryFragment> for QueryBuilder<'_, Postgres> {
fn from(value: QueryFragment) -> Self {
value.gut_checks();
let mut builder = QueryBuilder::new("");
let mut param_iter = value.params.into_iter();
for plain_sql in value.plain_sql {
builder.push(plain_sql);
if let Some(param) = param_iter.next() {
param.push_bind_onto(&mut builder);
}
}
builder
}
}
use crate::{datum::Datum, query_builders::QueryFragment};
/// Building block of a syntax tree for a constrained subset of SQL that can be
/// statically analyzed, to validate that user-provided expressions perform only

View file

@ -27,6 +27,7 @@ pub mod language;
mod macros;
pub mod portal;
pub mod presentation;
pub mod query_builders;
pub mod service_cred;
pub mod user;
pub mod workspace;

View file

@ -0,0 +1,171 @@
//! Assorted utilities for dynamically constructing and manipulating [`sqlx`]
//! queries.
use sqlx::{Postgres, QueryBuilder};
use crate::datum::Datum;
/// Representation of a partial, parameterized SQL query. Allows callers to
/// build queries iteratively and dynamically, handling parameter numbering
/// (`$1`, `$2`, `$3`, ...) automatically.
///
/// This is similar to [`sqlx::QueryBuilder`], except that [`QueryFragment`]
/// objects are composable and may be concatenated to each other.
#[derive(Clone, Debug, PartialEq)]
pub struct QueryFragment {
/// SQL string, split wherever there is a query parameter. For example,
/// `select * from foo where id = $1 and status = $2` is represented along
/// the lines of `["select * from foo where id = ", " and status = ", ""]`.
/// `plain_sql` should always have exactly one more element than `params`.
plain_sql: Vec<String>,
params: Vec<Datum>,
}
impl QueryFragment {
/// Validate invariants. Should be run immediately before returning any
/// useful output.
fn gut_checks(&self) {
assert!(self.plain_sql.len() == self.params.len() + 1);
}
/// Parse from a SQL string with no parameters.
pub fn from_sql(sql: &str) -> Self {
Self {
plain_sql: vec![sql.to_owned()],
params: vec![],
}
}
/// Convenience function to construct an empty value.
pub fn empty() -> Self {
Self::from_sql("")
}
/// Parse from a parameter value with no additional SQL. (Renders as `$n`,
/// where`n` is the appropriate parameter index.)
pub fn from_param(param: Datum) -> Self {
Self {
plain_sql: vec!["".to_owned(), "".to_owned()],
params: vec![param],
}
}
/// Append another query fragment to this one.
pub fn push(&mut self, mut other: QueryFragment) {
let tail = self
.plain_sql
.pop()
.expect("already asserted that vec contains at least 1 item");
let head = other
.plain_sql
.first()
.expect("already asserted that vec contains at least 1 item");
self.plain_sql.push(format!("{tail}{head}"));
for value in other.plain_sql.drain(1..) {
self.plain_sql.push(value);
}
self.params.append(&mut other.params);
}
/// Combine multiple QueryFragments with a separator, similar to
/// [`Vec::join`].
pub fn join<I: IntoIterator<Item = Self>>(fragments: I, sep: Self) -> Self {
let mut acc = QueryFragment::from_sql("");
let mut iter = fragments.into_iter();
let mut fragment = match iter.next() {
Some(value) => value,
None => return acc,
};
for next_fragment in iter {
acc.push(fragment);
acc.push(sep.clone());
fragment = next_fragment;
}
acc.push(fragment);
acc
}
/// Convenience method equivalent to:
/// `QueryFragment::concat(fragments, QueryFragment::from_sql(""))`
pub fn concat<I: IntoIterator<Item = Self>>(fragments: I) -> Self {
Self::join(fragments, Self::from_sql(""))
}
/// Checks whether value is empty. A value is considered empty if the
/// resulting SQL code is 0 characters long.
pub fn is_empty(&self) -> bool {
self.gut_checks();
self.plain_sql.len() == 1
&& self
.plain_sql
.first()
.expect("already checked that len == 1")
.is_empty()
}
}
impl From<QueryFragment> for QueryBuilder<'_, Postgres> {
fn from(value: QueryFragment) -> Self {
value.gut_checks();
let mut builder = QueryBuilder::new("");
let mut param_iter = value.params.into_iter();
for plain_sql in value.plain_sql {
builder.push(plain_sql);
if let Some(param) = param_iter.next() {
param.push_bind_onto(&mut builder);
}
}
builder
}
}
/// Helper type to make it easier to build and reason about multiple related SQL
/// queries.
#[derive(Clone, Debug)]
pub struct SelectQuery {
/// Query fragment following (not including) "select ".
pub selection: QueryFragment,
/// Query fragment following (not including) "from ".
pub source: QueryFragment,
/// Query fragment following (not including) "where ", or empty if not
/// applicable.
pub filters: QueryFragment,
/// Query fragment following (not including) "order by ", or empty if not
/// applicable.
pub order: QueryFragment,
/// Query fragment following (not including) "limit ", or empty if not
/// applicable.
pub limit: QueryFragment,
}
impl From<SelectQuery> for QueryFragment {
fn from(value: SelectQuery) -> Self {
let mut result = QueryFragment::from_sql("select ");
result.push(value.selection);
result.push(QueryFragment::from_sql(" from "));
result.push(value.source);
if !value.filters.is_empty() {
result.push(QueryFragment::from_sql(" where "));
result.push(value.filters);
}
if !value.order.is_empty() {
result.push(QueryFragment::from_sql(" order by "));
result.push(value.order);
}
if !value.limit.is_empty() {
result.push(QueryFragment::from_sql(" limit "));
result.push(value.limit);
}
result
}
}
impl From<SelectQuery> for QueryBuilder<'_, Postgres> {
fn from(value: SelectQuery) -> Self {
QueryFragment::from(value).into()
}
}

View file

@ -11,12 +11,13 @@ use phono_backends::{
use phono_models::{
accessors::{Accessor, Actor, portal::PortalAccessor},
datum::Datum,
expression::{PgExpressionAny, QueryFragment},
expression::PgExpressionAny,
field::Field,
query_builders::{QueryFragment, SelectQuery},
};
use serde::{Deserialize, Serialize};
use sqlx::{
Postgres, QueryBuilder,
QueryBuilder,
postgres::{PgRow, types::Oid},
};
use tracing::debug;
@ -107,7 +108,7 @@ pub(super) async fn get(
field_info
};
let main_data_query = GetDataQuery {
let main_data_query = SelectQuery {
selection: QueryFragment::from_sql(
&pkey_attrs
.iter()
@ -154,7 +155,7 @@ pub(super) async fn get(
limit: QueryFragment::from_param(Datum::Numeric(Some(FRONTEND_ROW_LIMIT.into()))),
};
let count_query = GetDataQuery {
let count_query = SelectQuery {
selection: QueryFragment::from_sql("count(*)"),
order: QueryFragment::empty(),
limit: QueryFragment::empty(),
@ -214,42 +215,3 @@ pub(super) async fn get(
})
.into_response())
}
/// Helper type to make it easier to build and reason about multiple related SQL
/// queries.
#[derive(Clone, Debug)]
struct GetDataQuery {
selection: QueryFragment,
source: QueryFragment,
filters: QueryFragment,
order: QueryFragment,
limit: QueryFragment,
}
impl From<GetDataQuery> for QueryFragment {
fn from(value: GetDataQuery) -> Self {
let mut result = QueryFragment::from_sql("select ");
result.push(value.selection);
result.push(QueryFragment::from_sql(" from "));
result.push(value.source);
if !value.filters.is_empty() {
result.push(QueryFragment::from_sql(" where "));
result.push(value.filters);
}
if !value.order.is_empty() {
result.push(QueryFragment::from_sql(" order by "));
result.push(value.order);
}
if !value.limit.is_empty() {
result.push(QueryFragment::from_sql(" limit "));
result.push(value.limit);
}
result
}
}
impl From<GetDataQuery> for QueryBuilder<'_, Postgres> {
fn from(value: GetDataQuery) -> Self {
QueryFragment::from(value).into()
}
}