forked from 2sys/phonograph
replace bespoke expression constructs with sql parsing
This commit is contained in:
parent
36a0c27ad4
commit
928f6cb759
21 changed files with 241 additions and 872 deletions
|
|
@ -0,0 +1,2 @@
|
|||
alter table portals add column if not exists table_filter jsonb not null default 'null';
|
||||
alter table portals drop column if exists filter;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
alter table portals add column if not exists filter text not null default '';
|
||||
-- This is irreversible and ordinarily should be run in a later migration, but
|
||||
-- it's being rolled out while manually verifying that there will be negligible
|
||||
-- impact to users, so I'm folding it into this migration for convenience.
|
||||
alter table portals drop column if exists table_filter;
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
use std::fmt::Display;
|
||||
|
||||
use phono_pestgros::{Datum, QueryFragment, escape_identifier};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 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.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(tag = "t", content = "c")]
|
||||
pub enum PgExpressionAny {
|
||||
Comparison(PgComparisonExpression),
|
||||
Identifier(PgIdentifierExpression),
|
||||
Literal(Datum),
|
||||
ToJson(PgToJsonExpression),
|
||||
}
|
||||
|
||||
impl PgExpressionAny {
|
||||
pub fn into_query_fragment(self) -> QueryFragment {
|
||||
match self {
|
||||
Self::Comparison(expr) => expr.into_query_fragment(),
|
||||
Self::Identifier(expr) => expr.into_query_fragment(),
|
||||
Self::Literal(expr) => {
|
||||
if expr.is_none() {
|
||||
QueryFragment::from_sql("null")
|
||||
} else {
|
||||
QueryFragment::from_param(expr)
|
||||
}
|
||||
}
|
||||
Self::ToJson(expr) => expr.into_query_fragment(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(tag = "t", content = "c")]
|
||||
pub enum PgComparisonExpression {
|
||||
Infix(PgInfixExpression<PgComparisonOperator>),
|
||||
IsNull(PgIsNullExpression),
|
||||
IsNotNull(PgIsNotNullExpression),
|
||||
}
|
||||
|
||||
impl PgComparisonExpression {
|
||||
fn into_query_fragment(self) -> QueryFragment {
|
||||
match self {
|
||||
Self::Infix(expr) => expr.into_query_fragment(),
|
||||
Self::IsNull(expr) => expr.into_query_fragment(),
|
||||
Self::IsNotNull(expr) => expr.into_query_fragment(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct PgInfixExpression<T: Display> {
|
||||
pub operator: T,
|
||||
pub lhs: Box<PgExpressionAny>,
|
||||
pub rhs: Box<PgExpressionAny>,
|
||||
}
|
||||
|
||||
impl<T: Display> PgInfixExpression<T> {
|
||||
fn into_query_fragment(self) -> QueryFragment {
|
||||
QueryFragment::concat([
|
||||
QueryFragment::from_sql("(("),
|
||||
self.lhs.into_query_fragment(),
|
||||
QueryFragment::from_sql(&format!(") {} (", self.operator)),
|
||||
self.rhs.into_query_fragment(),
|
||||
QueryFragment::from_sql("))"),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, strum::Display, Deserialize, PartialEq, Serialize)]
|
||||
pub enum PgComparisonOperator {
|
||||
#[strum(to_string = "and")]
|
||||
And,
|
||||
#[strum(to_string = "=")]
|
||||
Eq,
|
||||
#[strum(to_string = ">")]
|
||||
Gt,
|
||||
#[strum(to_string = "<")]
|
||||
Lt,
|
||||
#[strum(to_string = "<>")]
|
||||
Neq,
|
||||
#[strum(to_string = "or")]
|
||||
Or,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct PgIsNullExpression {
|
||||
lhs: Box<PgExpressionAny>,
|
||||
}
|
||||
|
||||
impl PgIsNullExpression {
|
||||
fn into_query_fragment(self) -> QueryFragment {
|
||||
QueryFragment::concat([
|
||||
QueryFragment::from_sql("(("),
|
||||
self.lhs.into_query_fragment(),
|
||||
QueryFragment::from_sql(") is null)"),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct PgIsNotNullExpression {
|
||||
lhs: Box<PgExpressionAny>,
|
||||
}
|
||||
|
||||
impl PgIsNotNullExpression {
|
||||
fn into_query_fragment(self) -> QueryFragment {
|
||||
QueryFragment::concat([
|
||||
QueryFragment::from_sql("(("),
|
||||
self.lhs.into_query_fragment(),
|
||||
QueryFragment::from_sql(") is not null)"),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct PgIdentifierExpression {
|
||||
pub parts_raw: Vec<String>,
|
||||
}
|
||||
|
||||
impl PgIdentifierExpression {
|
||||
fn into_query_fragment(self) -> QueryFragment {
|
||||
QueryFragment::join(
|
||||
self.parts_raw
|
||||
.iter()
|
||||
.map(|part| QueryFragment::from_sql(&escape_identifier(part))),
|
||||
QueryFragment::from_sql("."),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct PgToJsonExpression {
|
||||
entries: Vec<(String, PgExpressionAny)>,
|
||||
}
|
||||
|
||||
impl PgToJsonExpression {
|
||||
/// Generates a query fragment to the effect of:
|
||||
/// `to_json((select ($expr) as "ident", ($expr2) as "ident2"))`
|
||||
fn into_query_fragment(self) -> QueryFragment {
|
||||
if self.entries.is_empty() {
|
||||
QueryFragment::from_sql("'{}'")
|
||||
} else {
|
||||
QueryFragment::concat([
|
||||
QueryFragment::from_sql("to_json((select "),
|
||||
QueryFragment::join(
|
||||
self.entries.into_iter().map(|(key, value)| {
|
||||
QueryFragment::concat([
|
||||
QueryFragment::from_sql("("),
|
||||
value.into_query_fragment(),
|
||||
QueryFragment::from_sql(&format!(") as {}", escape_identifier(&key))),
|
||||
])
|
||||
}),
|
||||
QueryFragment::from_sql(", "),
|
||||
),
|
||||
QueryFragment::from_sql("))"),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,6 @@ pub mod accessors;
|
|||
pub mod client;
|
||||
pub mod cluster;
|
||||
pub mod errors;
|
||||
pub mod expression;
|
||||
pub mod field;
|
||||
pub mod language;
|
||||
mod macros;
|
||||
|
|
|
|||
|
|
@ -3,13 +3,11 @@ use std::sync::LazyLock;
|
|||
use derive_builder::Builder;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use sqlx::{postgres::types::Oid, query, query_as, types::Json};
|
||||
use sqlx::{postgres::types::Oid, query, query_as};
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
client::AppDbClient, errors::QueryError, expression::PgExpressionAny, macros::with_id_query,
|
||||
};
|
||||
use crate::{client::AppDbClient, errors::QueryError, macros::with_id_query};
|
||||
|
||||
pub static RE_PORTAL_NAME: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9][()a-zA-Z0-9 _-]*[a-zA-Z0-9()_-]$").unwrap());
|
||||
|
|
@ -36,7 +34,7 @@ pub struct Portal {
|
|||
|
||||
/// JSONB-encoded expression to use for filtering rows in the web-based
|
||||
/// table view.
|
||||
pub table_filter: Json<Option<PgExpressionAny>>,
|
||||
pub filter: String,
|
||||
}
|
||||
|
||||
impl Portal {
|
||||
|
|
@ -65,7 +63,7 @@ select
|
|||
workspace_id,
|
||||
class_oid,
|
||||
form_public,
|
||||
table_filter as "table_filter: Json<Option<PgExpressionAny>>"
|
||||
filter
|
||||
from portals
|
||||
where id = $1
|
||||
"#,
|
||||
|
|
@ -87,7 +85,7 @@ select
|
|||
workspace_id,
|
||||
class_oid,
|
||||
form_public,
|
||||
table_filter as "table_filter: Json<Option<PgExpressionAny>>"
|
||||
filter
|
||||
from portals
|
||||
where workspace_id = $1
|
||||
"#,
|
||||
|
|
@ -122,7 +120,7 @@ select
|
|||
workspace_id,
|
||||
class_oid,
|
||||
form_public,
|
||||
table_filter as "table_filter: Json<Option<PgExpressionAny>>"
|
||||
filter
|
||||
from portals
|
||||
where workspace_id = $1 and class_oid = $2
|
||||
"#,
|
||||
|
|
@ -161,7 +159,7 @@ returning
|
|||
workspace_id,
|
||||
class_oid,
|
||||
form_public,
|
||||
table_filter as "table_filter: Json<Option<PgExpressionAny>>"
|
||||
filter
|
||||
"#,
|
||||
self.workspace_id,
|
||||
self.class_oid,
|
||||
|
|
@ -180,7 +178,7 @@ pub struct Update {
|
|||
form_public: Option<bool>,
|
||||
|
||||
#[builder(default, setter(strip_option = true))]
|
||||
table_filter: Option<Option<PgExpressionAny>>,
|
||||
filter: Option<String>,
|
||||
|
||||
#[builder(default, setter(strip_option = true))]
|
||||
#[validate(regex(path = *RE_PORTAL_NAME))]
|
||||
|
|
@ -196,16 +194,16 @@ impl Update {
|
|||
query!(
|
||||
"update portals set form_public = $1 where id = $2",
|
||||
form_public,
|
||||
self.id
|
||||
self.id,
|
||||
)
|
||||
.execute(app_db.get_conn())
|
||||
.await?;
|
||||
}
|
||||
if let Some(table_filter) = self.table_filter {
|
||||
if let Some(filter) = self.filter {
|
||||
query!(
|
||||
"update portals set table_filter = $1 where id = $2",
|
||||
Json(table_filter) as Json<Option<PgExpressionAny>>,
|
||||
self.id
|
||||
"update portals set filter = $1 where id = $2",
|
||||
filter,
|
||||
self.id,
|
||||
)
|
||||
.execute(app_db.get_conn())
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::error::Error;
|
||||
|
||||
use crate::{ArithOp, Datum, Expr, FnArgs, InfixOp};
|
||||
use crate::{Datum, Expr, FnArgs, InfixOp};
|
||||
|
||||
#[test]
|
||||
fn parses_without_args() -> Result<(), Box<dyn Error>> {
|
||||
|
|
@ -29,7 +29,7 @@ fn parses_with_args() -> Result<(), Box<dyn Error>> {
|
|||
Expr::Literal(Datum::Text(Some("hello!".to_owned()))),
|
||||
Expr::Infix {
|
||||
lhs: Box::new(Expr::Literal(Datum::Numeric(Some(1.into())))),
|
||||
op: InfixOp::ArithInfix(ArithOp::Add),
|
||||
op: InfixOp::Add,
|
||||
rhs: Box::new(Expr::Literal(Datum::Numeric(Some(2.into())))),
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ static PRATT_PARSER: LazyLock<PrattParser<Rule>> = LazyLock::new(|| {
|
|||
/// operators that theoretically evaluates to some value, such as a boolean
|
||||
/// condition, an object name, or a string dynamically derived from other
|
||||
/// values. An expression is *not* a complete SQL statement, command, or query.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Expr {
|
||||
Infix {
|
||||
|
|
@ -186,23 +187,17 @@ impl TryFrom<&str> for Expr {
|
|||
}
|
||||
}
|
||||
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum InfixOp {
|
||||
ArithInfix(ArithOp),
|
||||
BoolInfix(BoolOp),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum ArithOp {
|
||||
// Arithmetic ops:
|
||||
Add,
|
||||
Concat,
|
||||
Div,
|
||||
Mult,
|
||||
Sub,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum BoolOp {
|
||||
// Boolean ops:
|
||||
And,
|
||||
Or,
|
||||
Eq,
|
||||
|
|
@ -277,19 +272,19 @@ fn parse_expr_pairs(expr_pairs: Pairs<'_, Rule>) -> Result<Expr, ParseError> {
|
|||
.map_infix(|lhs, op, rhs| Ok(Expr::Infix {
|
||||
lhs: Box::new(lhs?),
|
||||
op: match op.as_rule() {
|
||||
Rule::Add => InfixOp::ArithInfix(ArithOp::Add),
|
||||
Rule::ConcatInfixOp => InfixOp::ArithInfix(ArithOp::Concat),
|
||||
Rule::Divide => InfixOp::ArithInfix(ArithOp::Div),
|
||||
Rule::Multiply => InfixOp::ArithInfix(ArithOp::Mult),
|
||||
Rule::Subtract => InfixOp::ArithInfix(ArithOp::Sub),
|
||||
Rule::And => InfixOp::BoolInfix(BoolOp::And),
|
||||
Rule::Eq => InfixOp::BoolInfix(BoolOp::Eq),
|
||||
Rule::Gt => InfixOp::BoolInfix(BoolOp::Gt),
|
||||
Rule::GtEq => InfixOp::BoolInfix(BoolOp::Gte),
|
||||
Rule::Lt => InfixOp::BoolInfix(BoolOp::Lt),
|
||||
Rule::LtEq => InfixOp::BoolInfix(BoolOp::Lte),
|
||||
Rule::NotEq => InfixOp::BoolInfix(BoolOp::Neq),
|
||||
Rule::Or => InfixOp::BoolInfix(BoolOp::Or),
|
||||
Rule::Add => InfixOp::Add,
|
||||
Rule::ConcatInfixOp => InfixOp::Concat,
|
||||
Rule::Divide => InfixOp::Div,
|
||||
Rule::Multiply => InfixOp::Mult,
|
||||
Rule::Subtract => InfixOp::Sub,
|
||||
Rule::And => InfixOp::And,
|
||||
Rule::Eq => InfixOp::Eq,
|
||||
Rule::Gt => InfixOp::Gt,
|
||||
Rule::GtEq => InfixOp::Gte,
|
||||
Rule::Lt => InfixOp::Lt,
|
||||
Rule::LtEq => InfixOp::Lte,
|
||||
Rule::NotEq => InfixOp::Neq,
|
||||
Rule::Or => InfixOp::Or,
|
||||
rule => Err(ParseError::UnknownRule(rule))?,
|
||||
},
|
||||
rhs: Box::new(rhs?),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Unit tests for infix operator parsing within expressions.
|
||||
|
||||
use crate::{ArithOp, Datum, Expr, InfixOp};
|
||||
use crate::{Datum, Expr, InfixOp};
|
||||
|
||||
#[test]
|
||||
fn add_op_parses() {
|
||||
|
|
@ -9,7 +9,7 @@ fn add_op_parses() {
|
|||
Expr::try_from("six + 7"),
|
||||
Ok(Expr::Infix {
|
||||
lhs: Box::new(Expr::ObjName(vec!["six".to_owned()])),
|
||||
op: InfixOp::ArithInfix(ArithOp::Add),
|
||||
op: InfixOp::Add,
|
||||
rhs: Box::new(Expr::Literal(Datum::Numeric(Some(7.into())))),
|
||||
})
|
||||
);
|
||||
|
|
@ -21,7 +21,7 @@ fn mult_op_parses() {
|
|||
Expr::try_from("six * 7"),
|
||||
Ok(Expr::Infix {
|
||||
lhs: Box::new(Expr::ObjName(vec!["six".to_owned()])),
|
||||
op: InfixOp::ArithInfix(ArithOp::Mult),
|
||||
op: InfixOp::Mult,
|
||||
rhs: Box::new(Expr::Literal(Datum::Numeric(Some(7.into())))),
|
||||
})
|
||||
);
|
||||
|
|
@ -35,13 +35,13 @@ fn arith_precedence() {
|
|||
lhs: Box::new(Expr::Infix {
|
||||
lhs: Box::new(Expr::Infix {
|
||||
lhs: Box::new(Expr::Literal(Datum::Numeric(Some(1.into())))),
|
||||
op: InfixOp::ArithInfix(ArithOp::Add),
|
||||
op: InfixOp::Add,
|
||||
rhs: Box::new(Expr::Literal(Datum::Numeric(Some(2.into())))),
|
||||
}),
|
||||
op: InfixOp::ArithInfix(ArithOp::Mult),
|
||||
op: InfixOp::Mult,
|
||||
rhs: Box::new(Expr::Literal(Datum::Numeric(Some(3.into())))),
|
||||
}),
|
||||
op: InfixOp::ArithInfix(ArithOp::Add),
|
||||
op: InfixOp::Add,
|
||||
rhs: Box::new(Expr::Literal(Datum::Numeric(Some(4.into())))),
|
||||
})
|
||||
);
|
||||
|
|
@ -49,13 +49,13 @@ fn arith_precedence() {
|
|||
Expr::try_from("1 - 2 / (3 - 4)"),
|
||||
Ok(Expr::Infix {
|
||||
lhs: Box::new(Expr::Literal(Datum::Numeric(Some(1.into())))),
|
||||
op: InfixOp::ArithInfix(ArithOp::Sub),
|
||||
op: InfixOp::Sub,
|
||||
rhs: Box::new(Expr::Infix {
|
||||
lhs: Box::new(Expr::Literal(Datum::Numeric(Some(2.into())))),
|
||||
op: InfixOp::ArithInfix(ArithOp::Div),
|
||||
op: InfixOp::Div,
|
||||
rhs: Box::new(Expr::Infix {
|
||||
lhs: Box::new(Expr::Literal(Datum::Numeric(Some(3.into())))),
|
||||
op: InfixOp::ArithInfix(ArithOp::Sub),
|
||||
op: InfixOp::Sub,
|
||||
rhs: Box::new(Expr::Literal(Datum::Numeric(Some(4.into())))),
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
use sqlx::{Postgres, QueryBuilder};
|
||||
|
||||
use crate::{ArithOp, BoolOp, Datum, Expr, FnArgs, InfixOp, escape_identifier};
|
||||
use crate::{Datum, Expr, FnArgs, InfixOp, escape_identifier};
|
||||
|
||||
/// Representation of a partial, parameterized SQL query. Allows callers to
|
||||
/// build queries iteratively and dynamically, handling parameter numbering
|
||||
|
|
@ -183,19 +183,19 @@ impl From<Expr> for QueryFragment {
|
|||
impl From<InfixOp> for QueryFragment {
|
||||
fn from(value: InfixOp) -> Self {
|
||||
Self::from_sql(match value {
|
||||
InfixOp::ArithInfix(ArithOp::Add) => "+",
|
||||
InfixOp::ArithInfix(ArithOp::Concat) => "||",
|
||||
InfixOp::ArithInfix(ArithOp::Div) => "/",
|
||||
InfixOp::ArithInfix(ArithOp::Mult) => "*",
|
||||
InfixOp::ArithInfix(ArithOp::Sub) => "-",
|
||||
InfixOp::BoolInfix(BoolOp::And) => "and",
|
||||
InfixOp::BoolInfix(BoolOp::Or) => "or",
|
||||
InfixOp::BoolInfix(BoolOp::Eq) => "=",
|
||||
InfixOp::BoolInfix(BoolOp::Gt) => ">",
|
||||
InfixOp::BoolInfix(BoolOp::Gte) => ">=",
|
||||
InfixOp::BoolInfix(BoolOp::Lt) => "<",
|
||||
InfixOp::BoolInfix(BoolOp::Lte) => "<=",
|
||||
InfixOp::BoolInfix(BoolOp::Neq) => "<>",
|
||||
InfixOp::Add => "+",
|
||||
InfixOp::Concat => "||",
|
||||
InfixOp::Div => "/",
|
||||
InfixOp::Mult => "*",
|
||||
InfixOp::Sub => "-",
|
||||
InfixOp::And => "and",
|
||||
InfixOp::Or => "or",
|
||||
InfixOp::Eq => "=",
|
||||
InfixOp::Gt => ">",
|
||||
InfixOp::Gte => ">=",
|
||||
InfixOp::Lt => "<",
|
||||
InfixOp::Lte => "<=",
|
||||
InfixOp::Neq => "<>",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,16 +8,14 @@ use axum::{
|
|||
use phono_backends::{pg_acl::PgPrivilegeType, pg_attribute::PgAttribute, pg_class::PgClass};
|
||||
use phono_models::{
|
||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
||||
expression::PgExpressionAny,
|
||||
field::Field,
|
||||
};
|
||||
use phono_pestgros::{Datum, QueryFragment, escape_identifier};
|
||||
use phono_pestgros::{Datum, Expr, FnArgs, InfixOp, QueryFragment, escape_identifier};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{
|
||||
Postgres, QueryBuilder,
|
||||
postgres::{PgRow, types::Oid},
|
||||
};
|
||||
use tracing::debug;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
|
|
@ -39,62 +37,12 @@ pub(super) struct PathParams {
|
|||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub(super) struct FormBody {
|
||||
subfilter: Option<String>,
|
||||
#[serde(default)]
|
||||
subfilter: String,
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
@ -176,24 +124,8 @@ pub(super) async fn get(
|
|||
)),
|
||||
filters: QueryFragment::join(
|
||||
[
|
||||
portal
|
||||
.table_filter
|
||||
.0
|
||||
.map(|filter| filter.into_query_fragment()),
|
||||
form.subfilter
|
||||
.and_then(|value| {
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
serde_json::from_str::<Option<PgExpressionAny>>(&value)
|
||||
// Ignore invalid input. A user likely pasted incorrectly
|
||||
// or made a typo.
|
||||
.inspect_err(|_| debug!("ignoring invalid subfilter expression"))
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
})
|
||||
.map(|filter| filter.into_query_fragment()),
|
||||
into_safe_filter_sql(&portal.filter),
|
||||
into_safe_filter_sql(&form.subfilter),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
|
|
@ -263,3 +195,155 @@ 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)]
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// Users are allowed to put any text they want in the `Portal.filter` field.
|
||||
/// This needs to be either transformed into a SQL expression that we trust to
|
||||
/// be injected into a `WHERE` clause or disregarded if no such expression can
|
||||
/// be generated.
|
||||
///
|
||||
/// Given the known (not to mention unknown) limitations of [`phono_pestgros`]'s
|
||||
/// homegrown PostgreSQL grammar, trying to positively establish the correctness
|
||||
/// and trustworthiness of a filter expression exactly as written would be
|
||||
/// impractical and dangerous. Instead, we validate the syntax tree as parsed by
|
||||
/// [`phono_pestgros`] (even if the parsing logic isn't spec-compliant), and use
|
||||
/// the SQL expression only after it has been converted back from parsed form.
|
||||
fn into_safe_filter_sql(expr_text: &str) -> Option<QueryFragment> {
|
||||
if let Ok(expr) = Expr::try_from(expr_text)
|
||||
&& is_safe_filter_expr(&expr)
|
||||
{
|
||||
Some(expr.into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_safe_filter_expr(expr: &Expr) -> bool {
|
||||
match expr {
|
||||
&Expr::Literal(_) | &Expr::ObjName(_) => true,
|
||||
&Expr::Infix {
|
||||
ref lhs,
|
||||
op,
|
||||
ref rhs,
|
||||
} => match op {
|
||||
// Most if not all infix operators should be safe, but enumerate
|
||||
// them just in case.
|
||||
|
||||
// Arithmetic:
|
||||
InfixOp::Add
|
||||
| InfixOp::Concat
|
||||
| InfixOp::Div
|
||||
| InfixOp::Mult
|
||||
| InfixOp::Sub
|
||||
// Boolean:
|
||||
| InfixOp::And
|
||||
| InfixOp::Or
|
||||
| InfixOp::Eq
|
||||
| InfixOp::Gt
|
||||
| InfixOp::Gte
|
||||
| InfixOp::Lt
|
||||
| InfixOp::Lte
|
||||
| InfixOp::Neq => is_safe_filter_expr(lhs) && is_safe_filter_expr(rhs),
|
||||
_ => false,
|
||||
},
|
||||
&Expr::FnCall { ref name, ref args } => match name
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice()
|
||||
{
|
||||
// Math:
|
||||
&["abs"]
|
||||
| &["ceil"]
|
||||
| &["floor"]
|
||||
| &["ln"]
|
||||
| &["log"]
|
||||
| &["mod"]
|
||||
| &["power"]
|
||||
| &["pi"]
|
||||
| &["round"]
|
||||
| &["sqrt"]
|
||||
| &["trunc"]
|
||||
// Timestamp:
|
||||
| &["now"]
|
||||
| &["to_timestamp"]
|
||||
// Strings:
|
||||
| &["upper"]
|
||||
| &["lower"]
|
||||
| &["replace"]
|
||||
| &["btrim"]
|
||||
| &["length"]
|
||||
| &["concat_ws"]
|
||||
| &["lpad"]
|
||||
| &["rpad"]
|
||||
| &["regexp_replace"]
|
||||
| &["regexp_matches"]
|
||||
| &["to_char"]
|
||||
// Misc:
|
||||
| &["any"] => match args {
|
||||
FnArgs::Exprs {
|
||||
distinct_flag,
|
||||
exprs,
|
||||
} => !distinct_flag && exprs.iter().all(is_safe_filter_expr),
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
},
|
||||
&Expr::Not(ref inner) => is_safe_filter_expr(inner),
|
||||
&Expr::Nullness {
|
||||
is_null: _,
|
||||
expr: ref inner,
|
||||
} => is_safe_filter_expr(inner),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ use axum::{
|
|||
use phono_backends::{pg_acl::PgPrivilegeType, pg_attribute::PgAttribute};
|
||||
use phono_models::{
|
||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
||||
expression::PgExpressionAny,
|
||||
workspace::Workspace,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -98,7 +97,7 @@ pub(super) async fn get(
|
|||
struct ResponseTemplate {
|
||||
columns: Vec<ColumnInfo>,
|
||||
attr_names: Vec<String>,
|
||||
filter: Option<PgExpressionAny>,
|
||||
filter: String,
|
||||
settings: Settings,
|
||||
subfilter_str: String,
|
||||
navbar: WorkspaceNav,
|
||||
|
|
@ -107,7 +106,7 @@ pub(super) async fn get(
|
|||
ResponseTemplate {
|
||||
columns,
|
||||
attr_names,
|
||||
filter: portal.table_filter.0,
|
||||
filter: portal.filter,
|
||||
navbar: WorkspaceNav::builder()
|
||||
.navigator(navigator)
|
||||
.workspace(workspace)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ use axum::{
|
|||
use axum_extra::extract::Form;
|
||||
use phono_models::{
|
||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
||||
expression::PgExpressionAny,
|
||||
portal::Portal,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
|
@ -32,11 +31,10 @@ pub(super) struct PathParams {
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct FormBody {
|
||||
filter_expression: Option<String>,
|
||||
filter: String,
|
||||
}
|
||||
|
||||
/// HTTP POST handler for applying a [`PgExpressionAny`] filter to a portal's
|
||||
/// table viewer.
|
||||
/// HTTP POST handler for applying a filter to a portal's table viewer.
|
||||
///
|
||||
/// This handler expects 3 path parameters with the structure described by
|
||||
/// [`PathParams`].
|
||||
|
|
@ -51,7 +49,7 @@ pub(super) async fn post(
|
|||
rel_oid,
|
||||
workspace_id,
|
||||
}): Path<PathParams>,
|
||||
Form(form): Form<FormBody>,
|
||||
Form(FormBody { filter }): Form<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME: csrf
|
||||
|
||||
|
|
@ -70,12 +68,9 @@ pub(super) async fn post(
|
|||
.fetch_one()
|
||||
.await?;
|
||||
|
||||
let filter: Option<PgExpressionAny> =
|
||||
serde_json::from_str(&form.filter_expression.unwrap_or("null".to_owned()))?;
|
||||
|
||||
Portal::update()
|
||||
.id(portal.id)
|
||||
.table_filter(filter)
|
||||
.filter(filter)
|
||||
.build()?
|
||||
.execute(&mut app_db)
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@
|
|||
Portal Settings
|
||||
</a>
|
||||
<filter-menu
|
||||
identifier-hints="{{ attr_names | json }}"
|
||||
initial-value="{{ filter | json }}"
|
||||
initial-value="{{ filter }}"
|
||||
></filter-menu>
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
.expression-editor__container {
|
||||
background: #eee;
|
||||
border-radius: var(--default-border-radius--rounded);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.expression-editor__sidebar {
|
||||
display: grid;
|
||||
grid-template:
|
||||
'padding-top' 1fr
|
||||
'operator-selector' max-content
|
||||
'actions' minmax(max-content, 1fr);
|
||||
}
|
||||
|
||||
.expression-editor__main {
|
||||
background: #fff;
|
||||
border-radius: var(--default-border-radius--rounded);
|
||||
border: solid 1px var(--default-border-color);
|
||||
flex: 1;
|
||||
padding: var(--default-padding);
|
||||
}
|
||||
|
||||
.expression-editor__action-button {
|
||||
padding: var(--default-padding);
|
||||
|
||||
svg path {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.expression-editor__params {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--default-padding);
|
||||
}
|
||||
|
||||
.expression-selector {
|
||||
grid-area: operator-selector;
|
||||
}
|
||||
|
||||
.expression-selector__expression-button {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
width: 2.5rem;
|
||||
|
||||
svg path {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.expression-selector__popover:popover-open {
|
||||
top: anchor(bottom);
|
||||
margin-top: 0.25rem;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.expression-selector__section {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
justify-content: center;
|
||||
list-style-type: none;
|
||||
margin: var(--default-padding);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.expression-selector__li {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
/*
|
||||
@use 'forms';
|
||||
@use 'condition-editor';
|
||||
*/
|
||||
|
||||
/* ======== Theming ======== */
|
||||
|
||||
:root {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
@import "./expression-editor.css";
|
||||
|
||||
:root {
|
||||
--table-header-border-color: var(--default-border-color);
|
||||
--table-cell-border-color: oklch(from var(--default-border-color) calc(l * 1.15) c h);
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
<svelte:options
|
||||
customElement={{
|
||||
props: {
|
||||
identifier_hints: { attribute: "identifier-hints", type: "Array" },
|
||||
value: { reflect: true, type: "Object" },
|
||||
},
|
||||
shadow: "none",
|
||||
tag: "expression-editor",
|
||||
}}
|
||||
/>
|
||||
|
||||
<script lang="ts">
|
||||
import DatumEditor from "./datum-editor.svelte";
|
||||
import ExpressionSelector from "./expression-selector.svelte";
|
||||
import { type PgExpressionAny } from "./expression.svelte";
|
||||
import ExpressionEditor from "./expression-editor.webc.svelte";
|
||||
import { RFC_3339_S, type Presentation } from "./presentation.svelte";
|
||||
import type { Datum } from "./datum.svelte";
|
||||
|
||||
const POTENTIAL_PRESENTATIONS: Presentation[] = [
|
||||
{ t: "Numeric", c: {} },
|
||||
{ t: "Text", c: { input_mode: { t: "MultiLine", c: {} } } },
|
||||
{ t: "Timestamp", c: { format: RFC_3339_S } },
|
||||
{ t: "Uuid", c: {} },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
identifier_hints?: string[];
|
||||
value?: PgExpressionAny;
|
||||
};
|
||||
|
||||
let { identifier_hints = [], value = $bindable() }: Props = $props();
|
||||
|
||||
// Dynamic state to bind to datum editor.
|
||||
let editor_value = $state<Datum | undefined>();
|
||||
let editor_presentation = $state<Presentation>(POTENTIAL_PRESENTATIONS[0]);
|
||||
|
||||
$effect(() => {
|
||||
editor_value = value?.t === "Literal" ? value.c : undefined;
|
||||
});
|
||||
|
||||
function handle_identifier_selector_change(
|
||||
ev: Event & { currentTarget: HTMLSelectElement },
|
||||
) {
|
||||
if (value?.t === "Identifier") {
|
||||
value.c.parts_raw = [ev.currentTarget.value];
|
||||
}
|
||||
}
|
||||
|
||||
function handle_editor_change(datum_value: Datum) {
|
||||
if (value?.t === "Literal") {
|
||||
value.c = datum_value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="expression-editor__container">
|
||||
<div class="expression-editor__sidebar">
|
||||
<ExpressionSelector bind:value />
|
||||
</div>
|
||||
{#if value !== undefined}
|
||||
<div class="expression-editor__main">
|
||||
<div class="expression-editor__params">
|
||||
{#if value.t === "Comparison"}
|
||||
{#if value.c.t === "Infix"}
|
||||
<ExpressionEditor bind:value={value.c.c.lhs} {identifier_hints} />
|
||||
<ExpressionEditor bind:value={value.c.c.rhs} {identifier_hints} />
|
||||
{:else if value.c.t === "IsNull" || value.c.t === "IsNotNull"}
|
||||
<ExpressionEditor bind:value={value.c.c.lhs} {identifier_hints} />
|
||||
{/if}
|
||||
{:else if value.t === "Identifier"}
|
||||
<select
|
||||
onchange={handle_identifier_selector_change}
|
||||
value={value.c.parts_raw[0]}
|
||||
>
|
||||
{#each identifier_hints as hint}
|
||||
<option value={hint}>{hint}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if value.t === "Literal"}
|
||||
<DatumEditor
|
||||
bind:current_presentation={editor_presentation}
|
||||
bind:value={editor_value}
|
||||
potential_presentations={POTENTIAL_PRESENTATIONS}
|
||||
on_change={handle_editor_change}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
<!--
|
||||
@component
|
||||
Dropdown menu with grid of buttons for quickly selecting a Postgres expression
|
||||
type. Used by `<ExpressionEditor />`.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { type PgExpressionAny, expression_icon } from "./expression.svelte";
|
||||
|
||||
type Props = {
|
||||
on_change?(new_value: PgExpressionAny): void;
|
||||
value?: PgExpressionAny;
|
||||
};
|
||||
|
||||
let { on_change, value = $bindable() }: Props = $props();
|
||||
|
||||
let menu_button_element = $state<HTMLButtonElement | undefined>();
|
||||
let popover_element = $state<HTMLDivElement | undefined>();
|
||||
// Hacky workaround because as of September 2025 implicit anchor association
|
||||
// is still pretty broken, at least in Firefox.
|
||||
let anchor_name = $state(`--anchor-${Math.floor(Math.random() * 1000000)}`);
|
||||
|
||||
const expressions: ReadonlyArray<{
|
||||
section_label: string;
|
||||
expressions: ReadonlyArray<PgExpressionAny>;
|
||||
}> = [
|
||||
{
|
||||
section_label: "Comparisons",
|
||||
expressions: [
|
||||
{
|
||||
t: "Comparison",
|
||||
c: {
|
||||
t: "Infix",
|
||||
c: {
|
||||
lhs: { t: "Identifier", c: { parts_raw: [] } },
|
||||
operator: "Eq",
|
||||
rhs: { t: "Literal", c: { t: "Text", c: "" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
t: "Comparison",
|
||||
c: {
|
||||
t: "Infix",
|
||||
c: {
|
||||
lhs: { t: "Identifier", c: { parts_raw: [] } },
|
||||
operator: "Neq",
|
||||
rhs: { t: "Literal", c: { t: "Text", c: "" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
t: "Comparison",
|
||||
c: {
|
||||
t: "Infix",
|
||||
c: {
|
||||
lhs: { t: "Identifier", c: { parts_raw: [] } },
|
||||
operator: "Lt",
|
||||
rhs: { t: "Literal", c: { t: "Text", c: "" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
t: "Comparison",
|
||||
c: {
|
||||
t: "Infix",
|
||||
c: {
|
||||
lhs: { t: "Identifier", c: { parts_raw: [] } },
|
||||
operator: "Gt",
|
||||
rhs: { t: "Literal", c: { t: "Text", c: "" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
t: "Comparison",
|
||||
c: {
|
||||
t: "IsNull",
|
||||
c: {
|
||||
lhs: { t: "Identifier", c: { parts_raw: [] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
t: "Comparison",
|
||||
c: {
|
||||
t: "IsNotNull",
|
||||
c: {
|
||||
lhs: { t: "Identifier", c: { parts_raw: [] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section_label: "Conjunctions",
|
||||
expressions: [
|
||||
{
|
||||
t: "Comparison",
|
||||
c: { t: "Infix", c: { operator: "And" } },
|
||||
},
|
||||
{
|
||||
t: "Comparison",
|
||||
c: { t: "Infix", c: { operator: "Or" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section_label: "Values",
|
||||
expressions: [
|
||||
{
|
||||
t: "Identifier",
|
||||
c: { parts_raw: [] },
|
||||
},
|
||||
{
|
||||
t: "Literal",
|
||||
c: { t: "Text", c: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section_label: "Transformations",
|
||||
expressions: [
|
||||
{
|
||||
t: "ToJson",
|
||||
c: { entries: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
let iconography_current = $derived(value && expression_icon(value));
|
||||
|
||||
function handle_menu_button_click() {
|
||||
popover_element?.togglePopover();
|
||||
}
|
||||
|
||||
function handle_expression_button_click(expr: PgExpressionAny) {
|
||||
value = expr;
|
||||
popover_element?.hidePopover();
|
||||
menu_button_element?.focus();
|
||||
on_change?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="expression-selector">
|
||||
<button
|
||||
aria-label={`Select expression type (current: ${iconography_current?.label ?? "None"})`}
|
||||
bind:this={menu_button_element}
|
||||
class="expression-selector__expression-button"
|
||||
onclick={handle_menu_button_click}
|
||||
style:anchor-name={anchor_name}
|
||||
title={iconography_current?.label}
|
||||
type="button"
|
||||
>
|
||||
{#if value}
|
||||
{@html iconography_current?.html}
|
||||
{:else}
|
||||
<i class="ti ti-circle-plus"></i>
|
||||
{/if}
|
||||
</button>
|
||||
<div
|
||||
bind:this={popover_element}
|
||||
class="popover expression-selector__popover"
|
||||
popover="auto"
|
||||
style:position-anchor={anchor_name}
|
||||
>
|
||||
{#each expressions as section}
|
||||
<ul class="expression-selector__section">
|
||||
{#each section.expressions as expr}
|
||||
{@const iconography = expression_icon(expr)}
|
||||
<li class="expression-selector__li">
|
||||
<button
|
||||
class="expression-selector__expression-button"
|
||||
onclick={() => handle_expression_button_click(expr)}
|
||||
title={iconography.label}
|
||||
type="button"
|
||||
>
|
||||
{@html iconography.html}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { datum_schema } from "./datum.svelte.ts";
|
||||
|
||||
export const all_expression_types = [
|
||||
"Comparison",
|
||||
"Identifier",
|
||||
"Literal",
|
||||
"ToJson",
|
||||
] as const;
|
||||
// Type checking to ensure that all valid enum tags are included.
|
||||
type Assert<_T extends true> = void;
|
||||
type _ = Assert<PgExpressionAny["t"] extends PgExpressionType ? true : false>;
|
||||
|
||||
export const expression_type_schema = z.enum(all_expression_types);
|
||||
|
||||
export const all_infix_comparison_operators = [
|
||||
"Eq",
|
||||
"Neq",
|
||||
"Gt",
|
||||
"Lt",
|
||||
"And",
|
||||
"Or",
|
||||
] as const;
|
||||
|
||||
const pg_comparison_operator_schema = z.enum(all_infix_comparison_operators);
|
||||
|
||||
const pg_infix_expression_schema = z.object({
|
||||
operator: z.union([pg_comparison_operator_schema]),
|
||||
get lhs() {
|
||||
return pg_expression_any_schema.optional();
|
||||
},
|
||||
get rhs() {
|
||||
return pg_expression_any_schema.optional();
|
||||
},
|
||||
});
|
||||
|
||||
const pg_comparison_expression_infix_schema = z.object({
|
||||
t: z.literal("Infix"),
|
||||
c: pg_infix_expression_schema,
|
||||
});
|
||||
|
||||
const pg_is_null_expression_schema = z.object({
|
||||
get lhs() {
|
||||
return pg_expression_any_schema.optional();
|
||||
},
|
||||
});
|
||||
|
||||
const pg_comparison_expression_is_null_schema = z.object({
|
||||
t: z.literal("IsNull"),
|
||||
c: pg_is_null_expression_schema,
|
||||
});
|
||||
|
||||
const pg_is_not_null_expression_schema = z.object({
|
||||
get lhs() {
|
||||
return pg_expression_any_schema.optional();
|
||||
},
|
||||
});
|
||||
|
||||
const pg_comparison_expression_is_not_null_schema = z.object({
|
||||
t: z.literal("IsNotNull"),
|
||||
c: pg_is_not_null_expression_schema,
|
||||
});
|
||||
|
||||
const pg_comparison_expression_schema = z.union([
|
||||
pg_comparison_expression_infix_schema,
|
||||
pg_comparison_expression_is_null_schema,
|
||||
pg_comparison_expression_is_not_null_schema,
|
||||
]);
|
||||
|
||||
const pg_expression_any_comparison_schema = z.object({
|
||||
t: z.literal("Comparison"),
|
||||
c: pg_comparison_expression_schema,
|
||||
});
|
||||
|
||||
const pg_identifier_expression_schema = z.object({
|
||||
parts_raw: z.array(z.string()),
|
||||
});
|
||||
|
||||
const pg_expression_any_identifier_schema = z.object({
|
||||
t: z.literal("Identifier"),
|
||||
c: pg_identifier_expression_schema,
|
||||
});
|
||||
|
||||
const pg_expression_any_literal_schema = z.object({
|
||||
t: z.literal("Literal"),
|
||||
c: datum_schema,
|
||||
});
|
||||
|
||||
const pg_to_json_expression_schema = z.object({
|
||||
get entries() {
|
||||
return z.array(z.tuple([z.string(), pg_expression_any_schema.optional()]));
|
||||
},
|
||||
});
|
||||
|
||||
const pg_expression_any_to_json_expression_schema = z.object({
|
||||
t: z.literal("ToJson"),
|
||||
c: pg_to_json_expression_schema,
|
||||
});
|
||||
|
||||
export const pg_expression_any_schema = z.union([
|
||||
pg_expression_any_comparison_schema,
|
||||
pg_expression_any_identifier_schema,
|
||||
pg_expression_any_literal_schema,
|
||||
pg_expression_any_to_json_expression_schema,
|
||||
]);
|
||||
|
||||
export type PgExpressionAny = z.infer<typeof pg_expression_any_schema>;
|
||||
export type PgExpressionType = z.infer<typeof expression_type_schema>;
|
||||
|
||||
export function expression_human_name(expr_type: PgExpressionType): string {
|
||||
if (expr_type === "Comparison") {
|
||||
return "Condition";
|
||||
}
|
||||
if (expr_type === "Identifier") {
|
||||
return "Identifier";
|
||||
}
|
||||
if (expr_type === "Literal") {
|
||||
return "Literal";
|
||||
}
|
||||
if (expr_type === "ToJson") {
|
||||
return "JSON";
|
||||
}
|
||||
// Type guard to check for exhaustive matching.
|
||||
type _ = Assert<typeof expr_type extends never ? true : false>;
|
||||
throw new Error("this should be unreachable");
|
||||
}
|
||||
|
||||
export function expression_icon(expr: PgExpressionAny): {
|
||||
html: string;
|
||||
label: string;
|
||||
} {
|
||||
if (expr.t === "Comparison") {
|
||||
if (expr.c.t === "Infix") {
|
||||
const op = expr.c.c.operator;
|
||||
if (op === "And") {
|
||||
return { html: "&&", label: "And" };
|
||||
}
|
||||
if (op === "Eq") {
|
||||
return { html: "=", label: "Is Equal To" };
|
||||
}
|
||||
if (op === "Gt") {
|
||||
return { html: ">", label: "Is Greater Than" };
|
||||
}
|
||||
if (op === "Lt") {
|
||||
return { html: "<", label: "Is Less Than" };
|
||||
}
|
||||
if (op === "Or") {
|
||||
return { html: "||", label: "Or" };
|
||||
}
|
||||
if (op === "Neq") {
|
||||
return { html: "\u2260", label: "Is Not Equal To" };
|
||||
}
|
||||
// Type guard to check for exhaustive matching.
|
||||
type _ = Assert<typeof op extends never ? true : false>;
|
||||
throw new Error("this should be unreachable");
|
||||
} else if (expr.c.t === "IsNull") {
|
||||
return { html: '<i class="ti ti-cube-3d-sphere-off"></i>', label: "Is Null" };
|
||||
} else if (expr.c.t === "IsNotNull") {
|
||||
return { html: '<i class="ti ti-cube"></i>', label: "Is Not Null" };
|
||||
}
|
||||
// Type guard to check for exhaustive matching.
|
||||
type _ = Assert<typeof expr.c extends never ? true : false>;
|
||||
throw new Error("this should be unreachable");
|
||||
} else if (expr.t === "Identifier") {
|
||||
return { html: '<i class="ti ti-variable"></i>', label: "Dynamic Value" };
|
||||
} else if (expr.t === "Literal") {
|
||||
return { html: '<i class="ti ti-hash"></i>', label: "Static Value" };
|
||||
} else if (expr.t === "ToJson") {
|
||||
return { html: '<i class="ti ti-code"></i>', label: "JSON String" };
|
||||
}
|
||||
// Type guard to check for exhaustive matching.
|
||||
type _ = Assert<typeof expr extends never ? true : false>;
|
||||
throw new Error("this should be unreachable");
|
||||
}
|
||||
|
|
@ -1,51 +1,42 @@
|
|||
<svelte:options
|
||||
customElement={{
|
||||
props: {
|
||||
identifier_hints: { attribute: "identifier-hints", type: "Array" },
|
||||
initialValue: { attribute: "initial-value", type: "Object" },
|
||||
},
|
||||
props: { initialValue: { attribute: "initial-value" } },
|
||||
shadow: "none",
|
||||
tag: "filter-menu",
|
||||
}}
|
||||
/>
|
||||
|
||||
<script lang="ts">
|
||||
import { type PgExpressionAny } from "./expression.svelte";
|
||||
import BasicDropdown from "./basic-dropdown.webc.svelte";
|
||||
import ExpressionEditor from "./expression-editor.webc.svelte";
|
||||
|
||||
type Props = {
|
||||
identifier_hints?: string[];
|
||||
initialValue?: PgExpressionAny | null;
|
||||
initialValue?: string;
|
||||
};
|
||||
|
||||
let { identifier_hints = [], initialValue }: Props = $props();
|
||||
let { initialValue = "" }: Props = $props();
|
||||
|
||||
let expr = $state<PgExpressionAny | undefined>(initialValue ?? undefined);
|
||||
|
||||
function handle_clear_button_click() {
|
||||
expr = undefined;
|
||||
}
|
||||
let expr = $state(initialValue);
|
||||
</script>
|
||||
|
||||
<div class="filter-menu toolbar-item">
|
||||
<BasicDropdown>
|
||||
<span slot="button-contents">Filter</span>
|
||||
<form action="set-filter" class="padded" method="post" slot="popover">
|
||||
<ExpressionEditor bind:value={expr} {identifier_hints} />
|
||||
<div class="form__label">Filter expression (SQL)</div>
|
||||
<textarea
|
||||
class="form__input"
|
||||
name="filter"
|
||||
rows="8"
|
||||
cols="60"
|
||||
placeholder="For example: LOWER("my_column") = 'hello world'"
|
||||
>{expr}</textarea
|
||||
>
|
||||
<div class="form__buttons">
|
||||
<input
|
||||
name="filter_expression"
|
||||
type="hidden"
|
||||
value={JSON.stringify(expr)}
|
||||
/>
|
||||
<button
|
||||
class="button button--secondary"
|
||||
onclick={handle_clear_button_click}
|
||||
type="button"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button class="button button--primary" type="submit">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ component.
|
|||
subfilter?: string;
|
||||
};
|
||||
|
||||
let { columns = [], subfilter = "null" }: Props = $props();
|
||||
let { columns = [], subfilter = "" }: Props = $props();
|
||||
|
||||
type LazyData = {
|
||||
count: number;
|
||||
|
|
@ -71,7 +71,7 @@ component.
|
|||
{columns}
|
||||
fields={lazy_data.fields}
|
||||
rows_main={lazy_data.rows}
|
||||
subfilter_active={!!subfilter && subfilter !== "null"}
|
||||
subfilter_active={!!subfilter}
|
||||
total_count={lazy_data.count}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue