1
0
Fork 0
forked from 2sys/phonograph

move relevant common types to phono-pestgros

This commit is contained in:
Brent Schroeter 2026-02-13 08:00:23 +00:00
parent 17ccd80764
commit 36a0c27ad4
26 changed files with 90 additions and 364 deletions

3
Cargo.lock generated
View file

@ -2380,6 +2380,7 @@ dependencies = [
"chrono",
"derive_builder",
"nom 8.0.0",
"phono-pestgros",
"regex",
"serde",
"sqlx",
@ -2397,6 +2398,7 @@ dependencies = [
"derive_builder",
"futures",
"phono-backends",
"phono-pestgros",
"redact",
"regex",
"serde",
@ -2456,6 +2458,7 @@ dependencies = [
"phono-backends",
"phono-models",
"phono-namegen",
"phono-pestgros",
"rand 0.8.5",
"redact",
"regex",

View file

@ -7,6 +7,7 @@ version.workspace = true
chrono = { workspace = true }
derive_builder = { workspace = true }
nom = "8.0.0"
phono-pestgros = { workspace = true }
regex = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }

View file

@ -1,7 +1,6 @@
use phono_pestgros::escape_identifier;
use sqlx::{PgConnection, Postgres, Row as _, pool::PoolConnection, query};
use crate::escape_identifier;
/// Newtype to differentiate between workspace and application database
/// connections.
#[derive(Debug)]

View file

@ -22,6 +22,3 @@ pub mod pg_database;
pub mod pg_namespace;
pub mod pg_role;
pub mod rolnames;
mod utils;
pub use utils::escape_identifier;

View file

@ -1,10 +1,9 @@
use std::fmt::Display;
use phono_pestgros::escape_identifier;
use sqlx::{Encode, Postgres, postgres::types::Oid, query_as, query_as_unchecked};
use crate::{
client::WorkspaceClient, escape_identifier, pg_acl::PgAclItem, pg_namespace::PgNamespace,
};
use crate::{client::WorkspaceClient, pg_acl::PgAclItem, pg_namespace::PgNamespace};
#[derive(Clone, Debug)]
pub struct PgClass {

View file

@ -1,25 +0,0 @@
/// Given a raw identifier (such as a table name, column name, etc.), format it
/// so that it may be safely interpolated into a SQL query.
pub fn escape_identifier(identifier: &str) -> String {
// Escaping identifiers for Postgres is fairly easy, provided that the input is
// already known to contain no invalid multi-byte sequences. Backslashes may
// remain as-is, and embedded double quotes are escaped simply by doubling
// them (`"` becomes `""`). Refer to the PQescapeInternal() function in
// libpq (fe-exec.c) and Diesel's PgQueryBuilder::push_identifier().
format!("\"{}\"", identifier.replace('"', "\"\""))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_escape_identifier() {
assert_eq!(escape_identifier("hello"), r#""hello""#);
assert_eq!(escape_identifier("hello world"), r#""hello world""#);
assert_eq!(
escape_identifier(r#""hello" "world""#),
r#""""hello"" ""world""""#
);
}
}

View file

@ -9,6 +9,7 @@ chrono = { workspace = true }
derive_builder = { workspace = true }
futures = { workspace = true }
phono-backends = { workspace = true }
phono-pestgros = { workspace = true }
redact = { workspace = true }
regex = { workspace = true }
serde = { workspace = true }

View file

@ -1,67 +0,0 @@
use bigdecimal::BigDecimal;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use uuid::Uuid;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "t", content = "c")]
pub enum Datum {
// BigDecimal is used because a user may insert a value directly via SQL
// which overflows the representational space of `rust_decimal::Decimal`.
// Note that by default, [`BigDecimal`] serializes to JSON as a string. This
// behavior can be modified, but it's a pain when paired with the [`Option`]
// type. String representation should be acceptable for the UI, as [`Datum`]
// values should always be parsed through Zod, which can coerce the value to
// a number transparently.
Numeric(Option<BigDecimal>),
Text(Option<String>),
Timestamp(Option<DateTime<Utc>>),
Uuid(Option<Uuid>),
}
impl Datum {
// TODO: Can something similar be achieved with a generic return type?
/// Bind this as a parameter to a sqlx query.
pub fn bind_onto<'a>(
self,
query: sqlx::query::Query<'a, Postgres, <sqlx::Postgres as sqlx::Database>::Arguments<'a>>,
) -> sqlx::query::Query<'a, Postgres, <sqlx::Postgres as sqlx::Database>::Arguments<'a>> {
match self {
Self::Numeric(value) => query.bind(value),
Self::Text(value) => query.bind(value),
Self::Timestamp(value) => query.bind(value),
Self::Uuid(value) => query.bind(value),
}
}
/// Push this as a parameter to a [`QueryBuilder`].
pub fn push_bind_onto(self, builder: &mut QueryBuilder<'_, Postgres>) {
match self {
Self::Numeric(value) => builder.push_bind(value),
Self::Text(value) => builder.push_bind(value),
Self::Timestamp(value) => builder.push_bind(value),
Self::Uuid(value) => builder.push_bind(value),
};
}
/// Transform the contained value into a serde_json::Value.
pub fn inner_as_value(&self) -> serde_json::Value {
let serialized = serde_json::to_value(self).unwrap();
#[derive(Deserialize)]
struct Tagged {
c: serde_json::Value,
}
let deserialized: Tagged = serde_json::from_value(serialized).unwrap();
deserialized.c
}
pub fn is_none(&self) -> bool {
match self {
Self::Numeric(None) | Self::Text(None) | Self::Timestamp(None) | Self::Uuid(None) => {
true
}
Self::Numeric(_) | Self::Text(_) | Self::Timestamp(_) | Self::Uuid(_) => false,
}
}
}

View file

@ -1,10 +1,8 @@
use std::fmt::Display;
use phono_backends::escape_identifier;
use phono_pestgros::{Datum, QueryFragment, escape_identifier};
use serde::{Deserialize, Serialize};
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
/// operations that are read-only and otherwise safe to execute.

View file

@ -2,6 +2,7 @@ use bigdecimal::BigDecimal;
use chrono::{DateTime, Utc};
use derive_builder::Builder;
use phono_backends::pg_attribute::PgAttribute;
use phono_pestgros::Datum;
use serde::{Deserialize, Serialize};
use sqlx::Acquire as _;
use sqlx::{
@ -11,9 +12,7 @@ use sqlx::{
use thiserror::Error;
use uuid::Uuid;
use crate::client::AppDbClient;
use crate::datum::Datum;
use crate::presentation::Presentation;
use crate::{client::AppDbClient, presentation::Presentation};
/// A materialization of a database column, fit for consumption by an end user.
///

View file

@ -17,7 +17,6 @@
pub mod accessors;
pub mod client;
pub mod cluster;
pub mod datum;
pub mod errors;
pub mod expression;
pub mod field;
@ -25,7 +24,6 @@ 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

@ -1,171 +0,0 @@
//! 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

@ -35,7 +35,7 @@ use pest::{
};
use pest_derive::Parser;
pub use crate::datum::Datum;
pub use crate::{datum::Datum, query_builders::QueryFragment};
mod datum;
mod query_builders;

View file

@ -220,54 +220,3 @@ impl From<Expr> for QueryBuilder<'_, Postgres> {
Self::from(QueryFragment::from(value))
}
}
/// 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

@ -23,6 +23,7 @@ percent-encoding = "2.3.1"
phono-backends = { workspace = true }
phono-models = { workspace = true }
phono-namegen = { workspace = true }
phono-pestgros = { workspace = true }
rand = { workspace = true }
redact = { workspace = true }
regex = { workspace = true }

View file

@ -7,7 +7,6 @@ use anyhow::anyhow;
use askama::Template;
use phono_backends::{
client::WorkspaceClient,
escape_identifier,
pg_acl::{PgAclItem, PgPrivilegeType},
pg_class::PgClass,
pg_role::RoleTree,
@ -17,6 +16,7 @@ use phono_backends::{
},
};
use phono_models::{accessors::Actor, service_cred::ServiceCred, user::User};
use phono_pestgros::escape_identifier;
use serde::{Deserialize, Serialize};
use sqlx::{postgres::types::Oid, prelude::FromRow, query, query_as};
use tracing::{Instrument, info_span};

View file

@ -6,12 +6,13 @@ use axum::{
// [`axum_extra`]'s form extractor is preferred:
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
use axum_extra::extract::Form;
use phono_backends::{escape_identifier, pg_class::PgClass};
use phono_backends::pg_class::PgClass;
use phono_models::{
accessors::{Accessor as _, Actor, portal::PortalAccessor},
field::Field,
presentation::Presentation,
};
use phono_pestgros::escape_identifier;
use serde::Deserialize;
use sqlx::{postgres::types::Oid, query};
use uuid::Uuid;

View file

@ -5,19 +5,16 @@ use axum::{
extract::{Path, State},
response::{IntoResponse as _, Response},
};
use phono_backends::{
escape_identifier, pg_acl::PgPrivilegeType, pg_attribute::PgAttribute, pg_class::PgClass,
};
use phono_backends::{pg_acl::PgPrivilegeType, pg_attribute::PgAttribute, pg_class::PgClass};
use phono_models::{
accessors::{Accessor, Actor, portal::PortalAccessor},
datum::Datum,
expression::PgExpressionAny,
field::Field,
query_builders::{QueryFragment, SelectQuery},
};
use phono_pestgros::{Datum, QueryFragment, escape_identifier};
use serde::{Deserialize, Serialize};
use sqlx::{
QueryBuilder,
Postgres, QueryBuilder,
postgres::{PgRow, types::Oid},
};
use tracing::debug;
@ -47,6 +44,57 @@ pub(super) struct FormBody {
const FRONTEND_ROW_LIMIT: i64 = 1000;
/// 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()
}
}
/// HTTP GET handler for an API endpoint returning a JSON encoding of portal
/// data to display in a table or similar form. If the `subfilter` URL parameter
/// is specified, it is `&&`-ed with the portal's stored filter.

View file

@ -8,11 +8,9 @@ use axum::{
// [`axum_extra`]'s form extractor is required to support repeated keys:
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
use axum_extra::extract::Form;
use phono_backends::{escape_identifier, pg_acl::PgPrivilegeType, pg_class::PgClass};
use phono_models::{
accessors::{Accessor as _, Actor, portal::PortalAccessor},
datum::Datum,
};
use phono_backends::{pg_acl::PgPrivilegeType, pg_class::PgClass};
use phono_models::accessors::{Accessor as _, Actor, portal::PortalAccessor};
use phono_pestgros::{Datum, escape_identifier};
use serde::Deserialize;
use sqlx::{postgres::types::Oid, query};
use uuid::Uuid;

View file

@ -3,11 +3,12 @@ use axum::{
extract::{Path, State},
response::Response,
};
use phono_backends::{escape_identifier, pg_class::PgClass};
use phono_backends::pg_class::PgClass;
use phono_models::{
accessors::{Accessor, Actor, portal::PortalAccessor},
field::Field,
};
use phono_pestgros::escape_identifier;
use serde::Deserialize;
use sqlx::{postgres::types::Oid, query};
use uuid::Uuid;

View file

@ -5,7 +5,8 @@ use axum::{
extract::{Path, State},
response::Response,
};
use phono_backends::{escape_identifier, pg_class::PgClass};
use phono_backends::pg_class::PgClass;
use phono_pestgros::escape_identifier;
use regex::Regex;
use serde::Deserialize;
use sqlx::{postgres::types::Oid, query};

View file

@ -5,13 +5,9 @@ use axum::{
extract::{Path, State},
response::{IntoResponse as _, Response},
};
use phono_backends::{
escape_identifier, pg_acl::PgPrivilegeType, pg_attribute::PgAttribute, pg_class::PgClass,
};
use phono_models::{
accessors::{Accessor, Actor, portal::PortalAccessor},
datum::Datum,
};
use phono_backends::{pg_acl::PgPrivilegeType, pg_attribute::PgAttribute, pg_class::PgClass};
use phono_models::accessors::{Accessor, Actor, portal::PortalAccessor};
use phono_pestgros::{Datum, escape_identifier};
use serde::Deserialize;
use serde_json::json;
use sqlx::{Acquire as _, postgres::types::Oid, query};

View file

@ -1,9 +1,10 @@
use axum::{extract::State, response::IntoResponse};
use phono_backends::{client::WorkspaceClient, escape_identifier, rolnames::ROLE_PREFIX_USER};
use phono_backends::{client::WorkspaceClient, rolnames::ROLE_PREFIX_USER};
use phono_models::{
client::AppDbClient, cluster::Cluster, user::User, workspace::Workspace,
workspace_user_perm::WorkspaceMembership,
};
use phono_pestgros::escape_identifier;
use sqlx::{Connection as _, PgConnection, query};
use crate::{

View file

@ -3,14 +3,14 @@ use axum::{
extract::{Path, State},
response::IntoResponse,
};
use phono_backends::{
escape_identifier,
rolnames::{ROLE_PREFIX_SERVICE_CRED, SERVICE_CRED_CONN_LIMIT, SERVICE_CRED_SUFFIX_LEN},
use phono_backends::rolnames::{
ROLE_PREFIX_SERVICE_CRED, SERVICE_CRED_CONN_LIMIT, SERVICE_CRED_SUFFIX_LEN,
};
use phono_models::{
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
service_cred::ServiceCred,
};
use phono_pestgros::escape_identifier;
use rand::distributions::{Alphanumeric, DistString};
use redact::Secret;
use serde::Deserialize;

View file

@ -2,14 +2,11 @@ use axum::{
extract::{Path, State},
response::IntoResponse,
};
use phono_backends::{
escape_identifier,
rolnames::{
ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER,
ROLE_PREFIX_USER,
},
use phono_backends::rolnames::{
ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER, ROLE_PREFIX_USER,
};
use phono_models::accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor};
use phono_pestgros::escape_identifier;
use serde::Deserialize;
use sqlx::{Acquire as _, query};
use uuid::Uuid;

View file

@ -3,12 +3,13 @@ use axum::{
extract::{Path, State},
response::IntoResponse,
};
use phono_backends::{escape_identifier, pg_database::PgDatabase, rolnames::ROLE_PREFIX_USER};
use phono_backends::{pg_database::PgDatabase, rolnames::ROLE_PREFIX_USER};
use phono_models::{
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
user::User,
workspace_user_perm::WorkspaceMembership,
};
use phono_pestgros::escape_identifier;
use serde::Deserialize;
use sqlx::query;
use uuid::Uuid;