diff --git a/Cargo.lock b/Cargo.lock index c4ed6837..61c31545 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1296,6 +1296,23 @@ dependencies = [ "stringprep", ] +[[package]] +name = "postgres-protocol" +version = "0.6.7" +source = "git+https://github.com/levkk/rust-postgres.git?branch=levkk-make-error-pub#810fdaa2563f285e288c3ba49c53c58a91c9740d" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand 0.8.5", + "sha2", + "stringprep", +] + [[package]] name = "postgres-types" version = "0.2.8" @@ -1304,7 +1321,20 @@ checksum = "f66ea23a2d0e5734297357705193335e0a957696f34bed2f2faefacb2fec336f" dependencies = [ "bytes", "fallible-iterator", - "postgres-protocol", + "postgres-protocol 0.6.7 (registry+https://github.com/rust-lang/crates.io-index)", + "serde", + "serde_json", + "time", +] + +[[package]] +name = "postgres-types" +version = "0.2.8" +source = "git+https://github.com/levkk/rust-postgres.git?branch=levkk-make-error-pub#810fdaa2563f285e288c3ba49c53c58a91c9740d" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol 0.6.7 (git+https://github.com/levkk/rust-postgres.git?branch=levkk-make-error-pub)", "serde", "serde_json", "time", @@ -1645,7 +1675,7 @@ dependencies = [ "rand 0.8.5", "rayon", "regex", - "rwf-macros 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", + "rwf-macros 0.1.4", "serde", "serde_json", "sha1", @@ -1653,7 +1683,7 @@ dependencies = [ "thiserror", "time", "tokio", - "tokio-postgres", + "tokio-postgres 0.7.12 (git+https://github.com/levkk/rust-postgres.git?branch=levkk-make-error-pub)", "toml", "tracing", "tracing-subscriber", @@ -1683,7 +1713,7 @@ dependencies = [ "thiserror", "time", "tokio", - "tokio-postgres", + "tokio-postgres 0.7.12 (registry+https://github.com/rust-lang/crates.io-index)", "toml", "tracing", "tracing-subscriber", @@ -1693,11 +1723,13 @@ dependencies = [ name = "rwf-admin" version = "0.1.0" dependencies = [ + "once_cell", "rwf 0.1.3", "serde", "serde_json", "time", "tokio", + "uuid", ] [[package]] @@ -2084,8 +2116,33 @@ dependencies = [ "percent-encoding", "phf", "pin-project-lite", - "postgres-protocol", - "postgres-types", + "postgres-protocol 0.6.7 (registry+https://github.com/rust-lang/crates.io-index)", + "postgres-types 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", + "rand 0.8.5", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.12" +source = "git+https://github.com/levkk/rust-postgres.git?branch=levkk-make-error-pub#810fdaa2563f285e288c3ba49c53c58a91c9740d" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol 0.6.7 (git+https://github.com/levkk/rust-postgres.git?branch=levkk-make-error-pub)", + "postgres-types 0.2.8 (git+https://github.com/levkk/rust-postgres.git?branch=levkk-make-error-pub)", "rand 0.8.5", "socket2", "tokio", diff --git a/rwf-admin/Cargo.toml b/rwf-admin/Cargo.toml index 7f0d2b81..d4ead102 100644 --- a/rwf-admin/Cargo.toml +++ b/rwf-admin/Cargo.toml @@ -10,4 +10,6 @@ rwf = { path = "../rwf" } tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -time = { version = "0.3", features = ["serde"] } +time = { version = "0.3", features = ["formatting", "serde", "parsing", "macros"] } +once_cell = "1" +uuid = { version = "*", features = ["v4"]} diff --git a/rwf-admin/src/controllers/mod.rs b/rwf-admin/src/controllers/mod.rs index e91e72f2..f704bec4 100644 --- a/rwf-admin/src/controllers/mod.rs +++ b/rwf-admin/src/controllers/mod.rs @@ -2,6 +2,8 @@ use rwf::job::JobModel; use rwf::prelude::*; use rwf::serde::Serialize; +pub mod models; + #[derive(Default)] pub struct Index; diff --git a/rwf-admin/src/controllers/models.rs b/rwf-admin/src/controllers/models.rs new file mode 100644 index 00000000..e79a1df6 --- /dev/null +++ b/rwf-admin/src/controllers/models.rs @@ -0,0 +1,182 @@ +use std::collections::HashMap; + +use rwf::model::{Escape, Row}; +use rwf::prelude::*; + +use uuid::Uuid; + +#[derive(Clone, macros::Model)] +struct Table { + table_name: String, +} + +impl Table { + async fn load() -> Result, Error> { + Ok(Pool::pool() + .with_connection(|mut conn| async move { + Table::find_by_sql( + " + SELECT + relname::text AS table_name + FROM + pg_class + WHERE + pg_table_is_visible(oid) + AND reltype != 0 + AND relname NOT LIKE 'pg_%' + ORDER BY oid", + &[], + ) + .fetch_all(&mut conn) + .await + }) + .await?) + } +} + +#[derive(Clone, macros::Model)] +struct TableColumn { + table_name: String, + column_name: String, + data_type: String, + column_default: String, +} + +impl TableColumn { + pub async fn for_table(name: &str) -> Result, Error> { + Ok(Pool::pool() + .with_connection(|mut conn| async move { + TableColumn::find_by_sql( + "SELECT + table_name::text, + column_name::text, + data_type::text, + COALESCE(column_default::text, '')::text AS column_default + FROM information_schema.columns + INNER JOIN pg_class ON table_name = relname + INNER JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace + WHERE pg_class.relname = $1::text AND pg_table_is_visible(pg_class.oid) + ORDER BY ordinal_position", + &[name.to_value()], + ) + .fetch_all(&mut conn) + .await + }) + .await? + .into_iter() + .map(|c| c.transform_default()) + .collect()) + } + + pub fn transform_default(mut self) -> Self { + if self.column_default == "now()" { + let format = + time::macros::format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); + self.column_default = OffsetDateTime::now_utc().format(format).unwrap(); + } else if self.column_default == "gen_random_uuid()" { + self.column_default = Uuid::new_v4().to_string(); + } else if self.column_default.ends_with("::character varying") { + self.column_default = self + .column_default + .replace("::character varying", "") + .replace("'", ""); + } else if self.column_default.ends_with("::jsonb") { + self.column_default = self.column_default.replace("::jsonb", "").replace("'", ""); + } + + self + } + + pub fn skip(&self) -> bool { + if self.column_default.starts_with("nextval(") { + true + } else { + false + } + } +} + +#[derive(Default)] +pub struct ModelsController; + +#[async_trait] +impl Controller for ModelsController { + async fn handle(&self, _request: &Request) -> Result { + let tables = Table::load().await?; + render!("templates/rwf_admin/models.html", "models" => tables) + } +} + +#[derive(Default)] +pub struct ModelController; + +#[async_trait] +impl Controller for ModelController { + async fn handle(&self, request: &Request) -> Result { + let model = request.query().get::("name"); + let selected_columns = request + .query() + .get::("columns") + .unwrap_or("".to_string()) + .split(",") + .into_iter() + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()) + .collect::>(); + + if let Some(model) = model { + let columns = TableColumn::for_table(&model) + .await? + .into_iter() + .filter(|c| { + selected_columns.contains(&c.column_name) || selected_columns.is_empty() + }) + .collect::>(); + let create_columns = columns + .clone() + .into_iter() + .filter(|c| !c.skip()) + .collect::>(); + let order_by = if columns + .iter() + .find(|c| c.column_name == "id") + .take() + .is_some() + { + "ORDER BY id DESC " + } else { + "" + }; + + if !columns.is_empty() { + let table_name = model.clone(); + let rows = Pool::pool() + .with_connection(|mut conn| async move { + Row::find_by_sql( + format!( + "SELECT * FROM \"{}\" {}LIMIT 25", + table_name.escape(), + order_by + ), + &[], + ) + .fetch_all(&mut conn) + .await + }) + .await?; + let mut data = vec![]; + for row in rows { + data.push(row.values()?); + } + + render!("templates/rwf_admin/model.html", + "table_name" => model, + "columns" => columns, + "rows" => data, + "create_columns" => create_columns) + } + } + + Ok(Response::not_found()) + } +} diff --git a/rwf-admin/src/lib.rs b/rwf-admin/src/lib.rs index 1a2100fe..91b4a6ba 100644 --- a/rwf-admin/src/lib.rs +++ b/rwf-admin/src/lib.rs @@ -1,3 +1,4 @@ +use models::{ModelController, ModelsController}; use rwf::controller::Engine; use rwf::prelude::*; @@ -9,5 +10,7 @@ pub fn engine() -> Engine { route!("/" => Index), route!("/jobs" => Jobs), route!("/requests" => Requests), + route!("/models" => ModelsController), + route!("/models/model" => ModelController), ]) } diff --git a/rwf-admin/static/fonts/MaterialSymbolsOutlined-Regular.ttf b/rwf-admin/static/fonts/MaterialSymbolsOutlined-Regular.ttf new file mode 100644 index 00000000..93c7b755 Binary files /dev/null and b/rwf-admin/static/fonts/MaterialSymbolsOutlined-Regular.ttf differ diff --git a/rwf-admin/static/js/model_controller.js b/rwf-admin/static/js/model_controller.js new file mode 100644 index 00000000..864a07d4 --- /dev/null +++ b/rwf-admin/static/js/model_controller.js @@ -0,0 +1,9 @@ +import { Controller } from "hotwired/stimulus"; + +export default class extends Controller { + connect() { + const elems = this.element.querySelectorAll("select"); + M.FormSelect.init(elems); + M.updateTextFields(); + } +} diff --git a/rwf-admin/static/js/requests_controller.js b/rwf-admin/static/js/requests_controller.js index 39fb2a95..b7cd2a34 100644 --- a/rwf-admin/static/js/requests_controller.js +++ b/rwf-admin/static/js/requests_controller.js @@ -1,4 +1,5 @@ import { Controller } from "hotwired/stimulus"; +import "https://cdn.jsdelivr.net/npm/chart.js"; export default class extends Controller { static targets = ["requestsOk", "chart"]; diff --git a/rwf-admin/templates/rwf_admin/head.html b/rwf-admin/templates/rwf_admin/head.html index 64bc561a..09e73479 100644 --- a/rwf-admin/templates/rwf_admin/head.html +++ b/rwf-admin/templates/rwf_admin/head.html @@ -6,13 +6,12 @@ <%- rwf_head() %> - + @@ -20,9 +19,12 @@ import { Application } from 'hotwired/stimulus' const application = Application.start(); import Requests from '/static/js/requests_controller.js' + import Model from '/static/js/model_controller.js' application.register('requests', Requests) + application.register('model', Model) + - + <%- rwf_turbo_stream("/turbo-stream") %> diff --git a/rwf-admin/templates/rwf_admin/jobs.html b/rwf-admin/templates/rwf_admin/jobs.html index 61df1aab..7ad22e1a 100644 --- a/rwf-admin/templates/rwf_admin/jobs.html +++ b/rwf-admin/templates/rwf_admin/jobs.html @@ -38,6 +38,7 @@

<%= latency %>s

+

Last jobs

<% if jobs %> @@ -66,6 +67,8 @@

<%= latency %>s

<% end %>
+ <% else %> +

There are currently no jobs.

<% end %>
diff --git a/rwf-admin/templates/rwf_admin/model.html b/rwf-admin/templates/rwf_admin/model.html new file mode 100644 index 00000000..ffc0e81d --- /dev/null +++ b/rwf-admin/templates/rwf_admin/model.html @@ -0,0 +1,66 @@ +<%% "templates/rwf_admin/head.html" %> +<%% "templates/rwf_admin/nav.html" %> +
+

<%= table_name.camelize %>

+
+
Create model
+
+ +
+ <% for column in create_columns %> +
+ + +
+ <% end %> +
+
+ +
+
+
+
+

Records

+
+
+
+ + +
+
+ + + + <% for column in columns %> + + <% end %> + + + + <% for row in rows %> + + <% for column in columns %> + + <% end %> + + <% end %> + +
<%= column.column_name %>
+ <%= row[column.column_name] %> +
+
+
+<%% "templates/rwf_admin/footer.html" %> diff --git a/rwf-admin/templates/rwf_admin/models.html b/rwf-admin/templates/rwf_admin/models.html new file mode 100644 index 00000000..0f61c508 --- /dev/null +++ b/rwf-admin/templates/rwf_admin/models.html @@ -0,0 +1,27 @@ +<%% "templates/rwf_admin/head.html" %> +<%% "templates/rwf_admin/nav.html" %> +
+

Models

+
+ + + + + + + + <% for model in models %> + + + + <% end %> + +
Model
+ + <%= model.table_name.camelize %> + +
+
+
+ +<%% "templates/rwf_admin/footer.html" %> diff --git a/rwf-admin/templates/rwf_admin/nav.html b/rwf-admin/templates/rwf_admin/nav.html index ed800425..5a1ed515 100644 --- a/rwf-admin/templates/rwf_admin/nav.html +++ b/rwf-admin/templates/rwf_admin/nav.html @@ -1,9 +1,10 @@ diff --git a/rwf-admin/templates/rwf_admin/reload.html b/rwf-admin/templates/rwf_admin/reload.html index 3c5acb01..e5890382 100644 --- a/rwf-admin/templates/rwf_admin/reload.html +++ b/rwf-admin/templates/rwf_admin/reload.html @@ -7,5 +7,7 @@ " >

<%= name.capitalize %>

- Reload +
+ Reload +
diff --git a/rwf-macros/src/lib.rs b/rwf-macros/src/lib.rs index ab81b3c9..1bdaa389 100644 --- a/rwf-macros/src/lib.rs +++ b/rwf-macros/src/lib.rs @@ -559,7 +559,7 @@ pub fn render(input: TokenStream) -> TokenStream { { let template = rwf::view::template::Template::load(#template_name)?; #(#render_call)* - Ok(rwf::http::Response::new().html(html)) + return Ok(rwf::http::Response::new().html(html)) } } .into() diff --git a/rwf/Cargo.toml b/rwf/Cargo.toml index 64f33f3d..e9608a2f 100644 --- a/rwf/Cargo.toml +++ b/rwf/Cargo.toml @@ -13,7 +13,7 @@ default = [] [dependencies] time = { version = "0.3", features = ["formatting", "serde", "parsing"] } -tokio-postgres = { version = "0.7", features = [ +tokio-postgres = { git = "https://github.com/levkk/rust-postgres.git", branch = "levkk-make-error-pub", features = [ "with-time-0_3", "with-serde_json-1", "with-uuid-1", @@ -25,7 +25,7 @@ parking_lot = "0.12" once_cell = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -rwf-macros = { version = "0.1.4" } +rwf-macros = { path = "../rwf-macros" } colored = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/rwf/src/http/error.html b/rwf/src/http/error.html index 76ca623b..c84d731f 100644 --- a/rwf/src/http/error.html +++ b/rwf/src/http/error.html @@ -24,8 +24,10 @@

<%= title %>

+ <% if message %>
<%= message %>
+ <% end %> diff --git a/rwf/src/http/mod.rs b/rwf/src/http/mod.rs index f8b943da..8d3b4bb9 100644 --- a/rwf/src/http/mod.rs +++ b/rwf/src/http/mod.rs @@ -33,7 +33,7 @@ pub use request::Request; pub use response::Response; pub use router::Router; pub use server::{Server, Stream}; -pub use url::urldecode; +pub use url::{urldecode, urlencode}; pub use websocket::Message; #[derive(Debug, Copy, Clone, PartialEq)] diff --git a/rwf/src/http/response.rs b/rwf/src/http/response.rs index 8d279d7e..b34ae8d8 100644 --- a/rwf/src/http/response.rs +++ b/rwf/src/http/response.rs @@ -346,7 +346,7 @@ impl Response { pub fn internal_error(err: impl std::error::Error) -> Self { // TODO: #[cfg(debug_assertions)] - let err = format!("{:?}", err); + let err = format!("{}", err); #[cfg(not(debug_assertions))] let err = { @@ -354,18 +354,7 @@ impl Response { "" }; - Self::new() - .html(format!( - " -

-
500 - Internal Server Error
-

-

-
{:?}
- ", - err, - )) - .code(500) + Self::internal_error_pretty("500 - Internal Server Error", &err) } pub fn internal_error_pretty(title: &str, message: &str) -> Self { diff --git a/rwf/src/http/url.rs b/rwf/src/http/url.rs index 10a0a385..ce845f90 100644 --- a/rwf/src/http/url.rs +++ b/rwf/src/http/url.rs @@ -51,6 +51,43 @@ pub fn urldecode(s: &str) -> String { result } +pub fn urlencode(s: &str) -> String { + let mut result = String::new(); + + for c in s.chars() { + let replacement = match c { + ':' => "%3A", + '/' => "%2F", + '?' => "%3F", + '#' => "%23", + '[' => "%5B", + ']' => "%5D", + '@' => "%40", + '!' => "%21", + '$' => "%24", + '&' => "%26", + '\'' => "%27", + '(' => "%28", + ')' => "%29", + '*' => "%2A", + '+' => "%2B", + ',' => "%2C", + ';' => "%3B", + '=' => "%3D", + '%' => "%25", + ' ' => "%20", + c => { + result.push(c); + continue; + } + }; + + result.push_str(replacement); + } + + result +} + #[cfg(test)] mod test { use super::*; diff --git a/rwf/src/lib.rs b/rwf/src/lib.rs index 68571e8b..b8bed3e4 100644 --- a/rwf/src/lib.rs +++ b/rwf/src/lib.rs @@ -26,7 +26,6 @@ pub use logging::Logger; use std::net::SocketAddr; -#[allow(dead_code)] fn snake_case(string: &str) -> String { let mut result = "".to_string(); @@ -42,6 +41,16 @@ fn snake_case(string: &str) -> String { result } +fn capitalize(string: &str) -> String { + let mut iter = string.chars(); + let uppercase = match iter.next() { + None => String::new(), + Some(letter) => letter.to_uppercase().chain(iter).collect(), + }; + + uppercase +} + fn peer_addr(addr: &str) -> Option { use std::net::ToSocketAddrs; diff --git a/rwf/src/model/error.rs b/rwf/src/model/error.rs index ba2d97a5..632a5801 100644 --- a/rwf/src/model/error.rs +++ b/rwf/src/model/error.rs @@ -39,6 +39,11 @@ pub enum Error { #[error("io error: {0}")] IoError(#[from] std::io::Error), + + #[error( + "column \"{0}\" is missing from the row returned by the database,\ndid you forget to specify it in the query?" + )] + Column(String), } impl Error { @@ -49,6 +54,11 @@ impl Error { impl From for Error { fn from(error: tokio_postgres::Error) -> Error { - Error::DatabaseError(error) + use tokio_postgres::error::Kind; + + match error.kind() { + &Kind::Column(ref name) => Error::Column(name.clone()), + _ => Error::DatabaseError(error), + } } } diff --git a/rwf/src/model/row.rs b/rwf/src/model/row.rs index 3b73210d..128f6982 100644 --- a/rwf/src/model/row.rs +++ b/rwf/src/model/row.rs @@ -1,6 +1,6 @@ use super::{Error, FromRow, Model, Value}; -use std::sync::Arc; +use std::{collections::HashMap, hash::Hash, sync::Arc}; #[derive(Debug, Clone)] pub struct Row { @@ -51,6 +51,16 @@ impl Row { pub fn new(row: tokio_postgres::Row) -> Self { Self { row: Arc::new(row) } } + + pub fn values(self) -> Result, Error> { + let mut result = HashMap::new(); + for column in self.columns() { + let name = column.name(); + result.insert(name.to_string(), self.try_get(name)?); + } + + Ok(result) + } } #[cfg(test)] diff --git a/rwf/src/model/value.rs b/rwf/src/model/value.rs index 9a051198..0c054838 100644 --- a/rwf/src/model/value.rs +++ b/rwf/src/model/value.rs @@ -367,6 +367,40 @@ impl tokio_postgres::types::ToSql for Value { to_sql_checked!(); } +impl<'a> tokio_postgres::types::FromSql<'a> for Value { + fn from_sql( + ty: &Type, + raw: &'a [u8], + ) -> Result> { + match ty { + &Type::BOOL => Ok(Value::Boolean(bool::from_sql(ty, raw)?)), + &Type::INT8 => Ok(Value::Integer(i64::from_sql(ty, raw)?)), + &Type::INT4 => Ok(Value::Int(i32::from_sql(ty, raw)?)), + &Type::INT2 => Ok(Value::SmallInt(i16::from_sql(ty, raw)?)), + &Type::TEXT | &Type::VARCHAR => Ok(Value::String(String::from_sql(ty, raw)?)), + &Type::JSON | &Type::JSONB => Ok(Value::Json(serde_json::Value::from_sql(ty, raw)?)), + &Type::FLOAT4 => Ok(Value::Real(f32::from_sql(ty, raw)?)), + &Type::FLOAT8 => Ok(Value::Float(f64::from_sql(ty, raw)?)), + &Type::INET => Ok(Value::IpAddr(IpAddr::from_sql(ty, raw)?)), + &Type::TIMESTAMPTZ => Ok(Value::TimestampT(OffsetDateTime::from_sql(ty, raw)?)), + &Type::TIMESTAMP => Ok(Value::Timestamp(PrimitiveDateTime::from_sql(ty, raw)?)), + &Type::UUID => Ok(Value::Uuid(Uuid::from_sql(ty, raw)?)), + + ty => todo!("unimplemented conversion from {:?} to rust", ty), + } + } + + #[allow(unused_variables)] + fn from_sql_null(ty: &Type) -> Result> { + Ok(Value::Null) + } + + #[allow(unused_variables)] + fn accepts(ty: &Type) -> bool { + true + } +} + impl ToSql for Value { fn to_sql(&self) -> String { use Value::*; diff --git a/rwf/src/view/template/error.rs b/rwf/src/view/template/error.rs index d233b595..aed4081c 100644 --- a/rwf/src/view/template/error.rs +++ b/rwf/src/view/template/error.rs @@ -20,8 +20,8 @@ pub enum Error { #[error("variable \"{0}\" is not defined or in scope")] UndefinedVariable(String), - #[error("method \"{0}\" is not defined")] - UnknownMethod(String), + #[error("method \"{0}\" is not defined for type \"{1}\"")] + UnknownMethod(String, &'static str), #[error("template \"{0}\" does not exist")] TemplateDoesNotExist(PathBuf), @@ -34,6 +34,9 @@ pub enum Error { #[error("{0}")] Pretty(String), + + #[error("{0}")] + Runtime(String), } impl Error { diff --git a/rwf/src/view/template/language/expression.rs b/rwf/src/view/template/language/expression.rs index 9bc92baf..98e7f9b5 100644 --- a/rwf/src/view/template/language/expression.rs +++ b/rwf/src/view/template/language/expression.rs @@ -43,7 +43,7 @@ pub enum Expression { // Call a function on a value/expression. Function { term: Box, - name: String, + name: Box, args: Vec, }, @@ -74,7 +74,9 @@ impl Expression { let value = Value::Interpreter; match value.call(term.name(), &[], context) { Ok(value) => Ok(value), - Err(Error::UnknownMethod(_)) => return Err(Error::UndefinedVariable(name)), + Err(Error::UnknownMethod(_, _)) => { + return Err(Error::UndefinedVariable(name)) + } Err(err) => return Err(err), } } @@ -102,12 +104,22 @@ impl Expression { Expression::Function { term, name, args } => { let value = term.evaluate(context)?; + let name = match name.evaluate(context)? { + Value::String(name) => name, + name => { + return Err(Error::Runtime(format!( + "function name should be a string, got {} instead", + name + ))) + } + }; + let args = args .iter() .map(|arg| arg.evaluate(context)) .collect::, Error>>()?; - Ok(value.call(name, &args, context)?) + Ok(value.call(&name, &args, context)?) } Expression::Interpreter => Ok(Value::Interpreter), @@ -272,7 +284,7 @@ impl Expression { Ok(Expression::Function { term: Box::new(expr), - name: name.to_string(), + name: Box::new(Expression::constant(Value::String(name.to_string()))), args, }) } @@ -292,13 +304,28 @@ impl Expression { Token::Variable(name) => Self::function(&name, expr, iter)?, Token::Value(Value::Integer(n)) => Expression::Function { term: Box::new(expr), - name: n.to_string(), + name: Box::new(Expression::constant(Value::String(n.to_string()))), args: vec![], }, _ => return Err(Error::ExpressionSyntax(name.clone())), } } + Some(Token::SquareBracketStart) => { + let _ = iter.next().ok_or(Error::Eof("accessor bracket"))?; + let name = Self::parse(iter)?; + let next = iter.next().ok_or(Error::Eof("expected closing bracket"))?; + match next.token() { + Token::SquareBracketEnd => (), + token => return Err(Error::WrongToken(next, token)), + } + Expression::Function { + term: Box::new(expr), + name: Box::new(name), + args: vec![], + } + } + Some(_) | None => return Ok(expr), }; } @@ -485,10 +512,14 @@ mod test { "hash", Value::Hash(HashMap::from([("key".to_string(), Value::Integer(5))])), )?; + context.set("name", Value::String("key".into()))?; let t1 = "<% (hash.key * 2.5) - 2.5 %>".evaluate(&context)?; assert_eq!(t1, Value::Float(10.0)); + let t1 = "<% hash[name] %>".evaluate(&context)?; + assert_eq!(t1, Value::Integer(5)); + Ok(()) } diff --git a/rwf/src/view/template/language/statement.rs b/rwf/src/view/template/language/statement.rs index bbba6bb8..6b0929a5 100644 --- a/rwf/src/view/template/language/statement.rs +++ b/rwf/src/view/template/language/statement.rs @@ -111,25 +111,36 @@ impl Statement { let mut result = String::new(); let list = list.evaluate(context)?; let mut for_context = context.clone(); - match list { - Value::List(values) => { - for value in values { - match variable { - // Convert the variable to a value from the list. - Term::Variable(name) => { - for_context.set(&name, value)?; - } - Term::Constant(_) => (), // Looks like just a loop with no variables - _ => todo!(), // Function call is interesting - }; + let values = match list { + Value::List(values) => values, + Value::Hash(hash) => hash + .keys() + .cloned() + .into_iter() + .zip(hash.values().cloned()) + .map(|(k, v)| Value::List(vec![Value::String(k), v])) + .collect::>(), + Value::String(s) => s + .chars() + .map(|c| Value::String(String::from(c))) + .collect::>(), + + value => return Err(Error::Runtime(format!("not an iterable: {}", value))), + }; - for statement in body { - result.push_str(&statement.evaluate(&for_context)?); - } + for value in values { + match variable { + // Convert the variable to a value from the list. + Term::Variable(name) => { + for_context.set(&name, value)?; } - } + Term::Constant(_) => (), // Looks like just a loop with no variables + _ => todo!(), // Function call is interesting + }; - _ => return Err(Error::Syntax(TokenWithContext::new(Token::End, 0, 0))), + for statement in body { + result.push_str(&statement.evaluate(&for_context)?); + } } Ok(result) diff --git a/rwf/src/view/template/lexer/value.rs b/rwf/src/view/template/lexer/value.rs index 81e522c3..cb38b143 100644 --- a/rwf/src/view/template/lexer/value.rs +++ b/rwf/src/view/template/lexer/value.rs @@ -12,6 +12,7 @@ use std::cmp::Ordering; use std::collections::HashMap; use crate::model::Model; +use crate::model::Value as ModelValue; use crate::view::template::Template; static TURBO_STREAM: Lazy