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 client;
|
||||||
pub mod cluster;
|
pub mod cluster;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod expression;
|
|
||||||
pub mod field;
|
pub mod field;
|
||||||
pub mod language;
|
pub mod language;
|
||||||
mod macros;
|
mod macros;
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,11 @@ use std::sync::LazyLock;
|
||||||
use derive_builder::Builder;
|
use derive_builder::Builder;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::Serialize;
|
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 uuid::Uuid;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
use crate::{
|
use crate::{client::AppDbClient, errors::QueryError, macros::with_id_query};
|
||||||
client::AppDbClient, errors::QueryError, expression::PgExpressionAny, macros::with_id_query,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub static RE_PORTAL_NAME: LazyLock<Regex> =
|
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());
|
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
|
/// JSONB-encoded expression to use for filtering rows in the web-based
|
||||||
/// table view.
|
/// table view.
|
||||||
pub table_filter: Json<Option<PgExpressionAny>>,
|
pub filter: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Portal {
|
impl Portal {
|
||||||
|
|
@ -65,7 +63,7 @@ select
|
||||||
workspace_id,
|
workspace_id,
|
||||||
class_oid,
|
class_oid,
|
||||||
form_public,
|
form_public,
|
||||||
table_filter as "table_filter: Json<Option<PgExpressionAny>>"
|
filter
|
||||||
from portals
|
from portals
|
||||||
where id = $1
|
where id = $1
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -87,7 +85,7 @@ select
|
||||||
workspace_id,
|
workspace_id,
|
||||||
class_oid,
|
class_oid,
|
||||||
form_public,
|
form_public,
|
||||||
table_filter as "table_filter: Json<Option<PgExpressionAny>>"
|
filter
|
||||||
from portals
|
from portals
|
||||||
where workspace_id = $1
|
where workspace_id = $1
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -122,7 +120,7 @@ select
|
||||||
workspace_id,
|
workspace_id,
|
||||||
class_oid,
|
class_oid,
|
||||||
form_public,
|
form_public,
|
||||||
table_filter as "table_filter: Json<Option<PgExpressionAny>>"
|
filter
|
||||||
from portals
|
from portals
|
||||||
where workspace_id = $1 and class_oid = $2
|
where workspace_id = $1 and class_oid = $2
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -161,7 +159,7 @@ returning
|
||||||
workspace_id,
|
workspace_id,
|
||||||
class_oid,
|
class_oid,
|
||||||
form_public,
|
form_public,
|
||||||
table_filter as "table_filter: Json<Option<PgExpressionAny>>"
|
filter
|
||||||
"#,
|
"#,
|
||||||
self.workspace_id,
|
self.workspace_id,
|
||||||
self.class_oid,
|
self.class_oid,
|
||||||
|
|
@ -180,7 +178,7 @@ pub struct Update {
|
||||||
form_public: Option<bool>,
|
form_public: Option<bool>,
|
||||||
|
|
||||||
#[builder(default, setter(strip_option = true))]
|
#[builder(default, setter(strip_option = true))]
|
||||||
table_filter: Option<Option<PgExpressionAny>>,
|
filter: Option<String>,
|
||||||
|
|
||||||
#[builder(default, setter(strip_option = true))]
|
#[builder(default, setter(strip_option = true))]
|
||||||
#[validate(regex(path = *RE_PORTAL_NAME))]
|
#[validate(regex(path = *RE_PORTAL_NAME))]
|
||||||
|
|
@ -196,16 +194,16 @@ impl Update {
|
||||||
query!(
|
query!(
|
||||||
"update portals set form_public = $1 where id = $2",
|
"update portals set form_public = $1 where id = $2",
|
||||||
form_public,
|
form_public,
|
||||||
self.id
|
self.id,
|
||||||
)
|
)
|
||||||
.execute(app_db.get_conn())
|
.execute(app_db.get_conn())
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
if let Some(table_filter) = self.table_filter {
|
if let Some(filter) = self.filter {
|
||||||
query!(
|
query!(
|
||||||
"update portals set table_filter = $1 where id = $2",
|
"update portals set filter = $1 where id = $2",
|
||||||
Json(table_filter) as Json<Option<PgExpressionAny>>,
|
filter,
|
||||||
self.id
|
self.id,
|
||||||
)
|
)
|
||||||
.execute(app_db.get_conn())
|
.execute(app_db.get_conn())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
|
||||||
use crate::{ArithOp, Datum, Expr, FnArgs, InfixOp};
|
use crate::{Datum, Expr, FnArgs, InfixOp};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_without_args() -> Result<(), Box<dyn Error>> {
|
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::Literal(Datum::Text(Some("hello!".to_owned()))),
|
||||||
Expr::Infix {
|
Expr::Infix {
|
||||||
lhs: Box::new(Expr::Literal(Datum::Numeric(Some(1.into())))),
|
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())))),
|
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
|
/// operators that theoretically evaluates to some value, such as a boolean
|
||||||
/// condition, an object name, or a string dynamically derived from other
|
/// condition, an object name, or a string dynamically derived from other
|
||||||
/// values. An expression is *not* a complete SQL statement, command, or query.
|
/// values. An expression is *not* a complete SQL statement, command, or query.
|
||||||
|
#[non_exhaustive]
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub enum Expr {
|
pub enum Expr {
|
||||||
Infix {
|
Infix {
|
||||||
|
|
@ -186,23 +187,17 @@ impl TryFrom<&str> for Expr {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[non_exhaustive]
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub enum InfixOp {
|
pub enum InfixOp {
|
||||||
ArithInfix(ArithOp),
|
// Arithmetic ops:
|
||||||
BoolInfix(BoolOp),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub enum ArithOp {
|
|
||||||
Add,
|
Add,
|
||||||
Concat,
|
Concat,
|
||||||
Div,
|
Div,
|
||||||
Mult,
|
Mult,
|
||||||
Sub,
|
Sub,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
// Boolean ops:
|
||||||
pub enum BoolOp {
|
|
||||||
And,
|
And,
|
||||||
Or,
|
Or,
|
||||||
Eq,
|
Eq,
|
||||||
|
|
@ -277,19 +272,19 @@ fn parse_expr_pairs(expr_pairs: Pairs<'_, Rule>) -> Result<Expr, ParseError> {
|
||||||
.map_infix(|lhs, op, rhs| Ok(Expr::Infix {
|
.map_infix(|lhs, op, rhs| Ok(Expr::Infix {
|
||||||
lhs: Box::new(lhs?),
|
lhs: Box::new(lhs?),
|
||||||
op: match op.as_rule() {
|
op: match op.as_rule() {
|
||||||
Rule::Add => InfixOp::ArithInfix(ArithOp::Add),
|
Rule::Add => InfixOp::Add,
|
||||||
Rule::ConcatInfixOp => InfixOp::ArithInfix(ArithOp::Concat),
|
Rule::ConcatInfixOp => InfixOp::Concat,
|
||||||
Rule::Divide => InfixOp::ArithInfix(ArithOp::Div),
|
Rule::Divide => InfixOp::Div,
|
||||||
Rule::Multiply => InfixOp::ArithInfix(ArithOp::Mult),
|
Rule::Multiply => InfixOp::Mult,
|
||||||
Rule::Subtract => InfixOp::ArithInfix(ArithOp::Sub),
|
Rule::Subtract => InfixOp::Sub,
|
||||||
Rule::And => InfixOp::BoolInfix(BoolOp::And),
|
Rule::And => InfixOp::And,
|
||||||
Rule::Eq => InfixOp::BoolInfix(BoolOp::Eq),
|
Rule::Eq => InfixOp::Eq,
|
||||||
Rule::Gt => InfixOp::BoolInfix(BoolOp::Gt),
|
Rule::Gt => InfixOp::Gt,
|
||||||
Rule::GtEq => InfixOp::BoolInfix(BoolOp::Gte),
|
Rule::GtEq => InfixOp::Gte,
|
||||||
Rule::Lt => InfixOp::BoolInfix(BoolOp::Lt),
|
Rule::Lt => InfixOp::Lt,
|
||||||
Rule::LtEq => InfixOp::BoolInfix(BoolOp::Lte),
|
Rule::LtEq => InfixOp::Lte,
|
||||||
Rule::NotEq => InfixOp::BoolInfix(BoolOp::Neq),
|
Rule::NotEq => InfixOp::Neq,
|
||||||
Rule::Or => InfixOp::BoolInfix(BoolOp::Or),
|
Rule::Or => InfixOp::Or,
|
||||||
rule => Err(ParseError::UnknownRule(rule))?,
|
rule => Err(ParseError::UnknownRule(rule))?,
|
||||||
},
|
},
|
||||||
rhs: Box::new(rhs?),
|
rhs: Box::new(rhs?),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Unit tests for infix operator parsing within expressions.
|
//! Unit tests for infix operator parsing within expressions.
|
||||||
|
|
||||||
use crate::{ArithOp, Datum, Expr, InfixOp};
|
use crate::{Datum, Expr, InfixOp};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_op_parses() {
|
fn add_op_parses() {
|
||||||
|
|
@ -9,7 +9,7 @@ fn add_op_parses() {
|
||||||
Expr::try_from("six + 7"),
|
Expr::try_from("six + 7"),
|
||||||
Ok(Expr::Infix {
|
Ok(Expr::Infix {
|
||||||
lhs: Box::new(Expr::ObjName(vec!["six".to_owned()])),
|
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())))),
|
rhs: Box::new(Expr::Literal(Datum::Numeric(Some(7.into())))),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -21,7 +21,7 @@ fn mult_op_parses() {
|
||||||
Expr::try_from("six * 7"),
|
Expr::try_from("six * 7"),
|
||||||
Ok(Expr::Infix {
|
Ok(Expr::Infix {
|
||||||
lhs: Box::new(Expr::ObjName(vec!["six".to_owned()])),
|
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())))),
|
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::Infix {
|
lhs: Box::new(Expr::Infix {
|
||||||
lhs: Box::new(Expr::Literal(Datum::Numeric(Some(1.into())))),
|
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())))),
|
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())))),
|
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())))),
|
rhs: Box::new(Expr::Literal(Datum::Numeric(Some(4.into())))),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -49,13 +49,13 @@ fn arith_precedence() {
|
||||||
Expr::try_from("1 - 2 / (3 - 4)"),
|
Expr::try_from("1 - 2 / (3 - 4)"),
|
||||||
Ok(Expr::Infix {
|
Ok(Expr::Infix {
|
||||||
lhs: Box::new(Expr::Literal(Datum::Numeric(Some(1.into())))),
|
lhs: Box::new(Expr::Literal(Datum::Numeric(Some(1.into())))),
|
||||||
op: InfixOp::ArithInfix(ArithOp::Sub),
|
op: InfixOp::Sub,
|
||||||
rhs: Box::new(Expr::Infix {
|
rhs: Box::new(Expr::Infix {
|
||||||
lhs: Box::new(Expr::Literal(Datum::Numeric(Some(2.into())))),
|
lhs: Box::new(Expr::Literal(Datum::Numeric(Some(2.into())))),
|
||||||
op: InfixOp::ArithInfix(ArithOp::Div),
|
op: InfixOp::Div,
|
||||||
rhs: Box::new(Expr::Infix {
|
rhs: Box::new(Expr::Infix {
|
||||||
lhs: Box::new(Expr::Literal(Datum::Numeric(Some(3.into())))),
|
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())))),
|
rhs: Box::new(Expr::Literal(Datum::Numeric(Some(4.into())))),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
use sqlx::{Postgres, QueryBuilder};
|
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
|
/// Representation of a partial, parameterized SQL query. Allows callers to
|
||||||
/// build queries iteratively and dynamically, handling parameter numbering
|
/// build queries iteratively and dynamically, handling parameter numbering
|
||||||
|
|
@ -183,19 +183,19 @@ impl From<Expr> for QueryFragment {
|
||||||
impl From<InfixOp> for QueryFragment {
|
impl From<InfixOp> for QueryFragment {
|
||||||
fn from(value: InfixOp) -> Self {
|
fn from(value: InfixOp) -> Self {
|
||||||
Self::from_sql(match value {
|
Self::from_sql(match value {
|
||||||
InfixOp::ArithInfix(ArithOp::Add) => "+",
|
InfixOp::Add => "+",
|
||||||
InfixOp::ArithInfix(ArithOp::Concat) => "||",
|
InfixOp::Concat => "||",
|
||||||
InfixOp::ArithInfix(ArithOp::Div) => "/",
|
InfixOp::Div => "/",
|
||||||
InfixOp::ArithInfix(ArithOp::Mult) => "*",
|
InfixOp::Mult => "*",
|
||||||
InfixOp::ArithInfix(ArithOp::Sub) => "-",
|
InfixOp::Sub => "-",
|
||||||
InfixOp::BoolInfix(BoolOp::And) => "and",
|
InfixOp::And => "and",
|
||||||
InfixOp::BoolInfix(BoolOp::Or) => "or",
|
InfixOp::Or => "or",
|
||||||
InfixOp::BoolInfix(BoolOp::Eq) => "=",
|
InfixOp::Eq => "=",
|
||||||
InfixOp::BoolInfix(BoolOp::Gt) => ">",
|
InfixOp::Gt => ">",
|
||||||
InfixOp::BoolInfix(BoolOp::Gte) => ">=",
|
InfixOp::Gte => ">=",
|
||||||
InfixOp::BoolInfix(BoolOp::Lt) => "<",
|
InfixOp::Lt => "<",
|
||||||
InfixOp::BoolInfix(BoolOp::Lte) => "<=",
|
InfixOp::Lte => "<=",
|
||||||
InfixOp::BoolInfix(BoolOp::Neq) => "<>",
|
InfixOp::Neq => "<>",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,14 @@ use axum::{
|
||||||
use phono_backends::{pg_acl::PgPrivilegeType, pg_attribute::PgAttribute, pg_class::PgClass};
|
use phono_backends::{pg_acl::PgPrivilegeType, pg_attribute::PgAttribute, pg_class::PgClass};
|
||||||
use phono_models::{
|
use phono_models::{
|
||||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
accessors::{Accessor, Actor, portal::PortalAccessor},
|
||||||
expression::PgExpressionAny,
|
|
||||||
field::Field,
|
field::Field,
|
||||||
};
|
};
|
||||||
use phono_pestgros::{Datum, QueryFragment, escape_identifier};
|
use phono_pestgros::{Datum, Expr, FnArgs, InfixOp, QueryFragment, escape_identifier};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
Postgres, QueryBuilder,
|
Postgres, QueryBuilder,
|
||||||
postgres::{PgRow, types::Oid},
|
postgres::{PgRow, types::Oid},
|
||||||
};
|
};
|
||||||
use tracing::debug;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
|
|
@ -39,62 +37,12 @@ pub(super) struct PathParams {
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate)]
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
pub(super) struct FormBody {
|
pub(super) struct FormBody {
|
||||||
subfilter: Option<String>,
|
#[serde(default)]
|
||||||
|
subfilter: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
const FRONTEND_ROW_LIMIT: i64 = 1000;
|
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
|
/// 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
|
/// 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.
|
/// is specified, it is `&&`-ed with the portal's stored filter.
|
||||||
|
|
@ -176,24 +124,8 @@ pub(super) async fn get(
|
||||||
)),
|
)),
|
||||||
filters: QueryFragment::join(
|
filters: QueryFragment::join(
|
||||||
[
|
[
|
||||||
portal
|
into_safe_filter_sql(&portal.filter),
|
||||||
.table_filter
|
into_safe_filter_sql(&form.subfilter),
|
||||||
.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_iter()
|
.into_iter()
|
||||||
.flatten(),
|
.flatten(),
|
||||||
|
|
@ -263,3 +195,155 @@ pub(super) async fn get(
|
||||||
})
|
})
|
||||||
.into_response())
|
.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_backends::{pg_acl::PgPrivilegeType, pg_attribute::PgAttribute};
|
||||||
use phono_models::{
|
use phono_models::{
|
||||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
accessors::{Accessor, Actor, portal::PortalAccessor},
|
||||||
expression::PgExpressionAny,
|
|
||||||
workspace::Workspace,
|
workspace::Workspace,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -98,7 +97,7 @@ pub(super) async fn get(
|
||||||
struct ResponseTemplate {
|
struct ResponseTemplate {
|
||||||
columns: Vec<ColumnInfo>,
|
columns: Vec<ColumnInfo>,
|
||||||
attr_names: Vec<String>,
|
attr_names: Vec<String>,
|
||||||
filter: Option<PgExpressionAny>,
|
filter: String,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
subfilter_str: String,
|
subfilter_str: String,
|
||||||
navbar: WorkspaceNav,
|
navbar: WorkspaceNav,
|
||||||
|
|
@ -107,7 +106,7 @@ pub(super) async fn get(
|
||||||
ResponseTemplate {
|
ResponseTemplate {
|
||||||
columns,
|
columns,
|
||||||
attr_names,
|
attr_names,
|
||||||
filter: portal.table_filter.0,
|
filter: portal.filter,
|
||||||
navbar: WorkspaceNav::builder()
|
navbar: WorkspaceNav::builder()
|
||||||
.navigator(navigator)
|
.navigator(navigator)
|
||||||
.workspace(workspace)
|
.workspace(workspace)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ use axum::{
|
||||||
use axum_extra::extract::Form;
|
use axum_extra::extract::Form;
|
||||||
use phono_models::{
|
use phono_models::{
|
||||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
accessors::{Accessor, Actor, portal::PortalAccessor},
|
||||||
expression::PgExpressionAny,
|
|
||||||
portal::Portal,
|
portal::Portal,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
@ -32,11 +31,10 @@ pub(super) struct PathParams {
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct FormBody {
|
pub(super) struct FormBody {
|
||||||
filter_expression: Option<String>,
|
filter: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HTTP POST handler for applying a [`PgExpressionAny`] filter to a portal's
|
/// HTTP POST handler for applying a filter to a portal's table viewer.
|
||||||
/// table viewer.
|
|
||||||
///
|
///
|
||||||
/// This handler expects 3 path parameters with the structure described by
|
/// This handler expects 3 path parameters with the structure described by
|
||||||
/// [`PathParams`].
|
/// [`PathParams`].
|
||||||
|
|
@ -51,7 +49,7 @@ pub(super) async fn post(
|
||||||
rel_oid,
|
rel_oid,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
}): Path<PathParams>,
|
}): Path<PathParams>,
|
||||||
Form(form): Form<FormBody>,
|
Form(FormBody { filter }): Form<FormBody>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
// FIXME: csrf
|
// FIXME: csrf
|
||||||
|
|
||||||
|
|
@ -70,12 +68,9 @@ pub(super) async fn post(
|
||||||
.fetch_one()
|
.fetch_one()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let filter: Option<PgExpressionAny> =
|
|
||||||
serde_json::from_str(&form.filter_expression.unwrap_or("null".to_owned()))?;
|
|
||||||
|
|
||||||
Portal::update()
|
Portal::update()
|
||||||
.id(portal.id)
|
.id(portal.id)
|
||||||
.table_filter(filter)
|
.filter(filter)
|
||||||
.build()?
|
.build()?
|
||||||
.execute(&mut app_db)
|
.execute(&mut app_db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,7 @@
|
||||||
Portal Settings
|
Portal Settings
|
||||||
</a>
|
</a>
|
||||||
<filter-menu
|
<filter-menu
|
||||||
identifier-hints="{{ attr_names | json }}"
|
initial-value="{{ filter }}"
|
||||||
initial-value="{{ filter | json }}"
|
|
||||||
></filter-menu>
|
></filter-menu>
|
||||||
{% endblock %}
|
{% 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 ======== */
|
/* ======== Theming ======== */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
@import "./expression-editor.css";
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--table-header-border-color: var(--default-border-color);
|
--table-header-border-color: var(--default-border-color);
|
||||||
--table-cell-border-color: oklch(from var(--default-border-color) calc(l * 1.15) c h);
|
--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
|
<svelte:options
|
||||||
customElement={{
|
customElement={{
|
||||||
props: {
|
props: { initialValue: { attribute: "initial-value" } },
|
||||||
identifier_hints: { attribute: "identifier-hints", type: "Array" },
|
|
||||||
initialValue: { attribute: "initial-value", type: "Object" },
|
|
||||||
},
|
|
||||||
shadow: "none",
|
shadow: "none",
|
||||||
tag: "filter-menu",
|
tag: "filter-menu",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type PgExpressionAny } from "./expression.svelte";
|
|
||||||
import BasicDropdown from "./basic-dropdown.webc.svelte";
|
import BasicDropdown from "./basic-dropdown.webc.svelte";
|
||||||
import ExpressionEditor from "./expression-editor.webc.svelte";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
identifier_hints?: string[];
|
initialValue?: string;
|
||||||
initialValue?: PgExpressionAny | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let { identifier_hints = [], initialValue }: Props = $props();
|
let { initialValue = "" }: Props = $props();
|
||||||
|
|
||||||
let expr = $state<PgExpressionAny | undefined>(initialValue ?? undefined);
|
let expr = $state(initialValue);
|
||||||
|
|
||||||
function handle_clear_button_click() {
|
|
||||||
expr = undefined;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="filter-menu toolbar-item">
|
<div class="filter-menu toolbar-item">
|
||||||
<BasicDropdown>
|
<BasicDropdown>
|
||||||
<span slot="button-contents">Filter</span>
|
<span slot="button-contents">Filter</span>
|
||||||
<form action="set-filter" class="padded" method="post" slot="popover">
|
<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">
|
<div class="form__buttons">
|
||||||
<input
|
<input
|
||||||
name="filter_expression"
|
name="filter_expression"
|
||||||
type="hidden"
|
type="hidden"
|
||||||
value={JSON.stringify(expr)}
|
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>
|
<button class="button button--primary" type="submit">Apply</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ component.
|
||||||
subfilter?: string;
|
subfilter?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { columns = [], subfilter = "null" }: Props = $props();
|
let { columns = [], subfilter = "" }: Props = $props();
|
||||||
|
|
||||||
type LazyData = {
|
type LazyData = {
|
||||||
count: number;
|
count: number;
|
||||||
|
|
@ -71,7 +71,7 @@ component.
|
||||||
{columns}
|
{columns}
|
||||||
fields={lazy_data.fields}
|
fields={lazy_data.fields}
|
||||||
rows_main={lazy_data.rows}
|
rows_main={lazy_data.rows}
|
||||||
subfilter_active={!!subfilter && subfilter !== "null"}
|
subfilter_active={!!subfilter}
|
||||||
total_count={lazy_data.count}
|
total_count={lazy_data.count}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue