diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47625c5d..4a2adb79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,9 @@ jobs: - uses: actions/checkout@v4 - name: Set up cargo cache uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 + with: + key: test-${{ matrix.database }} + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install PostgreSQL ODBC driver if: matrix.setup_odbc run: sudo apt-get install -y odbc-postgresql diff --git a/CHANGELOG.md b/CHANGELOG.md index ae43e4d6..ddc1a143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ - `compress_responses` is now set to `false` by default in the configuration. - When response compression is enabled, additional buffering is needed. Users reported a better experience with pages that load more progressively, reducing the time before the pages' shell is rendered. - When SQLPage is deployed behind a reverse proxy, compressing responses between sqlpage and the proxy is wasteful. + - In the table component, allow simple objects in custom_actions instead of requiring arrays of objects. + - Fatser icon loading. Previously, even a page containing a single icon required downloading and parsing a ~2MB file. This resulted in a delay where pages initially appeared with a blank space before icons appeared. Icons are now inlined inside pages and appear instantaneously. + - Updated tabler icons to 3.35 ## v0.39.0 (2025-10-28) - Ability to execute sql for URL paths with another extension. If you create sitemap.xml.sql, it will be executed for example.com/sitemap.xml diff --git a/Cargo.lock b/Cargo.lock index 96f46222..bfec36a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,9 +290,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -872,9 +872,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.43" +version = "1.2.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "739eb0f94557554b3ca9a86d2d37bebd49c5e6d0c1d2bda35ba5bdac830befc2" +checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" dependencies = [ "find-msvc-tools", "jobserver", @@ -936,9 +936,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.50" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" dependencies = [ "clap_builder", "clap_derive", @@ -946,9 +946,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.50" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" dependencies = [ "anstream", "anstyle", @@ -1652,9 +1652,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" dependencies = [ "serde", "serde_core", @@ -2184,9 +2184,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", @@ -2197,9 +2197,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -2210,11 +2210,10 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -2225,42 +2224,38 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", - "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -2375,22 +2370,22 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jiff" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" dependencies = [ "jiff-static", "log", "portable-atomic", "portable-atomic-util", - "serde", + "serde_core", ] [[package]] name = "jiff-static" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" dependencies = [ "proc-macro2", "quote", @@ -2596,9 +2591,9 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "local-channel" @@ -2754,11 +2749,10 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b" dependencies = [ - "byteorder", "lazy_static", "libm", "num-integer", @@ -3153,9 +3147,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orbclient" -version = "0.3.48" +version = "0.3.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba0b26cec2e24f08ed8bb31519a9333140a6599b867dac464bb150bdb796fd43" +checksum = "247ad146e19b9437f8604c21f8652423595cf710ad108af40e77d3ae6e96b827" dependencies = [ "libredox", ] @@ -3430,9 +3424,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] @@ -3491,9 +3485,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -3790,9 +3784,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.34" +version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "aws-lc-rs", "log", @@ -3825,7 +3819,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.17", - "webpki-roots 1.0.3", + "webpki-roots 1.0.4", "x509-parser", ] @@ -3861,9 +3855,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.7" +version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ "aws-lc-rs", "ring", @@ -3915,9 +3909,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" dependencies = [ "dyn-clone", "ref-cast", @@ -4093,7 +4087,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.12.0", "schemars 0.9.0", - "schemars 1.0.4", + "schemars 1.1.0", "serde_core", "serde_json", "serde_with_macros", @@ -4227,7 +4221,7 @@ dependencies = [ [[package]] name = "sqlpage" -version = "0.39.0" +version = "0.39.1" dependencies = [ "actix-multipart", "actix-rt", @@ -4357,7 +4351,7 @@ dependencies = [ "tokio-util", "url", "uuid", - "webpki-roots 1.0.3", + "webpki-roots 1.0.4", "whoami", ] @@ -4433,9 +4427,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.108" +version = "2.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" dependencies = [ "proc-macro2", "quote", @@ -4548,9 +4542,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -4622,9 +4616,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.16" +version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", @@ -4780,24 +4774,24 @@ checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" [[package]] name = "unicode-ident" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-normalization" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] [[package]] name = "unicode-properties" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" @@ -4996,14 +4990,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.3", + "webpki-roots 1.0.4", ] [[package]] name = "webpki-roots" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" dependencies = [ "rustls-pki-types", ] @@ -5381,9 +5375,9 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "x509-parser" @@ -5443,11 +5437,10 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -5455,9 +5448,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", @@ -5514,9 +5507,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -5525,9 +5518,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -5536,9 +5529,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 465464e9..a436f6bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlpage" -version = "0.39.0" +version = "0.39.1" edition = "2021" description = "Build data user interfaces entirely in SQL. A web server that takes .sql files and formats the query result using pre-made configurable professional-looking components." keywords = ["web", "sql", "framework"] diff --git a/build.rs b/build.rs index ed14c612..3e594482 100644 --- a/build.rs +++ b/build.rs @@ -22,7 +22,10 @@ async fn main() { for h in [ spawn(download_deps(c.clone(), "sqlpage.js")), spawn(download_deps(c.clone(), "sqlpage.css")), - spawn(download_deps(c.clone(), "tabler-icons.svg")), + spawn(download_tabler_icons( + c.clone(), + "https://cdn.jsdelivr.net/npm/@tabler/icons-sprite@3.35.0/dist/tabler-sprite.svg", + )), spawn(download_deps(c.clone(), "apexcharts.js")), spawn(download_deps(c.clone(), "tomselect.js")), spawn(download_deps(c.clone(), "favicon.svg")), @@ -173,6 +176,35 @@ fn make_url_path(url: &str) -> PathBuf { sqlpage_artefacts.join(filename) } +async fn download_tabler_icons(client: Rc, sprite_url: &str) { + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let icon_map_path = out_dir.join("icons.rs"); + let mut sprite_content = Vec::with_capacity(3 * 1024 * 1024); + copy_url_to_opened_file(&client, sprite_url, &mut sprite_content).await; + let mut file = File::create(icon_map_path).unwrap(); + file.write_all(b"[").unwrap(); + extract_icons_from_sprite(&sprite_content, |name, content| { + writeln!(file, "({name:?}, r#\"{content}\"#),").unwrap(); + }); + file.write_all(b"]").unwrap(); +} + +fn extract_icons_from_sprite(sprite_content: &[u8], mut callback: impl FnMut(&str, &str)) { + let mut sprite_str = std::str::from_utf8(sprite_content).unwrap(); + fn take_between<'a>(s: &mut &'a str, start: &str, end: &str) -> Option<&'a str> { + let start_index = s.find(start)?; + let end_index = s[start_index + start.len()..].find(end)?; + let result = &s[start_index + start.len()..][..end_index]; + *s = &s[start_index + start.len() + end_index + end.len()..]; + Some(result) + } + while let Some(mut symbol_tag) = take_between(&mut sprite_str, "") { + let id = take_between(&mut symbol_tag, "id=\"tabler-", "\"").expect("id not found"); + let content_start = symbol_tag.find('>').unwrap() + 1; + callback(id, &symbol_tag[content_start..]); + } +} + /// On debian-based linux distributions, odbc drivers are installed in /usr/lib/-linux-gnu/odbc /// which is not in the default library search path. fn set_odbc_rpath() { diff --git a/examples/nginx/README.md b/examples/nginx/README.md index e1b838b2..46f38ee8 100644 --- a/examples/nginx/README.md +++ b/examples/nginx/README.md @@ -43,6 +43,26 @@ This service sets up a MySQL database with predefined credentials and a persiste The `nginx.conf` file contains the NGINX configuration: +### Streaming and compression + +SQLPage streams HTML as it is generated, so browsers can start rendering before the database finishes returning rows. NGINX enables `proxy_buffering` by default, which can delay those first bytes but stores responses for slow clients. Start with a modest buffer configuration and let the proxy handle compression: + +``` + proxy_buffering on; + proxy_buffer_size 16k; + proxy_buffers 4 16k; + + gzip on; + gzip_buffers 2 4k; + gzip_types text/html text/plain text/css application/javascript application/json; + + chunked_transfer_encoding on; +``` + +Keep buffering when you expect slow clients or longer SQLPage queries, increasing the buffer sizes only if responses overflow. When most users are on fast connections reading lightweight pages, consider reducing the buffer counts or flipping to `proxy_buffering off;` to minimise latency, accepting the extra load on SQLPage. See the [proxy buffering](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering), [gzip](https://nginx.org/en/docs/http/ngx_http_gzip_module.html), and [chunked transfer](https://nginx.org/en/docs/http/ngx_http_core_module.html#chunked_transfer_encoding) directives for more guidance. + +When SQLPage runs behind a reverse proxy, set `compress_responses` to `false` in its configuration (documented [here](https://github.com/sqlpage/SQLPage/blob/main/configuration.md)) so that NGINX can perform compression once at the edge. + ### Rate Limiting diff --git a/examples/official-site/component.sql b/examples/official-site/component.sql index f482d50e..05d4c30c 100644 --- a/examples/official-site/component.sql +++ b/examples/official-site/component.sql @@ -75,7 +75,14 @@ select group_concat( 'select ' || char(10) || ( - with t as (select * from json_tree(top.value)), + with t as ( + select *, + case type + when 'array' then json_array_length(value)>1 + else false + end as is_arr + from json_tree(top.value) + ), key_val as (select CASE t.type WHEN 'integer' THEN t.atom @@ -92,8 +99,8 @@ select ELSE parent.key END as key from t inner join t parent on parent.id = t.parent - where ((parent.fullkey = '$' and t.type != 'array') - or (parent.type = 'array' and parent.path = '$')) + where ((parent.fullkey = '$' and not t.is_arr) + or (parent.path = '$' and parent.is_arr)) ), key_val_padding as (select CASE diff --git a/examples/official-site/examples/form.sql b/examples/official-site/examples/form.sql index aef25559..913074e5 100644 --- a/examples/official-site/examples/form.sql +++ b/examples/official-site/examples/form.sql @@ -1,8 +1,92 @@ -select - 'form' as component, - 'Save' as validate; -select - 'username' as name; -select - 'password' as name, - 'password' as type; \ No newline at end of file +select 'shell' as component, 'dark' as theme, '[View source on Github](https://github.com/sqlpage/SQLPage/blob/main/examples/official-site/examples/form.sql)' as footer; + +SELECT 'form' AS component, 'Complete Input Types Reference' AS title, '/examples/show_variables.sql' as action; + +SELECT 'header' AS type, 'Text Input Types' AS label; + +SELECT 'username' AS name, 'text' AS type, 'Enter your username' AS placeholder, + '**Text** - Default single-line text input. Use for short text like names, usernames, titles. Supports `minlength`, `maxlength`, `pattern` for validation.' AS description_md; + +SELECT 'password' AS name, 'password' AS type, '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$' AS pattern, + '**Password** - Masked text input that hides characters. Use for passwords and sensitive data. Combine with `pattern` attribute for password strength requirements.' AS description_md; + +SELECT 'search_query' AS name, 'search' AS type, 'Search...' AS placeholder, + '**Search** - Search input field, may display a clear button. Use for search boxes. Mobile browsers may show optimized keyboard.' AS description_md; + +SELECT 'bio' AS name, 'textarea' AS type, 5 AS rows, 'Tell us about yourself...' AS placeholder, + '**Textarea** (SQLPage custom) - Multi-line text input. Use for long text like comments, descriptions, articles. Set `rows` to control initial height.' AS description_md; + +SELECT 'header' AS type, 'Numeric Input Types' AS label; + +SELECT 'age' AS name, 'number' AS type, 0 AS min, 120 AS max, 1 AS step, + '**Number** - Numeric input with up/down arrows. Use for quantities, ages, counts. Supports `min`, `max`, `step`. Mobile shows numeric keyboard.' AS description_md; + +SELECT 'price' AS name, 'number' AS type, 0.01 AS step, '$' AS prefix, + '**Number with decimals** - Set `step="0.01"` for currency. Use `prefix`/`suffix` for units. Great for prices, measurements, percentages.' AS description_md; + +SELECT 'volume' AS name, 'range' AS type, 0 AS min, 100 AS max, 50 AS value, 1 AS step, + '**Range** - Slider control for selecting a value. Use for volume, brightness, ratings, or any bounded numeric value where precision isn''t critical.' AS description_md; + +SELECT 'header' AS type, 'Date and Time Types' AS label; + +SELECT 'birth_date' AS name, 'date' AS type, + '**Date** - Date picker (year, month, day). Use for birthdays, deadlines, event dates. Most browsers show a calendar widget. Supports `min` and `max` for date ranges.' AS description_md; + +SELECT 'appointment_time' AS name, 'time' AS type, + '**Time** - Time picker (hours and minutes). Use for appointment times, opening hours, alarms. Shows time selector in supported browsers.' AS description_md; + +SELECT 'meeting_datetime' AS name, 'datetime-local' AS type, + '**Datetime-local** - Date and time picker without timezone. Use for scheduling events, booking appointments, logging timestamps in local time.' AS description_md; + +SELECT 'birth_month' AS name, 'month' AS type, + '**Month** - Month and year picker. Use for credit card expiration dates, monthly reports, subscription periods.' AS description_md; + +SELECT 'vacation_week' AS name, 'week' AS type, + '**Week** - Week and year picker. Use for week-based scheduling, timesheet entry, weekly reports.' AS description_md; + +SELECT 'header' AS type, 'Contact Information Types' AS label; + +SELECT 'user_email' AS name, 'email' AS type, 'user@example.com' AS placeholder, + '**Email** - Email address input with built-in validation. Use for email fields. Browser validates format automatically. Mobile shows @ key on keyboard.' AS description_md; + +SELECT 'phone' AS name, 'tel' AS type, '+1 (555) 123-4567' AS placeholder, + '**Tel** - Telephone number input. Use for phone numbers. Mobile browsers show numeric keyboard with phone symbols. No automatic validation - use `pattern` if needed.' AS description_md; + +SELECT 'website' AS name, 'url' AS type, 'https://example.com' AS placeholder, + '**URL** - URL input with validation. Use for website addresses, links. Browser validates URL format. Mobile may show .com key on keyboard.' AS description_md; + +SELECT 'header' AS type, 'Selection Types' AS label; + +SELECT 'country' AS name, 'select' AS type, + '[{"label": "United States", "value": "US"}, {"label": "Canada", "value": "CA"}, {"label": "United Kingdom", "value": "GB"}]' AS options, + '**Select** (SQLPage custom) - Dropdown menu. Use for single choice from many options. Add `multiple` for multi-select. Use `searchable` for long lists. Set `dropdown` for enhanced UI.' AS description_md; + +SELECT 'gender' AS name, 'radio' AS type, 'Male' AS value, 'Male' AS label, + '**Radio** - Radio button for mutually exclusive choices. Create multiple rows with same `name` for a radio group. One option can be selected. Use for 2-5 options.' AS description_md; + +SELECT 'gender' AS name, 'radio' AS type, 'Female' AS value, 'Female' AS label; + +SELECT 'gender' AS name, 'radio' AS type, 'Other' AS value, 'Other' AS label; + +SELECT 'interests' AS name, 'checkbox' AS type, 'Technology' AS value, 'Technology' AS label, + '**Checkbox** - Checkbox for multiple selections. Each checkbox is independent. Use for yes/no questions or multiple selections from a list.' AS description_md; + +SELECT 'terms' AS name, 'checkbox' AS type, TRUE AS required, 'I accept the terms and conditions' AS label, + '**Checkbox (required)** - Use `required` to make acceptance mandatory. Common for terms of service, privacy policies, consent forms.' AS description_md; + +SELECT 'notifications' AS name, 'switch' AS type, 'Enable email notifications' AS label, TRUE AS checked, + '**Switch** (SQLPage custom) - Toggle switch, styled checkbox alternative. Use for on/off settings, feature toggles, preferences. More intuitive than checkboxes for boolean settings.' AS description_md; + +SELECT 'header' AS type, 'File and Media Types' AS label; + +SELECT 'profile_picture' AS name, 'file' AS type, 'image/*' AS accept, + '**File** - File upload control. Use `accept` to limit file types (image/\*, .pdf, .doc). Use `multiple` to allow multiple files. Automatically sets form enctype to multipart/form-data.' AS description_md; + +SELECT 'documents[]' AS name, 'Documents' as label, 'file' AS type, '.pdf,.doc,.docx' AS accept, TRUE AS multiple, + '**File (multiple)** - Allow multiple file uploads with `multiple` attribute. Specify exact extensions or MIME types in `accept`.' AS description_md; + +SELECT 'favorite_color' AS name, 'color' AS type, '#3b82f6' AS value, + '**Color** - Color picker. Use for theme customization, design settings, highlighting preferences. Returns hex color code (#RRGGBB).' AS description_md; + +SELECT 'user_id' AS name, 'hidden' AS type, '12345' AS value, + '**Hidden** - Hidden input, not visible to users. Use for IDs, tokens, state information that needs to be submitted but not displayed or edited.' AS description_md; diff --git a/examples/official-site/performance.sql b/examples/official-site/performance.sql index 771d5c0b..332f6e3b 100644 --- a/examples/official-site/performance.sql +++ b/examples/official-site/performance.sql @@ -22,13 +22,13 @@ as opposed to writing imperative code in a backend programming language like Jav This declarative approach allows SQLPage to offer **optimizations** out of the box that are difficult or time-consuming to achieve in traditional web development stacks. -## Server-side rendering +## Progressive server-side rendering SQLPage applications are [server-side rendered](https://web.dev/articles/rendering-on-the-web), -which means that the SQL queries are executed on the server, and the results are sent to the user''s browser -as HTML, which allows it to start rendering the page as soon as the first byte is received. +which means that the SQL queries are executed on the server, and the results are sent to the user''s browser as HTML. In contrast, many other web frameworks render the page on the client side, which means that the browser has to download some HTML, then download some JavaScript, then execute the JavaScript, then make more requests, +wait for the database to produce a full result set, then process the responses before it can start rendering the actual data the user is interested in. This can lead to loading times that are several times longer than a SQLPage application. @@ -36,6 +36,7 @@ This can lead to loading times that are several times longer than a SQLPage appl SQLPage applications will often feel faster than even equivalent applications written even in alternative server-side rendering frameworks, because SQLPage streams the results of the SQL queries to the browser as soon as they are available. +The user sees the start of the page even before the database has finished producing the last query results. Most server-side rendering frameworks will first wait for all the SQL queries to finish, then render the page in memory on the server, and only then send the HTML webpage to the browser. If a page contains a long list of items, the user @@ -51,7 +52,7 @@ an execution plan every time an user requests a page. When an user loads a page, all SQLPage has to do is tell the database: "Hey, do you remember that query we talked about earlier? Can you give me the results for these specific parameters?". This is much faster than sending the whole SQL query -string to the database every time. +string to the database every time, especially for large complex queries that require heavy planning on the database side. ## Compiled templates @@ -87,16 +88,15 @@ interaction. ## Key Takeaways -SQLPage offers a radically different approach to web development, -resolving the classical tension between performance and ease of use. - -By leveraging a declarative approach, server-side rendering, and advanced optimization techniques, SQLPage enables: - -* **Faster page loads**: Long loading times make your website feel sluggish and unresponsive, causing users to leave. -* **Easier development**: Focus on writing SQL queries; all the heavy lifting is done for you. -* **Cost effective**: SQLPage''s low CPU and memory usage means you can host your website extremely cheaply, even if it gets significant traffic. +Performance is a key feature of SQLPage. +Its architecture allows you to build fast websites without having to implement advanced optimizations yourself. ## Ready to get started? [Build your fast, secure, and beautiful website](/your-first-sql-website) with SQLPage today! + +## Already a SQLPage developer ? + +Have a look at our [performance guide](/blog?post=Performance+Guide) to learn the best practices to leverage +all the features that will make your site faster. ' as contents_md; diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index dabd6d76..cbef4971 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -220,32 +220,24 @@ INSERT INTO component(name, icon, description) VALUES ('form', 'cursor-text', ' # Building forms in SQL -So, you have an SQL database, and would like to let users input data into it? -The `form` component is what you are looking for. - -## Collecting data from users to your database - The form component will display a series of input fields of various types, that can be filled in by the user. When the user submits the form, the data is posted to an SQL file specified in the `action` property. ## Handle Data with SQL -User-entered data is posted to an SQL file, that will handle the data, -and will be able to insert it into the database, search for it, format it, etc. +The receiving SQL page will be able to handle the data, +and insert it into the database, use it to perform a search, format it, update existing data, etc. -For example, a value in a field named "x" -can be referenced as `:x` in the SQL query of the target page. +A value in a field named "x" will be available as `:x` in the SQL query of the target page. ## Examples -- **Data Entry Automation**: Forms for tasks like inventory management. -- **Custom Report Builder**: Generate reports based on user-specified criteria. -- **Database Management**: Update records or query data. -- **Admin Panel**: Manage user roles and permissions. -- **Data Analytics with SQL**: Collect data for analytics. -- **SQL Query Parametrization**: Build and execute complex SQL queries that depend on user input. -- **SQL CRUD Operations**: Perform Create, Read, Update, and Delete operations. -- **Web SQL**: Integrate forms into web applications. + - [A multi-step form](https://github.com/sqlpage/SQLPage/tree/main/examples/forms-with-multiple-steps), guiding the user through a process without overwhelming them with a large form. + - [File upload form](https://github.com/sqlpage/SQLPage/tree/main/examples/image%20gallery%20with%20user%20uploads), letting users upload images to a gallery. + - [Rich text editor](https://github.com/sqlpage/SQLPage/tree/main/examples/rich-text-editor), letting users write text with bold, italics, links, images, etc. + - [Master-detail form](https://github.com/sqlpage/SQLPage/tree/main/examples/master-detail-forms), to edit a list of structured items. + - [Form with a variable number of fields](https://github.com/sqlpage/SQLPage/tree/main/examples/forms%20with%20a%20variable%20number%20of%20fields), when the fields are not known in advance. + - [Demo of all input types](/examples/form), showing all the input types supported by SQLPage. '); INSERT INTO parameter(component, name, description_md, type, top_level, optional) SELECT 'form', * FROM (VALUES -- top level @@ -276,7 +268,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('id', 'A unique identifier for the form, which can then be used to validate the form from a button outside of the form.', 'TEXT', TRUE, TRUE), ('auto_submit', 'Automatically submit the form when the user changes any of its fields, and remove the validation button.', 'BOOLEAN', TRUE, TRUE), -- item level - ('type', 'The type of input to use: text for a simple text field, textarea for a multi-line text input control, number to accept only numbers, checkbox, switch, or radio for a button that is part of a group specified in the ''name'' parameter, header for a form header, hidden for a value that will be submitted but not shown to the user. text by default.', 'TEXT', FALSE, TRUE), + ('type', 'Declares input control behavior and expected format. All HTML input types are supported (text, number, date, file, checkbox, radio, hidden, ...). SQLPage adds some custom types: textarea, switch, header. text by default. See https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#input_types', 'TEXT', FALSE, TRUE), ('name', 'The name of the input field, that you can use in the target page to get the value the user entered for the field.', 'TEXT', FALSE, FALSE), ('label', 'A friendly name for the text field to show to the user.', 'TEXT', FALSE, TRUE), ('placeholder', 'A placeholder text that will be shown in the field when is is empty.', 'TEXT', FALSE, TRUE), @@ -1022,7 +1014,9 @@ This is helpful if you want a more complex logic, for instance to disable a butt > You can leave blank spaces by including an object with only the `name` property. The table has a column of buttons, each button defined by the `_sqlpage_actions` column at the table level, and by the `_sqlpage_actions` property at the row level. + ### `custom_actions` & `_sqlpage_actions` JSON properties. + Each button is defined by the following properties: * `name`: sets the column header and the tooltip if no tooltip is provided, * `tooltip`: text to display when hovering over the button, @@ -1037,14 +1031,12 @@ Each button is defined by the following properties: "component": "table", "edit_url": "/examples/show_variables.sql?action=edit&update_id={id}", "delete_url": "/examples/show_variables.sql?action=delete&delete_id={id}", - "custom_actions": [ - { + "custom_actions": { "name": "history", "tooltip": "View Standard History", "link": "/examples/show_variables.sql?action=history&standard_id={id}", "icon": "history" - } - ] + } }, { "name": "CalStd", diff --git a/examples/official-site/sqlpage/migrations/69_blog_performance_guide.sql b/examples/official-site/sqlpage/migrations/69_blog_performance_guide.sql new file mode 100644 index 00000000..6dcdf111 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/69_blog_performance_guide.sql @@ -0,0 +1,284 @@ + +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES + ( + 'Performance Guide', + 'Concrete advice on how to make your SQLPage webapp fast', + 'bolt', + '2025-10-31', + ' +# Performance Guide + +SQLPage is [optimized](/performance) +to allow you to create web pages that feel snappy. +This guide contains advice on how to ensure your users never wait +behind a blank screen waiting for your pages to load. + +A lot of the advice here is not specific to SQLPage, but applies +to making SQL queries fast in general. +If you are already comfortable with SQL performance optimization, feel free to jump right to +the second part of the quide: *SQLPage-specific advice*. + +## Make your queries fast + +The best way to ensure your SQLPage webapp is fast is to ensure your +database is well managed and your SQL queries are well written. +We''ll go over the most common database performance pitfalls so that you know how to avoid them. + +### Choose the right database schema + +#### Normalize (but not too much) + +Your database schema should be [normalized](https://en.wikipedia.org/wiki/Database_normalization): +one piece of information should be stored in only one place in the database. +This is a good practice that will not only make your queries faster, +but also make it impossible to store incoherent data. +You should use meaningful natural [primary keys](https://en.wikipedia.org/wiki/Primary_key) for your tables +and resort to surrogate keys (such as auto-incremented integer ids) only when the data is not naturally keyed. +Relationships between tables should be explicitly represented by [foreign keys](https://en.wikipedia.org/wiki/Foreign_key). + +```sql +-- Products table, naturally keyed by catalog_number +CREATE TABLE product ( + catalog_number VARCHAR(20) PRIMARY KEY, + name TEXT NOT NULL, + price DECIMAL(10,2) NOT NULL +); + +-- Sales table: natural key = (sale_date, store_id, transaction_number) +-- composite primary key used since no single natural attribute alone uniquely identifies a sale +CREATE TABLE sale ( + sale_date DATE NOT NULL, + store_id VARCHAR(10) NOT NULL, + transaction_number INT NOT NULL, + product_catalog_number VARCHAR(20) NOT NULL, + quantity INT NOT NULL CHECK (quantity > 0), + PRIMARY KEY (sale_date, store_id, transaction_number), + FOREIGN KEY (product_catalog_number) REFERENCES product(catalog_number), + FOREIGN KEY (store_id) REFERENCES store(store_id) +); +``` + +Always use foreign keys instead of trying to store redundant data such as store names in the sales table. + +This way, when you need to display the list of stores in your application, you don''t have to +run a slow `select distinct store from sales`, that would have to go through your millions of sales +(*even if you have an index on the store column*), you just query the tiny `stores` table directly. + +You also need to use the right [data types](https://en.wikipedia.org/wiki/Data_type) for your columns, +otherwise you will waste a lot of space and time converting data at query time. +See [postgreSQL data types](https://www.postgresql.org/docs/current/datatype.html), +[MySQL data types](https://dev.mysql.com/doc/refman/8.0/en/data-types.html), +[Microsoft SQL Server data types](https://learn.microsoft.com/en-us/sql/t-sql/data-types/data-types-transact-sql?view=sql-server-ver16), +[SQLite data types](https://www.sqlite.org/datatype3.html). + +[Denormalization](https://en.wikipedia.org/wiki/Denormalization) can be introduced +only after you have already normalized your data, and is often not required at all. + +### Use views + +Querying normalized views can be cumbersome. +`select store_name, sum(paid_eur) from sale group by store_name` +is more readable than + +```sql +select store.name, sum(sale.paid_eur) +from sales + inner join stores on sale.store_id = store.store_id +group by store_name +``` + +To work around that, you can create views that contain +useful table joins so that you do not have to duplicate them in all your queries: + +```sql +create view enriched_sales as +select sales.sales_eur, sales.client_id, store.store_name +from sales +inner join store +``` + +#### Materialized views + +Some analytical queries just have to compute aggregated statistics over large quantities of data. +For instance, you might want to compute the total sales per store, or the total sales per product. +These queries are slow to compute when there are many rows, and you might not want to run them on every request. +You can use [materialized views](https://en.wikipedia.org/wiki/Materialized_view) to cache the results of these queries. +Materialized views are views that are stored as regular tables in the database. + +Depending on the database, you might have to refresh the materialized view manually. +You can either refresh the view manually from inside your sql pages when you detect they are outdated, +or write an external script to refresh the view periodically. + +```sql +create materialized view total_sales_per_store as +select store_name, sum(sales_eur) as total_sales +from sales +group by store_name; +``` + +### Use database indices + +When a query on a large table uses non-primary column in a `WHERE`, `GROUP BY`, `ORDER BY`, or `JOIN`, +you should create an [index](https://en.wikipedia.org/wiki/Database_index) on that column. +When multiple columns are used in the query, you should create a composite index on those columns. +When creating a composite index, the order of the columns is important. +The most frequently used columns should be first. + +```sql +create index idx_sales_store_date on sale (store_id, sale_date); -- useful for queries that filter by "store" or by "store and date" +create index idx_sales_product_date on sale (product_id, sale_date); +create index idx_sales_store_product_date on sale (store_id, product_id, sale_date); +``` + +Indexes are updated automatically when the table is modified. +They slow down the insertion and deletion of rows in the table, +but speed up the retrieval of rows in queries that use the indexed columns. + +### Query performance debugging + +When a query is slow, you can use the `EXPLAIN` keyword to see how the database will execute the query. +Just add `EXPLAIN` before the query you want to analyze. + +On PostgreSQL, you can use a tool like [explain.dalibo.com](https://explain.dalibo.com/) to visualize the query plan. + +What to look for: + - Are indexes used? You should see references to the indices you created. + - Are full table scans used? Large tables should never be scanned. + - Are expensive operations used? Such as sorting, hashing, bitmap index scans, etc. + - Are operations happening in the order you expected them to? Filtering large tables should come first. + +### Vacuum your database regularly + +On PostgreSQL, you can use the [`VACUUM`](https://www.postgresql.org/docs/current/sql-vacuum.html) command to garbage-collect and analyze a database. + +On MySQL, you can use the [`OPTIMIZE TABLE`](https://dev.mysql.com/doc/refman/8.0/en/optimize-table.html) command to reorganize it on disk and make it faster. +On Microsoft SQL Server, you can use the [`DBCC DBREINDEX`](https://learn.microsoft.com/en-us/sql/t-sql/database-console-commands/dbcc-dbreindex-transact-sql?view=sql-server-ver17) command to rebuild the indexes. +On SQLite, you can use the [`VACUUM`](https://www.sqlite.org/lang_vacuum.html) command to garbage-collect and analyze the database. + +### Use the right database engine + +If the amount of data you are working with is very large, does not change frequently, and you need to run complex queries on it, +you could use a specialized analytical database such as [ClickHouse](https://clickhouse.com/) or [DuckDB](https://duckdb.org/). +Such databases can be used with SQLPage by using their [ODBC](https://en.wikipedia.org/wiki/Open_Database_Connectivity) drivers. + +### Database-specific performance recommendations + + - [PostgreSQL "Performance Tips"](https://www.postgresql.org/docs/current/performance-tips.html) + - [MySQL optimization guide](https://dev.mysql.com/doc/refman/8.0/en/optimization.html) + - [Microsoft SQL Server "Monitor and Tune for Performance"](https://learn.microsoft.com/en-us/sql/relational-databases/performance/monitor-and-tune-for-performance?view=sql-server-ver17) + - [SQLite query optimizer overview](https://www.sqlite.org/optoverview.html) + +## SQLPage-specific advice + +The best way to make your SQLPage webapp fast is to make your queries fast. +Sometimes, you just don''t have control over the database, and have to run slow queries. +This section will help you minimize the impact to your users. + +### Order matters + +SQLPage executes the queries in your `.sql` files in order. +It does not start executing a query before the previous one has returned all its results. +So, if you have to execute a slow query, put it as far down in the page as possible. + +#### No heavy computation before the shell + +Every user-facing page in a SQLPage site has a [shell](/components?component=shell). + +The first queries in any sql file (all the ones that come before the [shell](/components?component=shell)) +are executed before any data has been sent to the user''s browser. +During that time, the user will see a blank screen. +So, ensure your shell comes as early as possible, and does not require any heavy computation. +If you can make your shell entirely static (independent of the database), do so, +and it will be rendered before SQLPage even finishes acquiring a database connection. + +#### Set variables just above their first usage + +For the reasons explained above, you should avoid defining all variables at the top of your sql file. +Instead, define them just above their first usage. + +### Avoid recomputing the same data multiple times + +Often, a single page will require the same pieces of data in multiple places. +In this case, avoid recomputing it on every use inside the page. + +#### Reusing a single database record + +When that data is small, store it in a sqlpage variable as JSON and then +extract the data you need using [json operations](/blog.sql?post=JSON%20in%20SQL%3A%20A%20Comprehensive%20Guide). + +```sql +set product = ( + select json_object(''name'', name, ''price'', price) -- in postgres, you can simply use row_to_json(product) + from products where id = $product_id +); + +select ''alert'' as component, ''Product'' as title, $product->>''name'' as description; +``` + +#### Reusing a large query result set + +You may have a page that lets the user filter a large dataset by many different criteria, +and then displays multiple charts and tables based on the filtered data. + +In this case, store the filtered data in a temporary table and then reuse it in multiple places. + +```sql +drop table if exists filtered_products; +create temporary table filtered_products as +select * from products where + ($category is null or category = $category) and + ($manufacturer is null or manufacturer = $manufacturer); + +select ''alert'' as component, count(*) || '' products'' as title +from filtered_products; + +select ''list'' as component; +select name as title from filtered_products; +``` + +### Reduce the number of queries + +Each query you execute has an overhead of at least the time it takes to send a packet back and forth +between SQLPage and the database. +When it''s possible, combine multiple queries into a single one, possibly using +[`UNION ALL`](https://en.wikipedia.org/wiki/Set_operations_(SQL)#UNION_operator). + +```sql +select ''big_number'' as component; + +with stats as ( + select count(*) as total, avg(price) as average_price from filtered_products +) +select ''count'' as title, stats.total as value from stats +union all +select ''average price'' as title, stats.average_price as value from stats; +``` + +### Lazy loading + +Use the [card](/component?component=card) and [modal](/component?component=modal) components +with the `embed` attribute to load data lazily. +Lazy loaded content is not sent to the user''s browser when the page initially loads, +so it does not block the initial rendering of the page and provides a better experience for +data that might be slow to load. + +### Database connections + +SQLPage uses connection pooling: it keeps multiple database connections opened, +and reuses them for consecutive requests. When it does not receive requests for a long time, +it closes idle connection. When it receives many requests, it opens new connection, +but never more than the value specified by `max_database_pool_connections` in its +[configuration](https://github.com/sqlpage/SQLPage/blob/main/configuration.md). +You can increase the value of that parameter if your website has many concurrent users and your +database is configured to allow opening many simultaneous connections. + +### SQLPage performance debugging + +When `environment` is set to `development` in its [configuration](https://github.com/sqlpage/SQLPage/blob/main/configuration.md), +SQLPage will include precise measurement of the time it spends in each of the steps it has to go through before starting to send data +back to the user''s browser. You can visualize that performance data in your browser''s network inspector. + +You can set the `RUST_LOG` environment variable to `sqlpage=debug` to make SQLPage +print detailed messages associated with precise timing for everything it does. +'); diff --git a/examples/official-site/your-first-sql-website/nginx.md b/examples/official-site/your-first-sql-website/nginx.md index b910114a..9e156135 100644 --- a/examples/official-site/your-first-sql-website/nginx.md +++ b/examples/official-site/your-first-sql-website/nginx.md @@ -65,6 +65,23 @@ sudo systemctl reload nginx Your SQLPage instance is now hosted behind a reverse proxy using NGINX. You can access it by visiting `http://example.com`. + +### Streaming-friendly proxy settings + +SQLPage streams HTML by default so the browser can render results while the database is still sending rows. +If you have slow SQL queries (you shouldn't), you can add the following directive to your location block: + +```nginx +proxy_buffering off; +``` + +That will allow users to start seeing the top of your pages faster, +but will increase the load on your SQLPage server, and reduce the amount of users you can serve concurrently. + +Refer to the official documentation for [proxy buffering](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering), [gzip](https://nginx.org/en/docs/http/ngx_http_gzip_module.html), and [chunked transfer](https://nginx.org/en/docs/http/ngx_http_core_module.html#chunked_transfer_encoding) when tuning these values. + +When SQLPage sits behind a reverse proxy, set `compress_responses` to `false` [in `sqlpage.json`](https://github.com/sqlpage/SQLPage/blob/main/configuration.md) so that NGINX compresses once at the edge. + ### URL Rewriting URL rewriting is a powerful feature that allows you to manipulate URLs to make them more readable, search-engine-friendly, and easy to maintain. diff --git a/sqlpage/templates/table.handlebars b/sqlpage/templates/table.handlebars index bf1cd3e4..9dc30f89 100644 --- a/sqlpage/templates/table.handlebars +++ b/sqlpage/templates/table.handlebars @@ -55,16 +55,13 @@ {{/each}} {{#if ../edit_url}}Edit{{/if}} {{#if ../delete_url}}Delete{{/if}} - {{#if ../custom_actions}} - {{#each ../custom_actions}} - {{this.name}} - {{/each}} - {{/if}} - {{#if _sqlpage_actions}} - {{#each _sqlpage_actions}} - {{this.name}} - {{/each}} - {{/if}} + {{#each (to_array ../custom_actions)}} + {{this.name}} + {{/each}} + + {{#each (to_array _sqlpage_actions)}} + {{this.name}} + {{/each}} {{#delay}}{{/delay}} @@ -105,24 +102,21 @@ {{/if}} - {{#if ../custom_actions}} - {{#each ../custom_actions}} - - {{!Title property sets the tooltip text}} - {{~icon_img this.icon~}} - - - {{/each}} - {{/if}} - {{#if _sqlpage_actions}} - {{#each _sqlpage_actions}} - - - {{~icon_img this.icon~}} - - - {{/each}} - {{/if}} + {{#each (to_array ../custom_actions)}} + + {{!Title property sets the tooltip text}} + {{~icon_img this.icon~}} + + + {{/each}} + + {{#each (to_array _sqlpage_actions)}} + + + {{~icon_img this.icon~}} + + + {{/each}} {{!~ After this has been rendered, if this was a footer, we need to reopen a new diff --git a/src/template_helpers.rs b/src/template_helpers.rs index 690e2d61..761fbc9c 100644 --- a/src/template_helpers.rs +++ b/src/template_helpers.rs @@ -1,4 +1,4 @@ -use std::borrow::Cow; +use std::{borrow::Cow, collections::HashMap, sync::LazyLock}; use crate::{app_config::AppConfig, utils::static_filename}; use anyhow::Context as _; @@ -67,7 +67,7 @@ pub fn register_all_helpers(h: &mut Handlebars<'_>, config: &AppConfig) { register_helper(h, "app_config", AppConfigHelper(config.clone())); // icon helper: generate an image with the specified icon - h.register_helper("icon_img", Box::new(IconImgHelper(site_prefix))); + h.register_helper("icon_img", Box::new(IconImgHelper)); register_helper(h, "markdown", MarkdownHelper::new(config)); register_helper(h, "buildinfo", buildinfo_helper as EH); register_helper(h, "typeof", typeof_helper as H); @@ -209,8 +209,11 @@ impl CanHelp for AppConfigHelper { } } -/// Generate an image with the specified icon. Struct Param is the site prefix -struct IconImgHelper(String); +pub static ICON_MAP: LazyLock> = + LazyLock::new(|| include!(concat!(env!("OUT_DIR"), "/icons.rs")).into()); + +/// Generate an image with the specified icon. +struct IconImgHelper; impl HelperDef for IconImgHelper { fn call<'reg: 'rc, 'rc>( &self, @@ -221,20 +224,17 @@ impl HelperDef for IconImgHelper { writer: &mut dyn handlebars::Output, ) -> handlebars::HelperResult { let null = handlebars::JsonValue::Null; - let params = [0, 1].map(|i| helper.params().get(i).map_or(&null, PathAndJson::value)); - let name = match params[0] { - JsonValue::String(s) => s, - other => { - log::debug!("icon_img: {other:?} is not an icon name, not rendering anything"); - return Ok(()); - } + let [name, size] = [0, 1].map(|i| helper.params().get(i).map_or(&null, PathAndJson::value)); + let size = size.as_u64().unwrap_or(24); + let content = name.as_str().and_then(|name| ICON_MAP.get(name)); + let Some(&inner_content) = content else { + log::warn!("icon_img: icon {name} not found"); + return Ok(()); }; - let size = params[1].as_u64().unwrap_or(24); + write!( writer, - "", - self.0, - static_filename!("tabler-icons.svg") + r#"{inner_content}"# )?; Ok(()) } diff --git a/src/webserver/http.rs b/src/webserver/http.rs index dbf3e208..7388695a 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -437,7 +437,6 @@ pub fn create_app( .service(static_content::apexcharts_js()) .service(static_content::tomselect_js()) .service(static_content::css()) - .service(static_content::icons()) .service(static_content::favicon()) .default_service(fn_service(main_handler)), ) @@ -548,6 +547,8 @@ fn log_welcome_message(config: &AppConfig) { "http://localhost:{port}\n\ (also accessible from other devices using your IP address)" ) + } else if ip.is_ipv6() { + format!("http://[{ip}]:{port}") } else { format!("http://{ip}:{port}") } diff --git a/src/webserver/static_content.rs b/src/webserver/static_content.rs index 6f66cb2c..fcb003d2 100644 --- a/src/webserver/static_content.rs +++ b/src/webserver/static_content.rs @@ -30,26 +30,27 @@ macro_rules! static_file_endpoint { }}; } +#[must_use] pub fn js() -> Resource { static_file_endpoint!("sqlpage", "js", "application/javascript") } +#[must_use] pub fn apexcharts_js() -> Resource { static_file_endpoint!("apexcharts", "js", "application/javascript") } +#[must_use] pub fn tomselect_js() -> Resource { static_file_endpoint!("tomselect", "js", "application/javascript") } +#[must_use] pub fn css() -> Resource { static_file_endpoint!("sqlpage", "css", "text/css") } -pub fn icons() -> Resource { - static_file_endpoint!("tabler-icons", "svg", "image/svg+xml") -} - +#[must_use] pub fn favicon() -> Resource { static_file_endpoint!("favicon", "svg", "image/svg+xml") }