chore: import upstream snapshot with attribution
CI / compile_and_lint (push) Failing after 0s
CI / docker_build (linux/amd64, -linux-amd64-duckdb, duckdb) (push) Failing after 0s
CI / docker_build (linux/arm64, -linux-arm64, minimal) (push) Failing after 2s
CI / docker_build (linux/arm64, -linux-arm64-duckdb, duckdb) (push) Failing after 1s
CI / docker_build (linux/amd64, minimal) (push) Failing after 1s
CI / test (, sqlite, sqlite::memory:) (push) Has been skipped
CI / test (mssql, mssql, mssql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (mysql, mysql, mysql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (oracle, oracle, Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!) (push) Has been skipped
CI / test (postgres, odbc, Driver=PostgreSQL Unicode;Server=127.0.0.1;Port=5432;Database=sqlpage;UID=root;PWD=Password123!, true) (push) Has been skipped
CI / test (postgres, postgres, postgres://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / playwright (push) Has been skipped
CI / docker_build (linux/arm/v7, -linux-arm-v7, minimal) (push) Failing after 0s
CI / hurl_examples (push) Failing after 8s
deploy website / deploy_official_site (push) Failing after 1s
CI / hurl (${{ matrix.example }}) (push) Has been skipped
CI / docker_push (duckdb) (push) Has been cancelled
CI / docker_push (minimal) (push) Has been cancelled
CI / windows_test (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:57 +08:00
commit d718c5a372
986 changed files with 74597 additions and 0 deletions
@@ -0,0 +1,15 @@
# Todo app with SQLPage
This is a simple todo app implemented with SQLPage. It uses a PostgreSQL database to store the todo items.
![Screenshot](screenshot.png)
It is meant as an illustrative example of how to use SQLPage to create a simple CRUD application. See [the SQLite version](../todo%20application/README.md) for a more detailed explanation of the structure of the application.
## Differences from the SQLite version
- URL parameters that contain numeric identifiers are cast to integers using the [`::int`](https://www.postgresql.org/docs/16/sql-expressions.html#SQL-SYNTAX-TYPE-CASTS) operator
- the `printf` function is replaced with the [`format`](https://www.postgresql.org/docs/current/functions-string.html#FUNCTIONS-STRING-FORMAT) function
- primary keys are generated using the [`serial`](https://www.postgresql.org/docs/current/datatype-numeric.html#DATATYPE-SERIAL) type
- dates and times are formatted using the [`to_char`](https://www.postgresql.org/docs/current/functions-formatting.html#FUNCTIONS-FORMATTING-DATETIME-TABLE) function
- the `INSERT OR REPLACE` statement is replaced with the [`ON CONFLICT`](https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT) clause
@@ -0,0 +1,98 @@
-- Include 'shell.sql' to generate the page header and footer
select 'dynamic' as component, sqlpage.run_sql('shell.sql') as properties;
-- Define a Common Table Expression (CTE) named 'updated'
-- CTEs are temporary named result sets, useful for complex queries
-- Here, it's used to perform the update and capture the results in one step
with updated as (
-- Update the 'todos' table and return the modified rows
-- This approach allows us to both update the data and use it for reporting
update todos set
-- Modify the title based on user input for labels
-- The CASE statements handle different scenarios for label management
title = case
-- If :remove_label is null, we keep the existing title as is
when :remove_label is null then
title
else
-- Remove any existing labels (text within parentheses)
-- This uses a regular expression to strip out (label) from the end
regexp_replace(title, '\s*\(.*\)', '')
end
-- Concatenate the result with a new label if provided
||
case
-- If no new label is provided, we don't add anything
when :new_label is null or :new_label = '' then
''
else
-- Add the new label in parentheses at the end
' (' || :new_label || ')'
end
-- Determine which todos to update based on user selection
where
-- Update specific todos if their IDs are in the :todos parameter
-- :todos is a JSON array of todo string IDs, e.g. ["1", "2", "3"]
-- that optionally includes "all" to update all todos
id in (
-- Parse the JSON array of todo IDs and convert each to integer
-- This allows for multiple todo selection in the UI
select e::int from jsonb_array_elements_text(:todos::jsonb) e
where e != 'all'
)
-- If 'all' is the only selected, update every todo (by making the where condition always true)
or :todos = '["all"]'
-- Return all updated rows for counting and potential further use
returning *
)
-- Generate an alert component to inform the user about the update result
-- This provides immediate feedback on the operation's outcome
select 'alert' as component,
'Batch update' as title,
-- Create a dynamic message with the count of updated todos
format('%s todos updated', (select count(*) from updated)) as description
-- Only display the alert if at least one todo was updated
-- This prevents showing unnecessary alerts for no-op updates
where exists (select * from updated);
-- Create a form component for the batch update interface
-- This sets up the structure for the user input form
select 'form' as component,
'Batch update' as title,
'Update all todos' as contents;
-- Create a select input for choosing which todos to update
-- This allows users to pick multiple todos or all todos for updating
select
'select' as type,
'Update these todos' as label,
'todos[]' as name,
true as multiple,
true as dropdown,
true as required,
-- Combine a static "all" option with dynamic options for each todo
-- This uses JSON functions to build a complex data structure for the UI
-- The JSON structure is used to set the label, value, and selection state for each option
-- The generated JSON looks like this:
-- [{"label":"Update all todos","value":"all","selected":true},{"label":"Todo 1","value":"1","selected":false}]
jsonb_build_array(jsonb_build_object( -- json_build_object takes a list of key-value pairs and returns a JSON object
'label', 'Update all todos', -- The label of the option
'value', 'all', -- The value of the option
'selected', :todos = '["all"]' or :todos is null -- Pre-select 'all' only if it was previously chosen or if :todos is not set (the page was just loaded)
)) ||
-- Generate an option for each todo in the database
jsonb_agg(jsonb_build_object(
'label', title,
'value', id,
-- Pre-select this todo if it was in the previous selection
'selected', (id in (select e::int from jsonb_array_elements_text(:todos::jsonb) e where e != 'all'))
)) as options
from todos;
-- Create a text input for entering a new label
-- This allows users to specify the label to be added to the selected todos
select 'new_label' as name, 'New label' as label;
-- Create a checkbox for optionally removing existing labels
-- This gives users the choice to strip old labels before adding a new one
select 'checkbox' as type, 'Remove previous labels' as label, 'remove_label' as name;
@@ -0,0 +1,30 @@
-- We find the todo item with the id given in the URL (/delete.sql?todo_id=1)
-- and we check that the URL also contains a 'confirm' parameter set to 'yes' (/delete.sql?todo_id=1&confirm=yes)
-- If both conditions are met, we delete the todo item from the database
-- and redirect the user to the home page.
delete from todos
where id = $todo_id::int and $confirm = 'yes'
returning -- returning will return one row if an item was deleted, and zero rows if no item was deleted
'redirect' as component, -- if one item was deleted, we redirect the user to the home page, and skip the rest of the page
'/' as link;
-- If we are here, it means that the delete statement above did not delete anything
-- because the confirm parameter was not set to 'yes'.
-- We display the same header as in other pages, by including the shell.sql file.
select 'dynamic' as component, sqlpage.run_sql('shell.sql') as properties;
-- When the page is initially loaded, it will contain a todo_id parameter
-- but no confirm parameter, so the delete statement above will not delete anything
-- and the 'redirect' component will not be returned.
-- In this case, we display a confirmation message to the user.
select
'alert' as component, -- an alert is a message that is displayed to the user
'red' as color,
'Confirm deletion' as title,
'Are you sure you want to delete the following todo item ?
> ' || title as description_md, -- we include the text of the todo item in the markdown confirmation message
'?todo_id=' || $todo_id || '&confirm=yes' as link, -- When the user clicks on the 'Delete' button, the page will be reloaded with the confirm parameter set to 'yes', so that the delete statement above will delete the todo item
'Delete' as link_text
from todos where id = $todo_id::int; -- finds the todo item with the id given in the URL
@@ -0,0 +1,18 @@
services:
web:
image: lovasoa/sqlpage:main # main is cutting edge, use sqlpage/SQLPage:latest for the latest stable version
ports:
- "8080:8080"
volumes:
- .:/var/www
- ./sqlpage:/etc/sqlpage
depends_on:
- db
environment:
DATABASE_URL: postgres://root:secret@db/sqlpage
db: # The DB environment variable can be set to "mariadb" or "postgres" to test the code with different databases
image: postgres
environment:
POSTGRES_USER: root
POSTGRES_DB: sqlpage
POSTGRES_PASSWORD: secret
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,20 @@
select 'dynamic' as component, sqlpage.run_sql('shell.sql') as properties;
select 'list' as component,
'Todo' as title,
'No todo yet...' as empty_title;
select
title,
'todo_form.sql?todo_id=' || id as edit_link,
'delete.sql?todo_id=' || id as delete_link
from todos;
select
'button' as component,
'center' as justify;
select
'todo_form.sql' as link,
'green' as color,
'Add new todo' as title,
'circle-plus' as icon;
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,7 @@
select
'shell' as component,
format ('Todo list (%s)', count(*)) as title,
'batch' as menu_item,
'timeline' as menu_item
from
todos;
@@ -0,0 +1,6 @@
create table
todos (
id serial primary key,
title text not null,
created_at timestamp default current_timestamp
);
@@ -0,0 +1,41 @@
# SQLPage migrations
SQLPage migrations are SQL scripts that you can use to create or update the database schema.
They are entirely optional: you can use SQLPage without them, and manage the database schema yourself with other tools.
If you are new to SQL migrations, please read our [**introduction to database migrations**](https://sql-page.com/your-first-sql-website/migrations.sql).
## Creating a migration
To create a migration, create a file in the `sqlpage/migrations` directory with the following name:
```
<version>_<name>.sql
```
Where `<version>` is a number that represents the version of the migration, and `<name>` is a name for the migration.
For example, `001_initial.sql` or `002_add_users.sql`.
When you need to update the database schema, always create a **new** migration file with a new version number
that is greater than the previous one.
Use commands like `ALTER TABLE` to update the schema declaratively instead of modifying the existing `CREATE TABLE`
statements.
If you try to edit an existing migration, SQLPage will not run it again, will detect
## Running migrations
Migrations that need to be applied are run automatically when SQLPage starts.
You need to restart SQLPage each time you create a new migration.
## How does it work?
SQLPage keeps track of the migrations that have been applied in a table called `_sqlx_migrations`.
This table is created automatically when SQLPage starts for the first time, if you create migration files.
If you don't create any migration files, SQLPage will never touch the database schema on its own.
When SQLPage starts, it checks the `_sqlx_migrations` table to see which migrations have been applied.
It checks the `sqlpage/migrations` directory to see which migrations are available.
If the checksum of a migration file is different from the checksum of the migration that has been applied,
SQLPage will return an error and refuse to start.
If you end up in this situation, you can remove the `_sqlx_migrations` table: all your old migrations will be reapplied, and SQLPage will start again.
@@ -0,0 +1,20 @@
# SQLPage component templates
SQLPage templates are handlebars[^1] files that are used to render the results of SQL queries.
[^1]: https://handlebarsjs.com/
## Default components
SQLPage comes with a set of default[^2] components that you can use without having to write any code.
These are documented on https://sql-page.com/components.sql
## Custom components
You can [write your own component templates](https://sql-page.com/custom_components.sql)
and place them in the `sqlpage/templates` directory.
To override a default component, create a file with the same name as the default component.
If you want to start from an existing component, you can copy it from the `sqlpage/templates` directory
in the SQLPage source code[^2].
[^2]: A simple component to start from: https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/code.handlebars
@@ -0,0 +1,49 @@
GET http://localhost:8080/
HTTP 200
[Asserts]
body contains "Todo"
body contains "Add new todo"
body not contains "An error occurred"
POST http://localhost:8080/todo_form.sql
[FormParams]
todo: Created by Hurl
HTTP 302
[Asserts]
header "Location" == "/"
GET http://localhost:8080/
HTTP 200
[Captures]
todo_id: xpath "substring-after(string((//a[contains(@href, 'todo_form.sql?todo_id=')])[last()]/@href), 'todo_id=')"
[Asserts]
body contains "Created by Hurl"
body htmlUnescape contains "todo_form.sql?todo_id={{todo_id}}"
body htmlUnescape contains "delete.sql?todo_id={{todo_id}}"
body not contains "An error occurred"
POST http://localhost:8080/todo_form.sql?todo_id={{todo_id}}
[FormParams]
todo: Edited by Hurl
HTTP 302
[Asserts]
header "Location" == "/"
GET http://localhost:8080/delete.sql?todo_id={{todo_id}}
HTTP 200
[Asserts]
body contains "Confirm deletion"
body contains "Edited by Hurl"
body htmlUnescape contains "?todo_id={{todo_id}}&confirm=yes"
body not contains "An error occurred"
GET http://localhost:8080/delete.sql?todo_id={{todo_id}}&confirm=yes
HTTP 302
[Asserts]
header "Location" == "/"
GET http://localhost:8080/
HTTP 200
[Asserts]
body not contains "Edited by Hurl"
body not contains "An error occurred"
@@ -0,0 +1,25 @@
select
'dynamic' as component,
sqlpage.run_sql ('shell.sql') as properties;
select
'timeline' as component;
SELECT
title,
'todo_form.sql?todo_id=' || id AS link,
TO_CHAR (created_at, 'FMMonth DD, YYYY, HH12:MI AM TZ') AS date,
'calendar' AS icon,
'green' AS color,
CONCAT (
EXTRACT(
DAY
FROM
NOW () - created_at
),
' days ago'
) AS description
FROM
todos
ORDER BY
created_at DESC;
@@ -0,0 +1,34 @@
-- When the form is submitted, we insert the todo item into the database
-- or update it if it already exists
-- and redirect the user to the home page.
-- When the form is initially loaded, :todo is null,
-- nothing is inserted, and the 'redirect' component is not returned.
insert into todos(id, title)
select COALESCE($todo_id::int, nextval('todos_id_seq')), :todo -- $todo_id will be null if the page is accessed via the 'Add new todo' button (without a ?todo_id= parameter)
where :todo is not null -- only insert if the form was submitted
on conflict(id) do update set title = excluded.title
returning
'redirect' as component,
'/' as link;
-- The header needs to come before the form, but after the potential redirect
select 'dynamic' as component, sqlpage.run_sql('shell.sql') as properties;
-- The form needs to come AFTER the insert statement
-- because the insert statement will redirect the user to the home page if the form was submitted
select
'form' as component,
'Todo' as title,
(
case when $todo_id is null then
'Add new todo'
else
'Edit todo'
end
) as validate;
select
'Todo item' as label,
'todo' as name,
'What do you have to do ?' as placeholder,
(select title from todos where id = $todo_id::int) as value;