From 164b79f418ca8032bdc874044db2e1836c4dc7a7 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Thu, 18 Jul 2024 22:48:38 +0200 Subject: [PATCH] add an example todo application using postgres --- .../todo application (PostgreSQL)/README.md | 15 +++ .../todo application (PostgreSQL)/delete.sql | 30 ++++++ .../docker-compose.yml | 21 +++++ .../explanation_diagram.svg | 21 +++++ .../todo application (PostgreSQL)/index.sql | 20 ++++ .../screenshot.png | Bin 0 -> 22261 bytes .../todo application (PostgreSQL)/shell.sql | 6 ++ .../sqlpage/migrations/0000_init.sql | 6 ++ .../sqlpage/migrations/README.md | 41 ++++++++ .../sqlpage/sqlpage.json | 3 + .../sqlpage/templates/README.md | 20 ++++ .../timeline.sql | 25 +++++ .../todo_form.sql | 34 +++++++ examples/todo application/README.md | 89 +++++++++--------- 14 files changed, 288 insertions(+), 43 deletions(-) create mode 100644 examples/todo application (PostgreSQL)/README.md create mode 100644 examples/todo application (PostgreSQL)/delete.sql create mode 100644 examples/todo application (PostgreSQL)/docker-compose.yml create mode 100644 examples/todo application (PostgreSQL)/explanation_diagram.svg create mode 100644 examples/todo application (PostgreSQL)/index.sql create mode 100644 examples/todo application (PostgreSQL)/screenshot.png create mode 100644 examples/todo application (PostgreSQL)/shell.sql create mode 100644 examples/todo application (PostgreSQL)/sqlpage/migrations/0000_init.sql create mode 100644 examples/todo application (PostgreSQL)/sqlpage/migrations/README.md create mode 100644 examples/todo application (PostgreSQL)/sqlpage/sqlpage.json create mode 100644 examples/todo application (PostgreSQL)/sqlpage/templates/README.md create mode 100644 examples/todo application (PostgreSQL)/timeline.sql create mode 100644 examples/todo application (PostgreSQL)/todo_form.sql diff --git a/examples/todo application (PostgreSQL)/README.md b/examples/todo application (PostgreSQL)/README.md new file mode 100644 index 00000000..43bbbb9e --- /dev/null +++ b/examples/todo application (PostgreSQL)/README.md @@ -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 diff --git a/examples/todo application (PostgreSQL)/delete.sql b/examples/todo application (PostgreSQL)/delete.sql new file mode 100644 index 00000000..c27eea54 --- /dev/null +++ b/examples/todo application (PostgreSQL)/delete.sql @@ -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 diff --git a/examples/todo application (PostgreSQL)/docker-compose.yml b/examples/todo application (PostgreSQL)/docker-compose.yml new file mode 100644 index 00000000..c29ecdc2 --- /dev/null +++ b/examples/todo application (PostgreSQL)/docker-compose.yml @@ -0,0 +1,21 @@ +services: + web: + image: lovasoa/sqlpage:main # main is cutting edge, use lovasoa/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 + ports: + - "5432:5432" + - "3306:3306" + image: postgres + environment: + POSTGRES_USER: root + POSTGRES_DB: sqlpage + POSTGRES_PASSWORD: secret diff --git a/examples/todo application (PostgreSQL)/explanation_diagram.svg b/examples/todo application (PostgreSQL)/explanation_diagram.svg new file mode 100644 index 00000000..734a0018 --- /dev/null +++ b/examples/todo application (PostgreSQL)/explanation_diagram.svg @@ -0,0 +1,21 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGtT28hcdTAwMTL9nl9BsV/Xk3l0zyNVW7dcdTAwMWNcYoE8SHhDli3KXHUwMDBmYYSN5bXlXHUwMDAw2cp/vz1cdTAwMDYsWZKfmNzkLlBFgVx1MDAxZePWTJ8+p6db/PNiZWU1vu1cdTAwMDSrr1ZWg5tapVx1MDAxNda7levV3/3xr0G3XHUwMDE3Rm06JVx1MDAwN3/3on63NrjyXCKOO71XL18md7BadHV3V9Bcbq6Cdtyj6/6kv1dW/lx1MDAxOfykM2Hd37u99XG9VOWfXCJ+WW7yVv9Eq9vy4NbBRVx1MDAwZsZ0g1pcXGk3WkFy6oaOXHUwMDBiwYFcdTAwMTkjhZJKcKHV8OytP1x1MDAwYqCYtcg1XHUwMDAwSKOdXHUwMDFknr5cdTAwMGXr8YW/xEmm0UhlOFx1MDAxN9Jcbje84lwiXGJcdTAwMWJcdTAwMTfxYFx1MDAxNMW08J/BlVHCKVx1MDAxY15zZ9GrXHUwMDE1PjzSi7tRM1iLWlHXm/2bXGL8d2J0tVJrNrpRv11Prjk/XHUwMDBmas4l15yHrdZefDtcdTAwMTiZZplmdDUz/tG99TJzfNxd9IGNi3bQ86sghkejTqVcdTAwMTbGg4niyVx1MDAxM3jrOlv1wYL9ldjUrVxcXHUwMDA1W37F2v1Wa3g4bNdcdTAwMDO/XHUwMDBlq5WjkU9r1+8/7WG1k6VU90e+J7ZcdTAwMDeBXHUwMDFmXHUwMDE4jZDoILVIicfROmePbkftgfdcdO5AXHUwMDAyXHUwMDE3XFwnVvXWye3iwajnlVYvSKbfm/Ym65Jpt0y5pul0I/VOX26q7a/lZrlr3/Fyc/iYI+5Z6Xaj69Xhme/3vyXz1+/UK3dcdTAwMDZcdCNQaCOtlDp5qFbYbmYnt1x1MDAxNdWayTO8SE1aXHUwMDA2Q5Xdo29cdTAwMWI7R2dcdTAwMDelysnh1/C2f2S/VObAkFRcZlx1MDAxMDhoyYVwXHUwMDFhRkFEZ5hxXHUwMDA2nDQokaPOg1xiNVx1MDAwMy24tFZcdTAwMTk/js2jaOmoqcpzWa3+4qg5eTRqaO65ss5gXHUwMDBlIHRcdTAwMTLEONSgXHUwMDEzkuKhsctcdTAwMDPNg5vFwU08ipI7L9173d6RR7VyaWtnq6ld7XW1VX2TwszvxcPe3Vxcss2KO7xt9k56+OV4/bzbLX/sLlx0i1qD0MIuXHUwMDA3i8VPmcPiyCTdw1A7XHUwMDA23CE6imhcXDiXhSFOgaFcdTAwMDaGyDlXXHUwMDFjhUFjRVx1MDAxZYVy+dzlv351XHUwMDE0XHUwMDFlXHUwMDE2w3Dk8lx1MDAwN7yRu5DaXHUwMDAwU4Q3gePwRlx1MDAwYstcdTAwMTVQiFxcXHUwMDAwbyNm5JxXaVx1MDAwM6llne68iS96XHUwMDFmpOev9uOYzE/mK2rHe+E3b7bkI0c3Kldh63ZkIfwg5VbY8I+/WiOLg1x1MDAwNJZ+XHUwMDEy4pA04fCCq7BeT/NPjVx1MDAwNq2E7aC7NVx1MDAwYpFF3bBcdTAwMTG2K639MYbTs1x1MDAwN5tD2cYkTsBp15XfdoKb6FZHjVx1MDAwME7C+pdcbu7OzpncXHUwMDE5prghbamcNlxuklx1MDAwNVx1MDAxOEyPMcCUJjIl0aitMcl8PYBVXHUwMDEy6XKBilx1MDAxY4nWT4iU6yTK01x0XHUwMDA21pH4VFZxJ2VcdTAwMTKjnpXnXHUwMDEwvZUlKE9ntcZCXG5cdTAwMTXK5lx1MDAwNOlQelrUXHUwMDAyrbBLl56PoLuJNPq0ktZYXG6My6HR/tr2XHUwMDA22r2zXm+7XHUwMDE2NT7JrTf8sDNcdTAwMWKNXG5BXHUwMDE5oUcmcajiqbTgLiVcdTAwMTSGceksxXBcdFx1MDAxNJVVXHUwMDBlmcYxy59pdH4gVudgUVx1MDAwNEe+wqUsgtxY1SqNdMpYWFx1MDAwNG/TWFRYkHP4bo5FXHUwMDA388B6f7eWQaSt4DyeQKNx1Fx1MDAxOcehIyZnXHSzwMZ5OPP4unxVPjo6dHhxuffl0+6HJvH1PHmmY9JJXHUwMDAxcPczw5mAllx1MDAxMdmBo2AsOIDJ55nZnZg8Mo1kyknS80pcdTAwMWFDkjlZ0uesc1xi1Nrjs04rXHUwMDExLFx1MDAxOFx1MDAwMWknvcdvKjXJXHUwMDEyplx1MDAwMW1cdTAwMDVKuYhcZl4w7bxUx/2r8vu4Xu1fnOyor7C7sfHuXHUwMDA3pJ3/O1x1MDAxZfaSZVlbS8WzN1x1MDAxM1x1MDAwZltkwkPRcIr0ae16h3YrXHUwMDE5Ui7LLZeU08pcdTAwMWPYlWDWJ1tcdTAwMDZRXHUwMDFiLbUrXHUwMDEwyM88XFxcdTAwMDTv+lx1MDAxYzwsjPZcdTAwMWKoqkj6mnEwln5cdTAwMGLCWP1cdTAwMDTJrFx1MDAwNVx1MDAwZfPsxORouFx1MDAxNfbiZTDwY1PZKVxcmWXmUbPnIeXJISpcdTAwMTNGsoRsXHUwMDE5iTBH6kejyPIxcs2cRVx1MDAxMFx1MDAwMoy0yub52FqGSirg2lx1MDAwMLdcdTAwMDZcdTAwMTIySLZ9taI8WVx1MDAxMZuTmyklU6nSXGaIdUbU5L9cdTAwMDCxwayELMdcdTAwMTMyXGKlfMAsXHUwMDAwslx1MDAwMjlWUlOcptVXmIpcdTAwMDBPTsndqKp2Oju168/bR1x1MDAxYp2ePv9wezMnxWlSf1x1MDAwYsn1Tlx1MDAxNGZt/zNlIU+by4e///V74dWlsVxi8F+kVJkhXHUwMDFkXG5Ajkwkh2rqeIJcdTAwMDBpnOO0KFx1MDAxNF6tkOnx8lhKxstNWavSi9eiq6swpon77Fx1MDAxZjpHXHUwMDAxcaVcdTAwMWK/JidcZtuNUWe7r9nOXHUwMDEyyFx1MDAwNtG01vdcdTAwMTPK6WFJeKBSXHUwMDFhNbG6NKmLXHUwMDFhlc4glFFOLSz3dO6IdVx1MDAxY+b8OWjXp9vUOnpcdTAwMWbu7ux393qHbz7gNy3it+FZkU0lX7rSWjogIeE46GRXfWiUZlxcWkMqRFx1MDAwYomCXHUwMDEykzzG/EyVfVx1MDAwML1cYio5XHUwMDE4k8Xpc2nBNmbrsdD9Z9BUTlx1MDAwMXFcdTAwMDE5huZAzoGZQp1GZEbQcVx1MDAxMkycW5vXVFJwSrHoS/lEy4IqKNTBXFybjP+aXGI9T4lcdTAwMDC8K43Mblx1MDAxMovdWFVFyZHl4MxcIoF4oqoyUqKR8yRcdTAwMDQ5VfX2zf7KyziqR2fnUffK7yCctv8z+Dus/2Hm0ltqZPCl6a0pMiirt2Z9oKxcdTAwMTKbXHUwMDAw68k53UQhRjGfUYyyXHUwMDBlOILJdrFYjkxcdTAwMGJQXHUwMDE0wDRFVSjoYpFkmudcdTAwMDTiXHUwMDE5JX3wLVBinFx1MDAxMipHo1x1MDAxM/hcco5sjD7jfIjz88crMVwieq4pXGJcdTAwMTehn1wi99hcdTAwMTKh5PRtyVx1MDAxNZKw/uRK7Pgj9Fx1MDAwZvY+V1157cPF/pd9RyzZmW/Tn1x1MDAxZXWuTf9ZlZjy2kmS4yGQVFx1MDAxMFx1MDAxMkza/pJcdTAwMDDm6CBcIreUwFx1MDAwMChcdTAwMWOeXHUwMDFkI66EIPkkOYkxXHUwMDBmIyVyXHUwMDAzWlJX0nDKbZ1cdTAwMTRcbmHagCXDrLVOck05NIlcdTAwMWWlR1x1MDAwNlRCM8uNLzNoXHUwMDBiXHUwMDBlUlx1MDAwNj6dWit9Myftw4tcdTAwMTO1/XGzfHMsZPPrx4NitVx1MDAwNlx1MDAxNknukEdcbqVBpVx1MDAxYVxuhspIMZpdS1JCgbHOWLGYWptZQXq1psF4baMsXHUwMDFhXG5meVx1MDAwNfnU4qxcdTAwMThcdTAwMTEziDNifPIv60uJaMnPRGbDi1RcdTAwMWIjZyBcdTAwMDVKj4hcdTAwMWHy6kxcdTAwMDBn2leevDuhQF1cdTAwMTDFn9VZYdSeQ51ZTbmVRVuYKKvswYfgTDCwlOO5RfqlpogzsNol87KAOOtcdTAwMDb10NdcXFbiaOW0/XKxQtRcdTAwMTPJsimiKCvL0o9S9CBzyLGb0sHN0WZj/bLU3uzDzrfPXHUwMDE1c4Kz7Vxcc+O3noGwSolcdTAwMTbl9aNAJi9gXFyA3670lYx8XHUwMDAxmbiDUdKp0SGgdbZoX+x557pcYseN2WHsi4BImawswrFcdTAwMWJfQqasV4JcdTAwMTLyZ2rZmK6yXHUwMDA01+5xXHUwMDE53Eiu81x1MDAxM5eox9g51474RFxyNL1MTdLSSl+g1tqhsZldXHUwMDE20udMUoiguC10vq9LOEV3k7ZcIlFI2ZbAJN9/7oWeXGL+i0dXpUlj+y2Uwn1cdTAwMTeT6qfNbrw4Y6w1Sv3AZuidLfPu+G1dXHUwMDFjXHUwMDA3m1x1MDAwN1x1MDAxMUB4wFx1MDAxYlx1MDAwN5uzVo/hYP1op7PXgN75+7VD2Yh2ttTHJVSlf9FqN+V+Jlx1MDAxZChcdTAwMTavdlx1MDAxN6/KTJrBKKYpnVx1MDAwMu5cdTAwMDRlrDrTdUbeNyloXHUwMDE4zXypW1Oaq5Ckf3L3s2CYXHUwMDE4M8LZXHUwMDA1g1x1MDAxMqiEQ1fUsWJ0rlx1MDAwZu0hODhLy1x1MDAwNiCWvyvLKVx1MDAwYnxcXOP2g1r+XHUwMDE56t1TSHec0F+M4SfXf2ZgeFx1MDAxMujWXHUwMDE5kvjWXHUwMDE4nerwvNtvXHUwMDA1YODLolY5K9O7W0OSt45cdFx1MDAwNGWsXHUwMDAwXHUwMDFmgXiBwlx1MDAxN1x1MDAxY1x1MDAxObdoNGpluFbJhzxz/lx1MDAxML+Xj+9Ek5SZXHUwMDFiibyok9TKXHUwMDFj2lOk71x1MDAwMJRaqNyyIOmX3u7q+i2gi882a7WNd7133zrNX5j0ZyBnSfp5SeRcXDx7M5EzwdWXpSVHRVx1MDAxY2sy5GwtsIfir07r/Vx1MDAwN7iDZNzTM2pwIIA/0/O9TdPg3ZydnunTuPRdXHUwMDEyhZ1oZjyONSknZ+RcdTAwMTOUTblcdTAwMTGAXHUwMDBilTZcdTAwMWVcYtrnsT9cdTAwMDM5T+HLLDmPmj1cdTAwMGYxn7xf2zypb9hcdTAwMTBj0V+r1o559ejDXHUwMDFjxKw44/5cdTAwMWRkR4xJidwoTp2UTIO1VpKIdpggLnlcdTAwMGZcdTAwMTlcdTAwMTjB2FinueBcdTAwMTJ1UUPac+5dXHUwMDAw1PajeVhRiqOks0U0jGqsvFx1MDAxNlxuSWUh/MCG8JPa/vnRerezXHUwMDFkfDhbv13/8vrgarfyXHUwMDAzWHg6W1x1MDAwZTKCJb1AVfyUs6WymmnrX0JGrlx1MDAxMdFlUWgmo1BzZp57te9tmlx1MDAwNrxonl5t1L55o7DFXHUwMDEzx/dcdTAwMTVYv1Ou+Fx1MDAxM5SuXHUwMDFln8H2+tWr8KfIX6cwV5ZcIrOGz0OSk2PI5EYh/1KF/8dcck5x498szmCTMle0KDgpWSTVm69NSaXoXG4hXHUwMDFkcMp+nCx665guccZcdTAwMTCBXHUwMDFhi2ggpb2WU3L+v2DMzqyMOb5RXGKNXHUwMDEwgrtCPEvNx+9Xg3S0eu5cdTAwMDdmrldcdTAwMWLX2+9L4VX1U1WXLzeaZ6Va9/18maDjZq6XkoaHl9qxPdb//VdJI1x1MDAwM2WV/5c30m/DuqnDcaZcdTAwMDU6S1x1MDAxMda/xY8wOl5cdTAwMWVKyYC5KVtaXHUwMDBm0ORQtpLuXHUwMDAx0kY6TdJNW6stiebUReP6bWZq+Zm5XHKpRDZcdTAwMDBwJYxRlILT8uBcZjYsueen2Ltn0UqgSCs5ykGVkH6TOFMrtNwwv0NovctZZVxu/nWSXHUwMDA2xpVS5I5I91x1MDAwYizYW1x1MDAxMPBcdTAwMWOBXHUwMDBiXCLw33NIJ5KqSOSY30bw+mh8oEW/XHUwMDBibFx1MDAxNvonSdOafkjNPUo5ff60t3/aXHUwMDFl38J82j5tv/J//XG6Wo9W4otg5ZRQ27tcYnqnqz9DZ9BcdTAwMTRcdTAwMTWUXHUwMDE1XFxcdTAwMGI8b/5pi9uHXtxHYVwi9c5eTFx1MDAwYjVcZmyrX8Pg+vX43OPFfUTxXHUwMDAwXG5cdTAwMDb0//3F9/9cdTAwMDLBTMouIn0= + + + + + buttonindex.sqllistGET /todo_form.sql?todo_id=7redirect to /index.sqltodo_form.sqlredirectformsubmitPOST/todo_form.sql?todo_id=7:todo="do the dishes" \ No newline at end of file diff --git a/examples/todo application (PostgreSQL)/index.sql b/examples/todo application (PostgreSQL)/index.sql new file mode 100644 index 00000000..699d08db --- /dev/null +++ b/examples/todo application (PostgreSQL)/index.sql @@ -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; \ No newline at end of file diff --git a/examples/todo application (PostgreSQL)/screenshot.png b/examples/todo application (PostgreSQL)/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..f464bbecd215029bb36ff80d833fb0b4d67a413e GIT binary patch literal 22261 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

(-}io5`LWN$OgKV?lk!HrU^ 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.sql +``` + +Where `` is a number that represents the version of the migration, and `` 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. diff --git a/examples/todo application (PostgreSQL)/sqlpage/sqlpage.json b/examples/todo application (PostgreSQL)/sqlpage/sqlpage.json new file mode 100644 index 00000000..086aa292 --- /dev/null +++ b/examples/todo application (PostgreSQL)/sqlpage/sqlpage.json @@ -0,0 +1,3 @@ +{ + "database_url": "sqlite://./sqlpage/sqlpage.db?mode=rwc" +} diff --git a/examples/todo application (PostgreSQL)/sqlpage/templates/README.md b/examples/todo application (PostgreSQL)/sqlpage/templates/README.md new file mode 100644 index 00000000..b1521b0a --- /dev/null +++ b/examples/todo application (PostgreSQL)/sqlpage/templates/README.md @@ -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.ophir.dev/components.sql + +## Custom components + +You can [write your own component templates](https://sql.ophir.dev/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/lovasoa/SQLpage/blob/main/sqlpage/templates/code.handlebars \ No newline at end of file diff --git a/examples/todo application (PostgreSQL)/timeline.sql b/examples/todo application (PostgreSQL)/timeline.sql new file mode 100644 index 00000000..8f2d49af --- /dev/null +++ b/examples/todo application (PostgreSQL)/timeline.sql @@ -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; \ No newline at end of file diff --git a/examples/todo application (PostgreSQL)/todo_form.sql b/examples/todo application (PostgreSQL)/todo_form.sql new file mode 100644 index 00000000..1ec457d2 --- /dev/null +++ b/examples/todo application (PostgreSQL)/todo_form.sql @@ -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; \ No newline at end of file diff --git a/examples/todo application/README.md b/examples/todo application/README.md index b9c4c2ee..85e265b5 100644 --- a/examples/todo application/README.md +++ b/examples/todo application/README.md @@ -1,6 +1,7 @@ # Todo app with SQLPage This is a simple todo app implemented with SQLPage. It uses a SQLite database to store the todo items. +(See [the PostgreSQL version](<../todo%20application%20(PostgreSQL)/README.md>)) ![Screenshot](screenshot.png) @@ -15,23 +16,25 @@ It will be loaded when the user visits the root of the application (`http://localhost:8080/` when running this example locally). In order, it uses: - - the [`dynamic`](https://sql.ophir.dev/documentation.sql?component=dynamic#component) component to load the [`shell.sql`](#shellsql) file that will be used at the top of every page -in the application to create a consistent layout and top bar. - - the [`list`](https://sql.ophir.dev/documentation.sql?component=list#component) component to display the list of todo items. - - the [`button`](https://sql.ophir.dev/documentation.sql?component=button#component) component to create a button that will redirect the user to the [`todo_form.sql`](#todo_formsql) page to create a new todo item when clicked. + +- the [`dynamic`](https://sql.ophir.dev/documentation.sql?component=dynamic#component) component to load the [`shell.sql`](#shellsql) file that will be used at the top of every page + in the application to create a consistent layout and top bar. +- the [`list`](https://sql.ophir.dev/documentation.sql?component=list#component) component to display the list of todo items. +- the [`button`](https://sql.ophir.dev/documentation.sql?component=button#component) component to create a button that will redirect the user to the [`todo_form.sql`](#todo_formsql) page to create a new todo item when clicked. ### [`todo_form.sql`](./todo_form.sql) This file is used to create a new todo item or edit an existing one. It uses: - 1. the [`redirect`](https://sql.ophir.dev/documentation.sql?component=redirect#component) component to redirect the user back to the [`index.sql`](#indexsql) page after the form is submitted. - 1. the [`dynamic`](https://sql.ophir.dev/documentation.sql?component=dynamic#component) component to load [`shell.sql`](#shellsql) to create a consistent layout and top bar. - 1. the [`form`](https://sql.ophir.dev/documentation.sql?component=form#component) component to create a form with fields for the title and description of the todo item. - The order of the components is important, as the `redirect` component cannot be used after the page has been displayed. It is called first to ensure that the user is redirected immediately after submitting the form. It is guarded by a `WHERE :todo_id IS NOT NULL` clause to ensure that it only redirects when - the form was submitted, not when the page is - initially loaded by the user in their browser. +1. the [`redirect`](https://sql.ophir.dev/documentation.sql?component=redirect#component) component to redirect the user back to the [`index.sql`](#indexsql) page after the form is submitted. +1. the [`dynamic`](https://sql.ophir.dev/documentation.sql?component=dynamic#component) component to load [`shell.sql`](#shellsql) to create a consistent layout and top bar. +1. the [`form`](https://sql.ophir.dev/documentation.sql?component=form#component) component to create a form with fields for the title and description of the todo item. + +The order of the components is important, as the `redirect` component cannot be used after the page has been displayed. It is called first to ensure that the user is redirected immediately after submitting the form. It is guarded by a `WHERE :todo_id IS NOT NULL` clause to ensure that it only redirects when +the form was submitted, not when the page is +initially loaded by the user in their browser. ![diagram explaining the structure of the application](./explanation_diagram.svg) @@ -43,34 +46,34 @@ It contains a delete statement guarded by a `WHERE $confirm = 'yes'` clause. So, the delete is not executed when the page is initially loaded, but only when the user -clicks the "Yes" button, which contains a link +clicks the "Yes" button, which contains a link pointing to the same page with the `confirm=yes` query parameter. The detailed step by step explanation of the delete process is as follows: - - - From the `index.sql` page, the user clicks the 'Delete' button on a todo item - - It loads the page `/delete.sql?todo_id=7` (without the `confirm=yes` parameter) - - the delete statement **is** sent to the database and executed. SQLPage has bound the values to URL query parameters, so we have - - `$todo_id` bound to `'7'`, and - - `$confirm` bound to `NULL` (since there was no `confirm` parameter in the url) - - the database evaluates the `where id = $todo_id and $confirm = 'yes'` condition to FALSE - - so it deletes nothing, and returns nothing - - SQLPage receives no row back from the database, it continues processing normally - - it executes the `select 'dynamic' ...` query, which itself requires executing the `shell.sql` file. The result of this is a row that contains `component=dynamic` and `properties={"component": "shell", "title": "My Todo App", ... }` - - it renders the page header with the application header and the top bar following the results of the query - - it sends to the database the last query: `select 'alert' as component, ... from todos where id = $todo_id` it binds the parameters like before - - `$todo_id` bound to `'7'` - - the database returns a single row, containing `component=alert`, `description_md=Are you sure [...] [the title of the todo item with id 7]`, ... - - SQLPage returns the the `alert` component with its contents to the browser - - The user sees the confirmation alert and clicks the 'Delete' button - - The page is reloaded, this time with the URL `/delete.sql?todo_id=7&confirm=yes` - - the delete statement is sent to the database and executed like last time. But this time SQLPage has bound the values to the new URL query parameters, - - `$todo_id` bound to `'7'`, (like before) - - `$confirm` bound to `'yes'` (since there is now a `confirm` parameter in the url) - - the database evaluates the `where id = $todo_id and $confirm = 'yes'` condition to TRUE - - so it deletes the todo item with id 7 and, as instructed by the `returning` clause, returns a single row containing `component=redirect`, `link=/` - - SQLPage receives the row back from the database, and immediately returns sends a 302 redirect response to the browser, redirecting the user to the `/` page. - - The following queries are not executed, as the page is redirected before they are processed. + +- From the `index.sql` page, the user clicks the 'Delete' button on a todo item +- It loads the page `/delete.sql?todo_id=7` (without the `confirm=yes` parameter) + - the delete statement **is** sent to the database and executed. SQLPage has bound the values to URL query parameters, so we have + - `$todo_id` bound to `'7'`, and + - `$confirm` bound to `NULL` (since there was no `confirm` parameter in the url) + - the database evaluates the `where id = $todo_id and $confirm = 'yes'` condition to FALSE + - so it deletes nothing, and returns nothing + - SQLPage receives no row back from the database, it continues processing normally + - it executes the `select 'dynamic' ...` query, which itself requires executing the `shell.sql` file. The result of this is a row that contains `component=dynamic` and `properties={"component": "shell", "title": "My Todo App", ... }` + - it renders the page header with the application header and the top bar following the results of the query + - it sends to the database the last query: `select 'alert' as component, ... from todos where id = $todo_id` it binds the parameters like before + - `$todo_id` bound to `'7'` + - the database returns a single row, containing `component=alert`, `description_md=Are you sure [...] [the title of the todo item with id 7]`, ... + - SQLPage returns the the `alert` component with its contents to the browser +- The user sees the confirmation alert and clicks the 'Delete' button +- The page is reloaded, this time with the URL `/delete.sql?todo_id=7&confirm=yes` + - the delete statement is sent to the database and executed like last time. But this time SQLPage has bound the values to the new URL query parameters, + - `$todo_id` bound to `'7'`, (like before) + - `$confirm` bound to `'yes'` (since there is now a `confirm` parameter in the url) + - the database evaluates the `where id = $todo_id and $confirm = 'yes'` condition to TRUE + - so it deletes the todo item with id 7 and, as instructed by the `returning` clause, returns a single row containing `component=redirect`, `link=/` + - SQLPage receives the row back from the database, and immediately returns sends a 302 redirect response to the browser, redirecting the user to the `/` page. + - The following queries are not executed, as the page is redirected before they are processed. ### [`shell.sql`](./shell.sql) @@ -92,14 +95,14 @@ of the common features of SQLPage. ### Components - - [list](https://sql.ophir.dev/documentation.sql?component=list#component) - - [button](https://sql.ophir.dev/documentation.sql?component=button#component) - - [form](https://sql.ophir.dev/documentation.sql?component=form#component) - - [redirect](https://sql.ophir.dev/documentation.sql?component=redirect#component) - - [shell](https://sql.ophir.dev/documentation.sql?component=shell#component) - - [timeline](https://sql.ophir.dev/documentation.sql?component=timeline#component) - - [dynamic](https://sql.ophir.dev/documentation.sql?component=timeline#component) +- [list](https://sql.ophir.dev/documentation.sql?component=list#component) +- [button](https://sql.ophir.dev/documentation.sql?component=button#component) +- [form](https://sql.ophir.dev/documentation.sql?component=form#component) +- [redirect](https://sql.ophir.dev/documentation.sql?component=redirect#component) +- [shell](https://sql.ophir.dev/documentation.sql?component=shell#component) +- [timeline](https://sql.ophir.dev/documentation.sql?component=timeline#component) +- [dynamic](https://sql.ophir.dev/documentation.sql?component=timeline#component) ### Functions - - [sqlpage.run_sql](https://sql.ophir.dev/functions.sql?function=run_sql#function) +- [sqlpage.run_sql](https://sql.ophir.dev/functions.sql?function=run_sql#function)