🐿️ Type safe SQL in Gleam
57

Configure Feed

Select the types of activity you want to include in your feed.

:tada: Hello, Joe!

Giacomo Cavalieri (Aug 9, 2024, 4:23 PM +0200) 25fc0d2b

+5258
+55
.github/workflows/release.yml
··· 1 + name: GH and Hex.pm Release 2 + 3 + on: 4 + push: 5 + tags: 6 + - v*.*.* 7 + 8 + jobs: 9 + test: 10 + uses: ./.github/workflows/test.yml 11 + 12 + version: 13 + runs-on: ubuntu-latest 14 + steps: 15 + - uses: actions/checkout@v4 16 + - uses: actions-rs/toolchain@v1 17 + with: 18 + toolchain: stable 19 + - run: cargo install tomlq 20 + - run: | 21 + if [ "v$(tomlq version -f gleam.toml)" == "${{ github.ref_name }}" ]; then 22 + exit 0 23 + fi 24 + echo "tag does not match version in gleam.toml, refusing to publish" 25 + exit 1 26 + 27 + release-gh: 28 + runs-on: ubuntu-latest 29 + needs: 30 + - test 31 + - version 32 + steps: 33 + - name: Checkout 34 + uses: actions/checkout@v4 35 + - uses: TanklesXL/gleam_actions/.github/actions/deps_restore@main 36 + - name: Release 37 + uses: softprops/action-gh-release@v1 38 + 39 + release-hex: 40 + runs-on: ubuntu-latest 41 + needs: 42 + - test 43 + - version 44 + steps: 45 + - uses: actions/checkout@v4 46 + - uses: TanklesXL/gleam_actions/.github/actions/deps_restore@main 47 + - uses: TanklesXL/gleam_actions/.github/actions/install_gleam@main 48 + with: 49 + erlang_version: 27 50 + gleam_version: 1.4.1 51 + - uses: TanklesXL/gleam_actions/.github/actions/hex_publish@main 52 + with: 53 + hex_user: ${{ secrets.HEXPM_USER }} 54 + hex_pass: ${{ secrets.HEXPM_PASS }} 55 + hex_key: ${{ secrets.HEXPM_API_KEY }}
+69
.github/workflows/test.yml
··· 1 + name: Test 2 + 3 + on: 4 + push: 5 + branches: 6 + - main 7 + pull_request: 8 + workflow_call: 9 + 10 + jobs: 11 + format: 12 + runs-on: ubuntu-latest 13 + steps: 14 + - uses: actions/checkout@v4 15 + - uses: TanklesXL/gleam_actions/.github/actions/install_gleam@main 16 + with: 17 + gleam_version: 1.4.1 18 + erlang_version: 27 19 + - uses: TanklesXL/gleam_actions/.github/actions/format@main 20 + 21 + deps: 22 + runs-on: ubuntu-latest 23 + steps: 24 + - uses: actions/checkout@v4 25 + - uses: TanklesXL/gleam_actions/.github/actions/install_gleam@main 26 + with: 27 + gleam_version: 1.4.1 28 + erlang_version: 27 29 + - uses: TanklesXL/gleam_actions/.github/actions/deps_cache@main 30 + with: 31 + gleam_version: 1.4.1 32 + 33 + test: 34 + runs-on: ubuntu-latest 35 + needs: deps 36 + strategy: 37 + fail-fast: true 38 + matrix: 39 + erlang: ["26", "27"] 40 + 41 + # Setup a postgres service to run the tests. 42 + services: 43 + postgres: 44 + image: postgres:latest 45 + env: 46 + POSTGRES_HOST_AUTH_METHOD: trust 47 + POSTGRES_HOST: localhost 48 + POSTGRES_PORT: 5432 49 + POSTGRES_PASSWORD: postgres_password 50 + POSTGRES_USER: squirrel_test 51 + POSTGRES_DB: squirrel_test 52 + ports: 53 + - 5432:5432 54 + options: >- 55 + --health-cmd pg_isready 56 + --health-interval 10s 57 + --health-timeout 5s 58 + --health-retries 5 59 + 60 + steps: 61 + - uses: actions/checkout@v4 62 + - uses: TanklesXL/gleam_actions/.github/actions/deps_restore@main 63 + - uses: TanklesXL/gleam_actions/.github/actions/install_gleam@main 64 + with: 65 + gleam_version: 1.4.1 66 + erlang_version: ${{ matrix.erlang }} 67 + - uses: TanklesXL/gleam_actions/.github/actions/test@main 68 + with: 69 + target: "erlang"
+4
.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump
+37
CONTRIBUTING.md
··· 1 + # Contributing 2 + 3 + If you're reading this, thank you so much for trying to contribute to 4 + `squirrel`! 5 + I tried to do my best and comment the code as much as possible and make it easy 6 + to understand. Each module also starts with a small comment to explain what it 7 + does, so it should be easier to dive in the codebase. 8 + 9 + > 💡 If you feel like some pieces of code are not commented enough or are too 10 + > obscure, than that's a bug! Please do reach out, I'd love to hear your 11 + > feedback and make `squirrel` easier to contribute to! 12 + 13 + ## Running the tests 14 + 15 + Most of the tests are snapshot tests that directly call the `postgres.main` 16 + function to let it type the queries. In order to do that `squirrel` will have to 17 + connect to a postgres server that must be running during the tests. 18 + 19 + - In CI this is taken care of automatically 20 + - Locally you'll need a little bit of setup: 21 + - There must be a user called `squirrel_test` 22 + - It must be able to read and write to a database called `squirrel_test` 23 + - It will use the empty password to connect at localhost's port 5432 24 + 25 + ## Writing tests 26 + 27 + `squirrel` uses a lot of snapshot tests, to add new tests for the code 28 + generation bits you can have a look and copy the existing ones. 29 + There's no hard requirements but I have some suggestion to write good snapshot 30 + tests: 31 + 32 + - Have at most one snapshot per test function 33 + - Try to keep the snapshots as small as possible. 34 + Ideally one snapshot should assert a single property of the generated code so 35 + that it is easier to focus on a specific aspect of the code when reviewing it 36 + - Use a long descriptive title for the snapshots: a title should describe what 37 + one expects to see in the produced snapshot to guide the review process
+201
LICENSE
··· 1 + Apache License 2 + Version 2.0, January 2004 3 + http://www.apache.org/licenses/ 4 + 5 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 + 7 + 1. Definitions. 8 + 9 + "License" shall mean the terms and conditions for use, reproduction, 10 + and distribution as defined by Sections 1 through 9 of this document. 11 + 12 + "Licensor" shall mean the copyright owner or entity authorized by 13 + the copyright owner that is granting the License. 14 + 15 + "Legal Entity" shall mean the union of the acting entity and all 16 + other entities that control, are controlled by, or are under common 17 + control with that entity. For the purposes of this definition, 18 + "control" means (i) the power, direct or indirect, to cause the 19 + direction or management of such entity, whether by contract or 20 + otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 + outstanding shares, or (iii) beneficial ownership of such entity. 22 + 23 + "You" (or "Your") shall mean an individual or Legal Entity 24 + exercising permissions granted by this License. 25 + 26 + "Source" form shall mean the preferred form for making modifications, 27 + including but not limited to software source code, documentation 28 + source, and configuration files. 29 + 30 + "Object" form shall mean any form resulting from mechanical 31 + transformation or translation of a Source form, including but 32 + not limited to compiled object code, generated documentation, 33 + and conversions to other media types. 34 + 35 + "Work" shall mean the work of authorship, whether in Source or 36 + Object form, made available under the License, as indicated by a 37 + copyright notice that is included in or attached to the work 38 + (an example is provided in the Appendix below). 39 + 40 + "Derivative Works" shall mean any work, whether in Source or Object 41 + form, that is based on (or derived from) the Work and for which the 42 + editorial revisions, annotations, elaborations, or other modifications 43 + represent, as a whole, an original work of authorship. For the purposes 44 + of this License, Derivative Works shall not include works that remain 45 + separable from, or merely link (or bind by name) to the interfaces of, 46 + the Work and Derivative Works thereof. 47 + 48 + "Contribution" shall mean any work of authorship, including 49 + the original version of the Work and any modifications or additions 50 + to that Work or Derivative Works thereof, that is intentionally 51 + submitted to Licensor for inclusion in the Work by the copyright owner 52 + or by an individual or Legal Entity authorized to submit on behalf of 53 + the copyright owner. For the purposes of this definition, "submitted" 54 + means any form of electronic, verbal, or written communication sent 55 + to the Licensor or its representatives, including but not limited to 56 + communication on electronic mailing lists, source code control systems, 57 + and issue tracking systems that are managed by, or on behalf of, the 58 + Licensor for the purpose of discussing and improving the Work, but 59 + excluding communication that is conspicuously marked or otherwise 60 + designated in writing by the copyright owner as "Not a Contribution." 61 + 62 + "Contributor" shall mean Licensor and any individual or Legal Entity 63 + on behalf of whom a Contribution has been received by Licensor and 64 + subsequently incorporated within the Work. 65 + 66 + 2. Grant of Copyright License. Subject to the terms and conditions of 67 + this License, each Contributor hereby grants to You a perpetual, 68 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 + copyright license to reproduce, prepare Derivative Works of, 70 + publicly display, publicly perform, sublicense, and distribute the 71 + Work and such Derivative Works in Source or Object form. 72 + 73 + 3. Grant of Patent License. Subject to the terms and conditions of 74 + this License, each Contributor hereby grants to You a perpetual, 75 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 + (except as stated in this section) patent license to make, have made, 77 + use, offer to sell, sell, import, and otherwise transfer the Work, 78 + where such license applies only to those patent claims licensable 79 + by such Contributor that are necessarily infringed by their 80 + Contribution(s) alone or by combination of their Contribution(s) 81 + with the Work to which such Contribution(s) was submitted. If You 82 + institute patent litigation against any entity (including a 83 + cross-claim or counterclaim in a lawsuit) alleging that the Work 84 + or a Contribution incorporated within the Work constitutes direct 85 + or contributory patent infringement, then any patent licenses 86 + granted to You under this License for that Work shall terminate 87 + as of the date such litigation is filed. 88 + 89 + 4. Redistribution. You may reproduce and distribute copies of the 90 + Work or Derivative Works thereof in any medium, with or without 91 + modifications, and in Source or Object form, provided that You 92 + meet the following conditions: 93 + 94 + (a) You must give any other recipients of the Work or 95 + Derivative Works a copy of this License; and 96 + 97 + (b) You must cause any modified files to carry prominent notices 98 + stating that You changed the files; and 99 + 100 + (c) You must retain, in the Source form of any Derivative Works 101 + that You distribute, all copyright, patent, trademark, and 102 + attribution notices from the Source form of the Work, 103 + excluding those notices that do not pertain to any part of 104 + the Derivative Works; and 105 + 106 + (d) If the Work includes a "NOTICE" text file as part of its 107 + distribution, then any Derivative Works that You distribute must 108 + include a readable copy of the attribution notices contained 109 + within such NOTICE file, excluding those notices that do not 110 + pertain to any part of the Derivative Works, in at least one 111 + of the following places: within a NOTICE text file distributed 112 + as part of the Derivative Works; within the Source form or 113 + documentation, if provided along with the Derivative Works; or, 114 + within a display generated by the Derivative Works, if and 115 + wherever such third-party notices normally appear. The contents 116 + of the NOTICE file are for informational purposes only and 117 + do not modify the License. You may add Your own attribution 118 + notices within Derivative Works that You distribute, alongside 119 + or as an addendum to the NOTICE text from the Work, provided 120 + that such additional attribution notices cannot be construed 121 + as modifying the License. 122 + 123 + You may add Your own copyright statement to Your modifications and 124 + may provide additional or different license terms and conditions 125 + for use, reproduction, or distribution of Your modifications, or 126 + for any such Derivative Works as a whole, provided Your use, 127 + reproduction, and distribution of the Work otherwise complies with 128 + the conditions stated in this License. 129 + 130 + 5. Submission of Contributions. Unless You explicitly state otherwise, 131 + any Contribution intentionally submitted for inclusion in the Work 132 + by You to the Licensor shall be under the terms and conditions of 133 + this License, without any additional terms or conditions. 134 + Notwithstanding the above, nothing herein shall supersede or modify 135 + the terms of any separate license agreement you may have executed 136 + with Licensor regarding such Contributions. 137 + 138 + 6. Trademarks. This License does not grant permission to use the trade 139 + names, trademarks, service marks, or product names of the Licensor, 140 + except as required for reasonable and customary use in describing the 141 + origin of the Work and reproducing the content of the NOTICE file. 142 + 143 + 7. Disclaimer of Warranty. Unless required by applicable law or 144 + agreed to in writing, Licensor provides the Work (and each 145 + Contributor provides its Contributions) on an "AS IS" BASIS, 146 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 + implied, including, without limitation, any warranties or conditions 148 + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 + PARTICULAR PURPOSE. You are solely responsible for determining the 150 + appropriateness of using or redistributing the Work and assume any 151 + risks associated with Your exercise of permissions under this License. 152 + 153 + 8. Limitation of Liability. In no event and under no legal theory, 154 + whether in tort (including negligence), contract, or otherwise, 155 + unless required by applicable law (such as deliberate and grossly 156 + negligent acts) or agreed to in writing, shall any Contributor be 157 + liable to You for damages, including any direct, indirect, special, 158 + incidental, or consequential damages of any character arising as a 159 + result of this License or out of the use or inability to use the 160 + Work (including but not limited to damages for loss of goodwill, 161 + work stoppage, computer failure or malfunction, or any and all 162 + other commercial damages or losses), even if such Contributor 163 + has been advised of the possibility of such damages. 164 + 165 + 9. Accepting Warranty or Additional Liability. While redistributing 166 + the Work or Derivative Works thereof, You may choose to offer, 167 + and charge a fee for, acceptance of support, warranty, indemnity, 168 + or other liability obligations and/or rights consistent with this 169 + License. However, in accepting such obligations, You may act only 170 + on Your own behalf and on Your sole responsibility, not on behalf 171 + of any other Contributor, and only if You agree to indemnify, 172 + defend, and hold each Contributor harmless for any liability 173 + incurred by, or claims asserted against, such Contributor by reason 174 + of your accepting any such warranty or additional liability. 175 + 176 + END OF TERMS AND CONDITIONS 177 + 178 + APPENDIX: How to apply the Apache License to your work. 179 + 180 + To apply the Apache License to your work, attach the following 181 + boilerplate notice, with the fields enclosed by brackets "[]" 182 + replaced with your own identifying information. (Don't include 183 + the brackets!) The text should be enclosed in the appropriate 184 + comment syntax for the file format. We also recommend that a 185 + file or class name and description of purpose be included on the 186 + same "printed page" as the copyright notice for easier 187 + identification within third-party archives. 188 + 189 + Copyright 2024 Giacomo Cavalieri 190 + 191 + Licensed under the Apache License, Version 2.0 (the "License"); 192 + you may not use this file except in compliance with the License. 193 + You may obtain a copy of the License at 194 + 195 + http://www.apache.org/licenses/LICENSE-2.0 196 + 197 + Unless required by applicable law or agreed to in writing, software 198 + distributed under the License is distributed on an "AS IS" BASIS, 199 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 + See the License for the specific language governing permissions and 201 + limitations under the License.
+186
README.md
··· 1 + # 🐿️ squirrel - type safe SQL in Gleam 2 + 3 + [![Package Version](https://img.shields.io/hexpm/v/squirrel)](https://hex.pm/packages/squirrel) 4 + [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/squirrel/) 5 + 6 + ## What's Squirrel? 7 + 8 + If you need to talk with a database in Gleam you'll have to write something like 9 + this: 10 + 11 + ```gleam 12 + import gleam/pgo 13 + import decode 14 + 15 + pub type FindSquirrelRow { 16 + FindSquirrelRow(name: String, owned_acorns: Int) 17 + } 18 + 19 + /// Find a squirrel and its owned acorns given its name. 20 + /// 21 + pub fn find_squirrel(db: pgo.Connection, name: String) { 22 + let squirrel_row_decoder = 23 + decode.into({ 24 + use name <- decode.parameter 25 + use owned_acorns <- decode.parameter 26 + FindSquirrelRow(name: name, owned_acorns: owned_acorns) 27 + }) 28 + |> decode.field(0, decode.string) 29 + |> decode.field(1, decode.int) 30 + 31 + "select name, owned_acorns 32 + from squirrel 33 + where name = $1" 34 + |> pgo.execute(db, [pgo.text(name)], squirrel_row_decoder) 35 + } 36 + ``` 37 + 38 + This is probably fine if you have a few small queries but it can become quite 39 + the burden when you have a lot of queries: 40 + 41 + - The SQL query you write is just a plain string, you do not get syntax 42 + highlighting, auto formatting, suggestions... all the little niceties you 43 + would otherwise get if you where writing a plain `*.sql` file. 44 + - This also means you loose the ability to run these queries on their own with 45 + other external tools, inspect them and so on. 46 + - You have to manually keep in sync the decoder with the query's output. 47 + 48 + One might be tempted to hide all of this by reaching for something like an ORM. 49 + Squirrel proposes a different approach: instead of trying to hide the SQL it 50 + _embraces it and leaves you in control._ 51 + You write the SQL queries in plain old `*.sql` files and Squirrel will take care 52 + of generating all the corresponding functions. 53 + 54 + A code snippet is worth a thousand words, so let's have a look at an example. 55 + Instead of the hand written example shown earlier you can instead just write the 56 + following query: 57 + 58 + ```sql 59 + -- we're in file `src/squirrels/sql/find_squirrel.sql` 60 + -- Find a squirrel and its owned acorns given its name. 61 + select 62 + name, 63 + owned_acorns 64 + from 65 + squirrel 66 + where 67 + name = $1 68 + ``` 69 + 70 + And run `gleam run -m squirrel`. Just like magic you'll now have a type-safe 71 + function `find_squirrel` you can use just as you'd expect: 72 + 73 + ```gleam 74 + import squirrels/sql 75 + 76 + pub fn main() { 77 + let db = todo as "the pgo connection" 78 + // And it just works as you'd expect: 79 + let assert Ok(#(_rows_count, rows)) = sql.find_squirrel("sandy") 80 + let assert [FindSquirrelRow(name: "sandy", owned_acorns: 11_111)] = rows 81 + } 82 + ``` 83 + 84 + Behind the scenes Squirrel generates the decoders and functions you need; and 85 + it's pretty-printed, standard Gleam code (actually it's exactly like the hand 86 + written example I showed you earlier)! 87 + So now you get the best of both worlds: 88 + 89 + - You don't have to take care of keeping encoders and decoders in sync, Squirrel 90 + does that for you. 91 + - And you're not compromising on type safety either: Squirrel is able to 92 + understand the types of your query and produce a correct decoder. 93 + - You can stick to writing plain SQL in `*.sql` files. You'll have better 94 + editor support, syntax highlighting and completions. 95 + - You can run each query on its own: need to `explain` a query? 96 + No big deal, it's just a plain old `*.sql` file. 97 + 98 + ## Usage 99 + 100 + First you'll need to add Squirrel to your project as a dev dependency: 101 + 102 + ```sh 103 + gleam add squirrel --dev 104 + 105 + # Remember to add these packages if you haven't yet, they are needed by the 106 + # generated code to run and decode the read rows! 107 + gleam add gleam_pgo 108 + gleam add decode 109 + ``` 110 + 111 + Then you can ask it to generate code running the `squirrel` module: 112 + 113 + ```sh 114 + gleam run -m squirrel 115 + ``` 116 + 117 + And that's it! As long as you follow a couple of conventions Squirrel will just 118 + work: 119 + 120 + - Squirrel will look for all `*.sql` files in any `sql` directory under your 121 + project's `src` directory. 122 + - Each `sql` directory will be turned into a single Gleam module containing a 123 + function for each `*.sql` file inside it. The generated Gleam module is going 124 + to be located in the same directory as the corresponding `sql` directory and 125 + it's name is `sql.gleam`. 126 + - Each `*.sql` file _must contain a single SQL query._ And the name of the file 127 + is going to be the name of the corresponding Gleam function to run that query. 128 + 129 + > Let's make an example. Imagine you have a Gleam project that looks like this 130 + > 131 + > ```txt 132 + > ├── src 133 + > │   ├── squirrels 134 + > │ │ └── sql 135 + > │ │ ├── find_squirrel.sql 136 + > │ │ └── list_squirrels.sql 137 + > │   └── squirrels.gleam 138 + > └── test 139 + > └── squirrels_test.gleam 140 + > ``` 141 + > 142 + > Running `gleam run -m squirrel` will create a `src/squirrels/sql.gleam` file 143 + > defining two functions `find_squirrel` and `list_squirrels` you can then 144 + > import and use in your code. 145 + 146 + ### Talking to the database 147 + 148 + In order to understand the type of your queries, Squirrel needs to connect to 149 + the Postgres server where the database is defined. To connect, it will read the 150 + [Postgres env variables](https://www.postgresql.org/docs/current/libpq-envars.html) 151 + and use the following defaults if one is not set: 152 + 153 + - `PGHOST`: `"localhost"` 154 + - `PGPORT`: `5432` 155 + - `PGUSER`: `"root"` 156 + - `PGDATABASE`: `"database"` 157 + - `PGPASSWORD`: `""` 158 + 159 + ## FAQ 160 + 161 + ### What flavour of SQL does squirrel support? 162 + 163 + Squirrel only has support for Postgres. 164 + 165 + ### Why isn't squirrel configurable in any way? 166 + 167 + By going the "convention over configuration" route, Squirrel enforces that all 168 + projects adopting it will always have the same structure. 169 + If you need to contribute to a project using Squirrel you'll immediately know 170 + which directories and modules to look for. 171 + 172 + This makes it easier to get started with a new project and cuts down on all the 173 + bike shedding: _"Where should I put my queries?",_ 174 + _"How many queries should go in on file?",_ ... 175 + 176 + ## References 177 + 178 + This package draws a lot of inspiration from the amazing 179 + [yesql](https://github.com/krisajenkins/yesql) and 180 + [sqlx](https://github.com/launchbadge/sqlx). 181 + 182 + ## Contributing 183 + 184 + If you think there’s any way to improve this package, or if you spot a bug don’t 185 + be afraid to open PRs, issues or requests of any kind! Any contribution is 186 + welcome 💜
+33
birdie_snapshots/array_decoding.accepted
··· 1 + --- 2 + version: 1.1.8 3 + title: array decoding 4 + file: ./test/squirrel_test.gleam 5 + test_name: array_decoding_test 6 + --- 7 + /// A row you get from running the `query` query 8 + /// defined in `query.sql`. 9 + /// 10 + /// > 🐿️ This type definition was generated automatically using v-test of the 11 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 12 + /// 13 + pub type QueryRow { 14 + QueryRow(res: List(Int)) 15 + } 16 + 17 + /// Runs the `query` query 18 + /// defined in `query.sql`. 19 + /// 20 + /// > 🐿️ This function was generated automatically using v-test of 21 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 22 + /// 23 + pub fn query(db) { 24 + let decoder = 25 + decode.into({ 26 + use res <- decode.parameter 27 + QueryRow(res: res) 28 + }) 29 + |> decode.field(0, decode.list(decode.int)) 30 + 31 + "select array[1, 2, 3] as res" 32 + |> pgo.execute(db, [], decode.from(decoder, _)) 33 + }
+37
birdie_snapshots/array_encoding.accepted
··· 1 + --- 2 + version: 1.1.8 3 + title: array encoding 4 + file: ./test/squirrel_test.gleam 5 + test_name: array_encoding_test 6 + --- 7 + /// A row you get from running the `query` query 8 + /// defined in `query.sql`. 9 + /// 10 + /// > 🐿️ This type definition was generated automatically using v-test of the 11 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 12 + /// 13 + pub type QueryRow { 14 + QueryRow(res: Bool) 15 + } 16 + 17 + /// Runs the `query` query 18 + /// defined in `query.sql`. 19 + /// 20 + /// > 🐿️ This function was generated automatically using v-test of 21 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 22 + /// 23 + pub fn query(db, arg_1) { 24 + let decoder = 25 + decode.into({ 26 + use res <- decode.parameter 27 + QueryRow(res: res) 28 + }) 29 + |> decode.field(0, decode.bool) 30 + 31 + "select true as res where $1 = array[1, 2, 3]" 32 + |> pgo.execute( 33 + db, 34 + [pgo.array(list.map(arg_1, fn(a) {pgo.int(a)}))], 35 + decode.from(decoder, _), 36 + ) 37 + }
+33
birdie_snapshots/bool_decoding.accepted
··· 1 + --- 2 + version: 1.1.8 3 + title: bool decoding 4 + file: ./test/squirrel_test.gleam 5 + test_name: bool_decoding_test 6 + --- 7 + /// A row you get from running the `query` query 8 + /// defined in `query.sql`. 9 + /// 10 + /// > 🐿️ This type definition was generated automatically using v-test of the 11 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 12 + /// 13 + pub type QueryRow { 14 + QueryRow(res: Bool) 15 + } 16 + 17 + /// Runs the `query` query 18 + /// defined in `query.sql`. 19 + /// 20 + /// > 🐿️ This function was generated automatically using v-test of 21 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 22 + /// 23 + pub fn query(db) { 24 + let decoder = 25 + decode.into({ 26 + use res <- decode.parameter 27 + QueryRow(res: res) 28 + }) 29 + |> decode.field(0, decode.bool) 30 + 31 + "select true as res" 32 + |> pgo.execute(db, [], decode.from(decoder, _)) 33 + }
+33
birdie_snapshots/bool_encoding.accepted
··· 1 + --- 2 + version: 1.1.8 3 + title: bool encoding 4 + file: ./test/squirrel_test.gleam 5 + test_name: bool_encoding_test 6 + --- 7 + /// A row you get from running the `query` query 8 + /// defined in `query.sql`. 9 + /// 10 + /// > 🐿️ This type definition was generated automatically using v-test of the 11 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 12 + /// 13 + pub type QueryRow { 14 + QueryRow(res: Bool) 15 + } 16 + 17 + /// Runs the `query` query 18 + /// defined in `query.sql`. 19 + /// 20 + /// > 🐿️ This function was generated automatically using v-test of 21 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 22 + /// 23 + pub fn query(db, arg_1) { 24 + let decoder = 25 + decode.into({ 26 + use res <- decode.parameter 27 + QueryRow(res: res) 28 + }) 29 + |> decode.field(0, decode.bool) 30 + 31 + "select true as res where $1 = true" 32 + |> pgo.execute(db, [pgo.bool(arg_1)], decode.from(decoder, _)) 33 + }
+33
birdie_snapshots/float_decoding.accepted
··· 1 + --- 2 + version: 1.1.8 3 + title: float decoding 4 + file: ./test/squirrel_test.gleam 5 + test_name: float_decoding_test 6 + --- 7 + /// A row you get from running the `query` query 8 + /// defined in `query.sql`. 9 + /// 10 + /// > 🐿️ This type definition was generated automatically using v-test of the 11 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 12 + /// 13 + pub type QueryRow { 14 + QueryRow(res: Float) 15 + } 16 + 17 + /// Runs the `query` query 18 + /// defined in `query.sql`. 19 + /// 20 + /// > 🐿️ This function was generated automatically using v-test of 21 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 22 + /// 23 + pub fn query(db) { 24 + let decoder = 25 + decode.into({ 26 + use res <- decode.parameter 27 + QueryRow(res: res) 28 + }) 29 + |> decode.field(0, decode.float) 30 + 31 + "select 1.1 as res" 32 + |> pgo.execute(db, [], decode.from(decoder, _)) 33 + }
+33
birdie_snapshots/float_encoding.accepted
··· 1 + --- 2 + version: 1.1.8 3 + title: float encoding 4 + file: ./test/squirrel_test.gleam 5 + test_name: float_encoding_test 6 + --- 7 + /// A row you get from running the `query` query 8 + /// defined in `query.sql`. 9 + /// 10 + /// > 🐿️ This type definition was generated automatically using v-test of the 11 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 12 + /// 13 + pub type QueryRow { 14 + QueryRow(res: Bool) 15 + } 16 + 17 + /// Runs the `query` query 18 + /// defined in `query.sql`. 19 + /// 20 + /// > 🐿️ This function was generated automatically using v-test of 21 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 22 + /// 23 + pub fn query(db, arg_1) { 24 + let decoder = 25 + decode.into({ 26 + use res <- decode.parameter 27 + QueryRow(res: res) 28 + }) 29 + |> decode.field(0, decode.bool) 30 + 31 + "select true as res where $1 = 1.1" 32 + |> pgo.execute(db, [pgo.float(arg_1)], decode.from(decoder, _)) 33 + }
+41
birdie_snapshots/generated_type_fields_are_labelled_with_their_name_in_the_select_list.accepted
··· 1 + --- 2 + version: 1.1.8 3 + title: generated type fields are labelled with their name in the select list 4 + file: ./test/squirrel_test.gleam 5 + test_name: generated_type_fields_are_labelled_with_their_name_in_the_select_list_test 6 + --- 7 + /// A row you get from running the `query` query 8 + /// defined in `query.sql`. 9 + /// 10 + /// > 🐿️ This type definition was generated automatically using v-test of the 11 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 12 + /// 13 + pub type QueryRow { 14 + QueryRow(acorns: Option(Int), squirrel_name: String) 15 + } 16 + 17 + /// Runs the `query` query 18 + /// defined in `query.sql`. 19 + /// 20 + /// > 🐿️ This function was generated automatically using v-test of 21 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 22 + /// 23 + pub fn query(db) { 24 + let decoder = 25 + decode.into({ 26 + use acorns <- decode.parameter 27 + use squirrel_name <- decode.parameter 28 + QueryRow(acorns: acorns, squirrel_name: squirrel_name) 29 + }) 30 + |> decode.field(0, decode.optional(decode.int)) 31 + |> decode.field(1, decode.string) 32 + 33 + " 34 + select 35 + acorns, 36 + name as squirrel_name 37 + from 38 + squirrel 39 + " 40 + |> pgo.execute(db, [], decode.from(decoder, _)) 41 + }
+35
birdie_snapshots/generated_type_has_the_same_name_as_the_function_but_in_pascal_case.accepted
··· 1 + --- 2 + version: 1.1.8 3 + title: generated type has the same name as the function but in pascal case 4 + file: ./test/squirrel_test.gleam 5 + test_name: generated_type_has_the_same_name_as_the_function_but_in_pascal_case_test 6 + --- 7 + /// A row you get from running the `query` query 8 + /// defined in `query.sql`. 9 + /// 10 + /// > 🐿️ This type definition was generated automatically using v-test of the 11 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 12 + /// 13 + pub type QueryRow { 14 + QueryRow(res: Bool) 15 + } 16 + 17 + /// Runs the `query` query 18 + /// defined in `query.sql`. 19 + /// 20 + /// > 🐿️ This function was generated automatically using v-test of 21 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 22 + /// 23 + pub fn query(db) { 24 + let decoder = 25 + decode.into({ 26 + use res <- decode.parameter 27 + QueryRow(res: res) 28 + }) 29 + |> decode.field(0, decode.bool) 30 + 31 + " 32 + select true as res 33 + " 34 + |> pgo.execute(db, [], decode.from(decoder, _)) 35 + }
+33
birdie_snapshots/int_decoding.accepted
··· 1 + --- 2 + version: 1.1.8 3 + title: int decoding 4 + file: ./test/squirrel_test.gleam 5 + test_name: int_decoding_test 6 + --- 7 + /// A row you get from running the `query` query 8 + /// defined in `query.sql`. 9 + /// 10 + /// > 🐿️ This type definition was generated automatically using v-test of the 11 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 12 + /// 13 + pub type QueryRow { 14 + QueryRow(res: Int) 15 + } 16 + 17 + /// Runs the `query` query 18 + /// defined in `query.sql`. 19 + /// 20 + /// > 🐿️ This function was generated automatically using v-test of 21 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 22 + /// 23 + pub fn query(db) { 24 + let decoder = 25 + decode.into({ 26 + use res <- decode.parameter 27 + QueryRow(res: res) 28 + }) 29 + |> decode.field(0, decode.int) 30 + 31 + "select 11 as res" 32 + |> pgo.execute(db, [], decode.from(decoder, _)) 33 + }
+33
birdie_snapshots/int_encoding.accepted
··· 1 + --- 2 + version: 1.1.8 3 + title: int encoding 4 + file: ./test/squirrel_test.gleam 5 + test_name: int_encoding_test 6 + --- 7 + /// A row you get from running the `query` query 8 + /// defined in `query.sql`. 9 + /// 10 + /// > 🐿️ This type definition was generated automatically using v-test of the 11 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 12 + /// 13 + pub type QueryRow { 14 + QueryRow(res: Bool) 15 + } 16 + 17 + /// Runs the `query` query 18 + /// defined in `query.sql`. 19 + /// 20 + /// > 🐿️ This function was generated automatically using v-test of 21 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 22 + /// 23 + pub fn query(db, arg_1) { 24 + let decoder = 25 + decode.into({ 26 + use res <- decode.parameter 27 + QueryRow(res: res) 28 + }) 29 + |> decode.field(0, decode.bool) 30 + 31 + "select true as res where $1 = 11" 32 + |> pgo.execute(db, [pgo.int(arg_1)], decode.from(decoder, _)) 33 + }
+33
birdie_snapshots/optional_decoding.accepted
··· 1 + --- 2 + version: 1.1.8 3 + title: optional decoding 4 + file: ./test/squirrel_test.gleam 5 + test_name: optional_decoding_test 6 + --- 7 + /// A row you get from running the `query` query 8 + /// defined in `query.sql`. 9 + /// 10 + /// > 🐿️ This type definition was generated automatically using v-test of the 11 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 12 + /// 13 + pub type QueryRow { 14 + QueryRow(acorns: Option(Int)) 15 + } 16 + 17 + /// Runs the `query` query 18 + /// defined in `query.sql`. 19 + /// 20 + /// > 🐿️ This function was generated automatically using v-test of 21 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 22 + /// 23 + pub fn query(db) { 24 + let decoder = 25 + decode.into({ 26 + use acorns <- decode.parameter 27 + QueryRow(acorns: acorns) 28 + }) 29 + |> decode.field(0, decode.optional(decode.int)) 30 + 31 + "select acorns from squirrel" 32 + |> pgo.execute(db, [], decode.from(decoder, _)) 33 + }
+35
birdie_snapshots/query_with_comment.accepted
··· 1 + --- 2 + version: 1.1.8 3 + title: query with comment 4 + file: ./test/squirrel_test.gleam 5 + test_name: query_with_comment_test 6 + --- 7 + /// A row you get from running the `query` query 8 + /// defined in `query.sql`. 9 + /// 10 + /// > 🐿️ This type definition was generated automatically using v-test of the 11 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 12 + /// 13 + pub type QueryRow { 14 + QueryRow(res: Bool) 15 + } 16 + 17 + /// This is a comment 18 + /// 19 + /// > 🐿️ This function was generated automatically using v-test of 20 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 21 + /// 22 + pub fn query(db) { 23 + let decoder = 24 + decode.into({ 25 + use res <- decode.parameter 26 + QueryRow(res: res) 27 + }) 28 + |> decode.field(0, decode.bool) 29 + 30 + " 31 + -- This is a comment 32 + select true as res 33 + " 34 + |> pgo.execute(db, [], decode.from(decoder, _)) 35 + }
+37
birdie_snapshots/query_with_multiline_comment.accepted
··· 1 + --- 2 + version: 1.1.8 3 + title: query with multiline comment 4 + file: ./test/squirrel_test.gleam 5 + test_name: query_with_multiline_comment_test 6 + --- 7 + /// A row you get from running the `query` query 8 + /// defined in `query.sql`. 9 + /// 10 + /// > 🐿️ This type definition was generated automatically using v-test of the 11 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 12 + /// 13 + pub type QueryRow { 14 + QueryRow(res: Bool) 15 + } 16 + 17 + /// This is a comment 18 + /// that goes over multiple lines! 19 + /// 20 + /// > 🐿️ This function was generated automatically using v-test of 21 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 22 + /// 23 + pub fn query(db) { 24 + let decoder = 25 + decode.into({ 26 + use res <- decode.parameter 27 + QueryRow(res: res) 28 + }) 29 + |> decode.field(0, decode.bool) 30 + 31 + " 32 + -- This is a comment 33 + -- that goes over multiple lines! 34 + select true as res 35 + " 36 + |> pgo.execute(db, [], decode.from(decoder, _)) 37 + }
+33
birdie_snapshots/string_decoding.accepted
··· 1 + --- 2 + version: 1.1.8 3 + title: string decoding 4 + file: ./test/squirrel_test.gleam 5 + test_name: string_decoding_test 6 + --- 7 + /// A row you get from running the `query` query 8 + /// defined in `query.sql`. 9 + /// 10 + /// > 🐿️ This type definition was generated automatically using v-test of the 11 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 12 + /// 13 + pub type QueryRow { 14 + QueryRow(res: String) 15 + } 16 + 17 + /// Runs the `query` query 18 + /// defined in `query.sql`. 19 + /// 20 + /// > 🐿️ This function was generated automatically using v-test of 21 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 22 + /// 23 + pub fn query(db) { 24 + let decoder = 25 + decode.into({ 26 + use res <- decode.parameter 27 + QueryRow(res: res) 28 + }) 29 + |> decode.field(0, decode.string) 30 + 31 + "select 'wibble' as res" 32 + |> pgo.execute(db, [], decode.from(decoder, _)) 33 + }
+33
birdie_snapshots/string_encoding.accepted
··· 1 + --- 2 + version: 1.1.8 3 + title: string encoding 4 + file: ./test/squirrel_test.gleam 5 + test_name: string_encoding_test 6 + --- 7 + /// A row you get from running the `query` query 8 + /// defined in `query.sql`. 9 + /// 10 + /// > 🐿️ This type definition was generated automatically using v-test of the 11 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 12 + /// 13 + pub type QueryRow { 14 + QueryRow(res: Bool) 15 + } 16 + 17 + /// Runs the `query` query 18 + /// defined in `query.sql`. 19 + /// 20 + /// > 🐿️ This function was generated automatically using v-test of 21 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 22 + /// 23 + pub fn query(db, arg_1) { 24 + let decoder = 25 + decode.into({ 26 + use res <- decode.parameter 27 + QueryRow(res: res) 28 + }) 29 + |> decode.field(0, decode.bool) 30 + 31 + "select true as res where $1 = 'wibble'" 32 + |> pgo.execute(db, [pgo.text(arg_1)], decode.from(decoder, _)) 33 + }
+25
gleam.toml
··· 1 + name = "squirrel" 2 + version = "1.0.0" 3 + description = "🐿️ Type safe SQL in Gleam" 4 + licences = ["Apache-2.0"] 5 + repository = { type = "github", user = "giacomocavalieri", repo = "squirrel" } 6 + 7 + [dependencies] 8 + gleam_stdlib = ">= 0.34.0 and < 2.0.0" 9 + simplifile = ">= 2.0.1 and < 3.0.0" 10 + eval = ">= 1.0.0 and < 2.0.0" 11 + gleam_json = ">= 1.0.0 and < 3.0.0" 12 + mug = ">= 1.1.0 and < 2.0.0" 13 + glam = ">= 2.0.1 and < 3.0.0" 14 + justin = ">= 1.0.1 and < 2.0.0" 15 + filepath = ">= 1.0.0 and < 2.0.0" 16 + gleam_community_ansi = ">= 1.4.0 and < 2.0.0" 17 + term_size = ">= 1.0.1 and < 2.0.0" 18 + argv = ">= 1.0.2 and < 2.0.0" 19 + envoy = ">= 1.0.1 and < 2.0.0" 20 + 21 + [dev-dependencies] 22 + gleeunit = ">= 1.0.0 and < 2.0.0" 23 + birdie = ">= 1.1.8 and < 2.0.0" 24 + temporary = ">= 1.0.0 and < 2.0.0" 25 + gleam_pgo = ">= 0.13.0 and < 1.0.0"
+53
manifest.toml
··· 1 + # This file was generated by Gleam 2 + # You typically do not need to edit this file 3 + 4 + packages = [ 5 + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 + { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, 7 + { name = "birdie", version = "1.1.8", build_tools = ["gleam"], requirements = ["argv", "filepath", "glance", "gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "justin", "rank", "simplifile", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "D225C0A3035FCD73A88402925A903AAD3567A1515C9EAE8364F11C17AD1805BB" }, 8 + { name = "envoy", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "CFAACCCFC47654F7E8B75E614746ED924C65BD08B1DE21101548AC314A8B6A41" }, 9 + { name = "eval", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "eval", source = "hex", outer_checksum = "264DAF4B49DF807F303CA4A4E4EBC012070429E40BE384C58FE094C4958F9BDA" }, 10 + { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, 11 + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, 12 + { name = "glam", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glam", source = "hex", outer_checksum = "66EC3BCD632E51EED029678F8DF419659C1E57B1A93D874C5131FE220DFAD2B2" }, 13 + { name = "glance", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "8F3314D27773B7C3B9FB58D8C02C634290422CE531988C0394FA0DF8676B964D" }, 14 + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, 15 + { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, 16 + { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" }, 17 + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, 18 + { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, 19 + { name = "gleam_pgo", version = "0.13.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "pgo"], otp_app = "gleam_pgo", source = "hex", outer_checksum = "6A1E7F3E717C077788254871E4EF4A8DFF58FEC07D7FA7C7702C2CCF66095AC8" }, 20 + { name = "gleam_stdlib", version = "0.39.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "2D7DE885A6EA7F1D5015D1698920C9BAF7241102836CE0C3837A4F160128A9C4" }, 21 + { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, 22 + { name = "glexer", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glexer", source = "hex", outer_checksum = "BD477AD657C2B637FEF75F2405FAEFFA533F277A74EF1A5E17B55B1178C228FB" }, 23 + { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 24 + { name = "mug", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "mug", source = "hex", outer_checksum = "85A61E67A7A8C25F4460D9CBEF1C09C68FC06ABBC6FF893B0A1F42AE01CBB546" }, 25 + { name = "opentelemetry_api", version = "1.3.0", build_tools = ["rebar3", "mix"], requirements = ["opentelemetry_semantic_conventions"], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "B9E5FF775FD064FA098DBA3C398490B77649A352B40B0B730A6B7DC0BDD68858" }, 26 + { name = "opentelemetry_semantic_conventions", version = "0.2.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_semantic_conventions", source = "hex", outer_checksum = "D61FA1F5639EE8668D74B527E6806E0503EFC55A42DB7B5F39939D84C07D6895" }, 27 + { name = "pg_types", version = "0.4.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "B02EFA785CAECECF9702C681C80A9CA12A39F9161A846CE17B01FB20AEEED7EB" }, 28 + { name = "pgo", version = "0.14.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "71016C22599936E042DC0012EE4589D24C71427D266292F775EBF201D97DF9C9" }, 29 + { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, 30 + { name = "simplifile", version = "2.0.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "5FFEBD0CAB39BDD343C3E1CCA6438B2848847DC170BA2386DF9D7064F34DF000" }, 31 + { name = "temporary", version = "1.0.0", build_tools = ["gleam"], requirements = ["envoy", "exception", "filepath", "gleam_crypto", "gleam_stdlib", "simplifile"], otp_app = "temporary", source = "hex", outer_checksum = "51C0FEF4D72CE7CA507BD188B21C1F00695B3D5B09D7DFE38240BFD3A8E1E9B3" }, 32 + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, 33 + { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, 34 + { name = "trie_again", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "5B19176F52B1BD98831B57FDC97BD1F88C8A403D6D8C63471407E78598E27184" }, 35 + ] 36 + 37 + [requirements] 38 + argv = { version = ">= 1.0.2 and < 2.0.0" } 39 + birdie = { version = ">= 1.1.8 and < 2.0.0" } 40 + envoy = { version = ">= 1.0.1 and < 2.0.0" } 41 + eval = { version = ">= 1.0.0 and < 2.0.0" } 42 + filepath = { version = ">= 1.0.0 and < 2.0.0" } 43 + glam = { version = ">= 2.0.1 and < 3.0.0" } 44 + gleam_community_ansi = { version = ">= 1.4.0 and < 2.0.0" } 45 + gleam_json = { version = ">= 1.0.0 and < 3.0.0" } 46 + gleam_pgo = { version = ">= 0.13.0 and < 1.0.0" } 47 + gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } 48 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 49 + justin = { version = ">= 1.0.1 and < 2.0.0" } 50 + mug = { version = ">= 1.1.0 and < 2.0.0" } 51 + simplifile = { version = ">= 2.0.1 and < 3.0.0" } 52 + temporary = { version = ">= 1.0.0 and < 2.0.0" } 53 + term_size = { version = ">= 1.0.1 and < 2.0.0" }
+250
src/squirrel.gleam
··· 1 + import envoy 2 + import filepath 3 + import glam/doc.{type Document} 4 + import gleam/bool 5 + import gleam/dict.{type Dict} 6 + import gleam/int 7 + import gleam/io 8 + import gleam/list 9 + import gleam/result 10 + import gleam/string 11 + import gleam_community/ansi 12 + import simplifile 13 + import squirrel/internal/database/postgres 14 + import squirrel/internal/error.{type Error, CannotWriteToFile} 15 + import squirrel/internal/query.{type TypedQuery} 16 + import term_size 17 + 18 + const squirrel_version = "v1.0.0" 19 + 20 + /// 🐿️ Performs code generation for your Gleam project. 21 + /// 22 + /// `squirrel` is not configurable and will discover the queries to generate 23 + /// code for by relying on a conventional project's structure: 24 + /// - `squirrel` first looks for all directories called `sql` under the `src` 25 + /// directory of your Gleam project, and reads all the `*.sql` files in there 26 + /// (in glob terms `src/**/sql/*.sql`). 27 + /// - Each `*.sql` file _must contain a single query_ as it is turned into a 28 + /// Gleam function with the same name. 29 + /// - All functions coming from the same `sql` directory will be grouped under 30 + /// a Gleam file called `sql.gleam` at the same level: given a `src/$PATH/sql` 31 + /// directory, you'll end up with a generated `src/$PATH/sql.gleam` file. 32 + /// 33 + /// > ⚠️ In order to generate type safe code, `squirrel` has to connect 34 + /// > to your Postgres database. To know what host, user, etc. values to use 35 + /// > when connecting, it will read your 36 + /// > [Postgres env variables.](https://www.postgresql.org/docs/current/libpq-envars.html) 37 + /// > 38 + /// > If a variable is not set it will go with the following defaults: 39 + /// > - `PGHOST`: `"localhost"` 40 + /// > - `PGPORT`: `5432` 41 + /// > - `PGUSER`: `"root"` 42 + /// > - `PGDATABASE`: `"database"` 43 + /// > - `PGPASSWORD`: `""` 44 + /// 45 + /// > ⚠️ The generated code relies on the 46 + /// > [`gleam_pgo`](https://hexdocs.pm/gleam_pgo/) and 47 + /// > [`decode`](https://hexdocs.pm/decode/) packages to work, so make sure to 48 + /// > add those as dependencies to your project. 49 + /// 50 + pub fn main() { 51 + walk("src") 52 + |> run(read_connection_options()) 53 + |> pretty_report 54 + |> io.println 55 + } 56 + 57 + fn read_connection_options() -> postgres.ConnectionOptions { 58 + let host = envoy.get("PGHOST") |> result.unwrap("localhost") 59 + let user = envoy.get("PGUSER") |> result.unwrap("root") 60 + let database = envoy.get("PGDATABASE") |> result.unwrap("database") 61 + let password = envoy.get("PGPASSWORD") |> result.unwrap("") 62 + let port = 63 + envoy.get("PGPORT") 64 + |> result.then(int.parse) 65 + |> result.unwrap(5432) 66 + 67 + postgres.ConnectionOptions( 68 + host: host, 69 + port: port, 70 + user: user, 71 + password: password, 72 + database: database, 73 + timeout: 1000, 74 + ) 75 + } 76 + 77 + /// Finds all `from/**/sql` directories and lists the full paths of the `*.sql` 78 + /// files inside each one. 79 + /// 80 + fn walk(from: String) -> Dict(String, List(String)) { 81 + case filepath.base_name(from) { 82 + "sql" -> { 83 + let assert Ok(files) = simplifile.read_directory(from) 84 + let files = { 85 + use file <- list.filter_map(files) 86 + use extension <- result.try(filepath.extension(file)) 87 + use <- bool.guard(when: extension != "sql", return: Error(Nil)) 88 + let file_name = filepath.join(from, file) 89 + case simplifile.is_file(file_name) { 90 + Ok(True) -> Ok(file_name) 91 + Ok(False) | Error(_) -> Error(Nil) 92 + } 93 + } 94 + dict.from_list([#(from, files)]) 95 + } 96 + 97 + _ -> { 98 + let assert Ok(files) = simplifile.read_directory(from) 99 + let directories = { 100 + use file <- list.filter_map(files) 101 + let file_name = filepath.join(from, file) 102 + case simplifile.is_directory(file_name) { 103 + Ok(True) -> Ok(file_name) 104 + Ok(False) | Error(_) -> Error(Nil) 105 + } 106 + } 107 + 108 + list.map(directories, walk) 109 + |> list.fold(from: dict.new(), with: dict.merge) 110 + } 111 + } 112 + } 113 + 114 + /// Given a dict of directories and their `*.sql` files, performs code 115 + /// generation for each one, bundling all `*.sql` files under the same directory 116 + /// into a single Gleam module. 117 + /// 118 + fn run( 119 + directories: Dict(String, List(String)), 120 + connection: postgres.ConnectionOptions, 121 + ) -> Dict(String, #(Int, List(Error))) { 122 + use directory, files <- dict.map_values(directories) 123 + 124 + let #(queries, errors) = 125 + list.map(files, query.from_file) 126 + |> result.partition 127 + 128 + let #(queries, errors) = case postgres.main(queries, connection) { 129 + Error(error) -> #([], [error, ..errors]) 130 + Ok(#(queries, type_errors)) -> #(queries, list.append(errors, type_errors)) 131 + } 132 + 133 + let output_file = 134 + filepath.directory_name(directory) 135 + |> filepath.join("sql.gleam") 136 + 137 + case write_queries(queries, to: output_file) { 138 + Ok(n) -> #(n, errors) 139 + Error(error) -> #(list.length(queries), [error, ..errors]) 140 + } 141 + } 142 + 143 + fn write_queries( 144 + queries: List(TypedQuery), 145 + to file: String, 146 + ) -> Result(Int, Error) { 147 + use <- bool.guard(when: queries == [], return: Ok(0)) 148 + 149 + let directory = filepath.directory_name(file) 150 + let _ = simplifile.create_directory_all(directory) 151 + 152 + // We need the top level imports. 153 + let imports = "import gleam/pgo\nimport decode\n" 154 + let #(count, code) = { 155 + use #(count, code), query <- list.fold(queries, #(0, imports)) 156 + #(count + 1, code <> "\n" <> query.generate_code(squirrel_version, query)) 157 + } 158 + 159 + let try_write = 160 + simplifile.write(code, to: file) 161 + |> result.map_error(CannotWriteToFile(file, _)) 162 + 163 + use _ <- result.try(try_write) 164 + Ok(count) 165 + } 166 + 167 + // --- PRETTY REPORT PRINTING -------------------------------------------------- 168 + 169 + fn pretty_report(dirs: Dict(String, #(Int, List(Error)))) -> String { 170 + let width = term_size.columns() |> result.unwrap(80) 171 + let #(ok, errors) = { 172 + use #(all_ok, all_errors), _, result <- dict.fold(dirs, #(0, [])) 173 + let #(ok, errors) = result 174 + #(all_ok + ok, errors |> list.append(all_errors)) 175 + } 176 + let errors_doc = 177 + list.map(errors, error.to_doc) 178 + |> doc.join(with: doc.lines(2)) 179 + 180 + case ok, errors { 181 + 0, [_, ..] -> doc.to_string(errors_doc, width) 182 + 0, [] -> 183 + text_with_header( 184 + "🐿️ ", 185 + "I couldn't find any `*.sql` file to generate queries from", 186 + ) 187 + |> doc.to_string(width) 188 + |> ansi.yellow 189 + 190 + n, [] -> 191 + text_with_header( 192 + "🐿️ ", 193 + "Generated " 194 + <> int.to_string(n) 195 + <> " " 196 + <> pluralise(n, "query", "queries"), 197 + ) 198 + |> doc.to_string(width) 199 + |> ansi.green 200 + |> string.append("\n") 201 + |> string.append( 202 + text_with_header( 203 + "🥜 ", 204 + "Don't forget to run `gleam add decode gleam_pgo` if you haven't yet!", 205 + ) 206 + |> doc.to_string(width) 207 + |> ansi.cyan, 208 + ) 209 + 210 + n, [_, ..] -> 211 + [ 212 + errors_doc, 213 + doc.lines(2), 214 + text_with_header( 215 + "🥜 ", 216 + "I could still generate " 217 + <> int.to_string(n) 218 + <> " " 219 + <> pluralise(n, "query", "queries"), 220 + ), 221 + ] 222 + |> doc.concat 223 + |> doc.to_string(width) 224 + } 225 + } 226 + 227 + fn text_with_header(header: String, text: String) { 228 + [ 229 + doc.from_string(header), 230 + flexible_string(text) 231 + |> doc.nest(by: string.length(header)), 232 + ] 233 + |> doc.concat 234 + |> doc.group 235 + } 236 + 237 + fn pluralise(count: Int, singular: String, plural: String) -> String { 238 + case count { 239 + 1 -> singular 240 + _ -> plural 241 + } 242 + } 243 + 244 + fn flexible_string(string: String) -> Document { 245 + string.split(string, on: "\n") 246 + |> list.flat_map(string.split(_, on: " ")) 247 + |> list.map(doc.from_string) 248 + |> doc.join(with: doc.flex_space) 249 + |> doc.group 250 + }
+899
src/squirrel/internal/database/postgres.gleam
··· 1 + //// In this module lies the core of `squirrel`. 2 + //// It exposes a single public function called `main` that is used to turn a 3 + //// list of untyped queries into typed ones. 4 + //// 5 + //// To do so, `squirrel` will try to connect to a database, have it parse the 6 + //// queries and reply with the types it could infer. 7 + //// Then it's as simple as (not that simple in practice 😆) converting the 8 + //// Postgres types into Gleam types. 9 + //// 10 + //// > 💡 I tried to do my best to comment everything as much as possible and 11 + //// > make things easy to read. 12 + //// > If you feel something is poorly commented or hard to understand, then 13 + //// > that is a bug! Please do reach out, I'd love to hear your feedback. 14 + //// 15 + 16 + import eval 17 + import gleam/bit_array 18 + import gleam/bool 19 + import gleam/dict.{type Dict} 20 + import gleam/dynamic.{type DecodeErrors, type Dynamic} as d 21 + import gleam/int 22 + import gleam/json 23 + import gleam/list 24 + import gleam/option.{type Option, None, Some} 25 + import gleam/result 26 + import gleam/set.{type Set} 27 + import gleam/string 28 + import squirrel/internal/database/postgres_protocol as pg 29 + import squirrel/internal/error.{ 30 + type Error, type Pointer, type ValueIdentifierError, ByteIndex, 31 + CannotParseQuery, PgCannotAuthenticate, PgCannotDecodeReceivedMessage, 32 + PgCannotDescribeQuery, PgCannotReceiveMessage, PgCannotSendMessage, Pointer, 33 + QueryHasInvalidColumn, QueryHasUnsupportedType, 34 + } 35 + import squirrel/internal/eval_extra 36 + import squirrel/internal/gleam 37 + import squirrel/internal/query.{ 38 + type TypedQuery, type UntypedQuery, TypedQuery, UntypedQuery, 39 + } 40 + 41 + const find_postgres_type_query = " 42 + select 43 + -- The name of the type or, if the type is an array, the name of its 44 + -- elements' type. 45 + case 46 + when elem.typname is null then type.typname 47 + else elem.typname 48 + end as type, 49 + -- Tells us how to interpret the firs column: if this is true then the first 50 + -- column is the type of the elements of the array type. 51 + -- Otherwise it means we've found a base type. 52 + case 53 + when elem.typname is null then false 54 + else true 55 + end as is_array 56 + from 57 + pg_type as type 58 + left join pg_type as elem on type.typelem = elem.oid 59 + where 60 + type.oid = $1 61 + " 62 + 63 + const find_column_nullability_query = " 64 + select 65 + -- Whether the column has a not-null constraint. 66 + attnotnull 67 + from 68 + pg_attribute 69 + where 70 + -- The oid of the table the column comes from. 71 + attrelid = $1 72 + -- The index of the column we're looking for. 73 + and attnum = $2 74 + " 75 + 76 + // --- TYPES ------------------------------------------------------------------- 77 + 78 + /// A Postgres type. 79 + /// 80 + /// > ⚠️Postgres has loads of types and this might not cover the more exotic 81 + /// > ones but for now it feels more than enough. 82 + /// 83 + type PgType { 84 + /// A base type, like `integer`, `text`, `char`, ... 85 + /// 86 + PBase(name: String) 87 + 88 + /// An array type like `int[]`, `text[]`, ... 89 + /// 90 + PArray(inner: PgType) 91 + 92 + /// A type that could also be `NULL`, this is particularly common for columns 93 + /// that do not have a `not null` constraint; or for those coming from partial 94 + /// joins. 95 + /// 96 + POption(inner: PgType) 97 + } 98 + 99 + /// The context in which all database-related actions will take place. 100 + /// 101 + type Context { 102 + Context( 103 + /// A connection to the database. Squirrel does nothing fancy and just uses 104 + /// a single connection to run all the queries. 105 + /// 106 + db: pg.Connection, 107 + /// A cache from `oid` to corresponding Gleam type. 108 + /// We use this to avoid having to reach to the database every time we need 109 + /// to infer a type. 110 + /// 111 + /// > 💡 An oid is an integer identifier that is used by Postgres to 112 + /// > uniquely identify types (and a lot of other various objects, see 113 + /// > [this documentation page](https://www.postgresql.org/docs/current/datatype-oid.html)). 114 + /// 115 + gleam_types: Dict(Int, gleam.Type), 116 + /// A cache from table `oid` and column index to its nullability. 117 + /// We use this to avoid having to reach to the database every time we need 118 + /// to type a column. 119 + /// 120 + column_nullability: Dict(#(Int, Int), Nullability), 121 + ) 122 + } 123 + 124 + /// Information about a column's nullability. 125 + /// If a column has a `not null` constraint then it will be `NotNullable`, in 126 + /// all other cases it will be `Nullable`. 127 + /// 128 + /// > ⚠️ A column with a `not null` constraint might still be considered 129 + /// > nullable if it comes from a left/right join! 130 + /// 131 + type Nullability { 132 + Nullable 133 + NotNullable 134 + } 135 + 136 + /// A query plan produced by Postgres when we ask it to `explain` a query. 137 + /// 138 + type Plan { 139 + Plan( 140 + join_type: Option(JoinType), 141 + parent_relation: Option(ParentRelation), 142 + output: Option(List(String)), 143 + plans: Option(List(Plan)), 144 + ) 145 + } 146 + 147 + type JoinType { 148 + Full 149 + Left 150 + Right 151 + Other 152 + } 153 + 154 + type ParentRelation { 155 + Inner 156 + NotInner 157 + } 158 + 159 + /// This is the type of a database-related action. 160 + /// In order to be carried out it needs to have access to the database `Context` 161 + /// and could fail with an `Error`. 162 + /// 163 + type Db(a) = 164 + eval.Eval(a, Error, Context) 165 + 166 + /// The options used to establish a connection to the Postgres database. 167 + /// 168 + pub type ConnectionOptions { 169 + ConnectionOptions( 170 + host: String, 171 + port: Int, 172 + user: String, 173 + password: String, 174 + database: String, 175 + timeout: Int, 176 + ) 177 + } 178 + 179 + // --- POSTGRES TO GLEAM TYPES CONVERSIONS ------------------------------------- 180 + 181 + /// This function turns a Postgres type into a Gleam one, returning an error 182 + /// with the type name if it is not currently supported. 183 + /// 184 + fn pg_to_gleam_type(type_: PgType) -> Result(gleam.Type, String) { 185 + case type_ { 186 + PArray(inner: inner) -> 187 + pg_to_gleam_type(inner) 188 + |> result.map(gleam.List) 189 + |> result.map_error(fn(inner) { inner <> "[]" }) 190 + 191 + POption(inner: inner) -> 192 + pg_to_gleam_type(inner) 193 + |> result.map(gleam.Option) 194 + |> result.map_error(fn(inner) { inner <> "?" }) 195 + 196 + PBase(name: name) -> 197 + case name { 198 + "bool" -> Ok(gleam.Bool) 199 + "text" | "char" -> Ok(gleam.String) 200 + "float4" | "float8" | "numeric" -> Ok(gleam.Float) 201 + "int2" | "int4" | "int8" -> Ok(gleam.Int) 202 + _ -> Error(name) 203 + } 204 + } 205 + } 206 + 207 + // --- CLI ENTRY POINT --------------------------------------------------------- 208 + 209 + /// Connects to a Postgres database (using the given options) and types a list 210 + /// of queries. 211 + /// 212 + /// This might fail with an `Error` if a database connection cannot be 213 + /// established, making it impossible to type any of the queries. 214 + /// Otherwise, it will try typing all the queries, retuning a list of typed ones 215 + /// and a list of possible errors for the ones it couldn't type. 216 + /// 217 + pub fn main( 218 + queries: List(UntypedQuery), 219 + connection: ConnectionOptions, 220 + ) -> Result(#(List(TypedQuery), List(Error)), Error) { 221 + let context = 222 + Context( 223 + db: pg.connect(connection.host, connection.port, connection.timeout), 224 + gleam_types: dict.new(), 225 + column_nullability: dict.new(), 226 + ) 227 + 228 + // Once the server has confirmed that it is ready to accept query requests we 229 + // can start gathering information about all the different queries. 230 + // After each one we need to make sure the server is ready to go on with the 231 + // next one. 232 + // 233 + // https://www.postgresql.org/docs/current/protocol-flow.html#PROTOCOL-FLOW-EXT-QUERY 234 + // 235 + let #(context, connection) = eval.step(authenticate(connection), context) 236 + case connection { 237 + Error(error) -> Error(error) 238 + // After successfully authenticating we can try and type all the queries. 239 + Ok(_) -> 240 + list.map(queries, infer_types) 241 + |> eval_extra.run_all(context) 242 + |> result.partition 243 + |> Ok 244 + } 245 + } 246 + 247 + fn authenticate(connection: ConnectionOptions) -> Db(Nil) { 248 + let params = [#("user", connection.user), #("database", connection.database)] 249 + use _ <- eval.try(send(pg.FeStartupMessage(params))) 250 + 251 + use msg <- eval.try(receive()) 252 + use _ <- eval.try(case msg { 253 + pg.BeAuthenticationOk -> eval.return(Nil) 254 + _ -> unexpected_message(PgCannotAuthenticate, "AuthenticationOk", msg) 255 + }) 256 + 257 + use _ <- eval.try(wait_until_ready()) 258 + eval.return(Nil) 259 + } 260 + 261 + /// Returns type information about a query. 262 + /// 263 + fn infer_types(query: UntypedQuery) -> Db(TypedQuery) { 264 + // Postgres doesn't give us 100% accurate data regardin a query's type. 265 + // We'll need to perform a couple of database interrogations and do some 266 + // guessing. 267 + // 268 + // The big picture idea is the following: 269 + // - We ask the server to prepare the query. 270 + // - Postgres will reply with type information about the returned rows and the 271 + // query's parameters. 272 + let action = parameters_and_returns(query) 273 + use #(parameters, returns) <- eval.try(action) 274 + // - The parameters' types are just OIDs so we need to interrogate the 275 + // database to learn the actual corresponding Gleam type. 276 + use parameters <- eval.try(resolve_parameters(query, parameters)) 277 + // - The returns' types are just OIDs so we have to do the same for those. 278 + // - Here comes the tricky part: we can't know if a column is nullable just 279 + // from the server's previous answer. 280 + // - For columns coming from a database table we'll look it up and see if 281 + // the column is nullable or not 282 + // - But this is not enough! If a returned column comes from a left/right 283 + // join it will be nullable even if it is not in the original table. 284 + // To work around this we'll have to inspect the query plan. 285 + use plan <- eval.try(query_plan(query, list.length(parameters))) 286 + let nullables = nullables_from_plan(plan) 287 + use returns <- eval.try(resolve_returns(query, returns, nullables)) 288 + 289 + query 290 + |> query.add_types(parameters, returns) 291 + |> eval.return 292 + } 293 + 294 + fn parameters_and_returns(query: UntypedQuery) -> Db(_) { 295 + // We need to send three messages: 296 + // - `Parse` with the query to parse 297 + // - `Describe` to ask the server to reply with a description of the query's 298 + // return types and parameter types. This is what we need to understand the 299 + // SQL inferred types and generate the corresponding Gleam types. 300 + // - `Sync` to ask the server to immediately reply with the results of parsing 301 + // 302 + use _ <- eval.try( 303 + send_all([ 304 + pg.FeParse("", query.content, []), 305 + pg.FeDescribe(pg.PreparedStatement, ""), 306 + pg.FeSync, 307 + ]), 308 + ) 309 + 310 + // Error builder used in the following steps in case the message sequence 311 + // doesn't go as planned. 312 + let cannot_describe = fn(expected, got) { 313 + PgCannotDescribeQuery( 314 + file: query.file, 315 + query_name: gleam.identifier_to_string(query.name), 316 + expected: expected, 317 + got: got, 318 + ) 319 + } 320 + 321 + use msg <- eval.try(receive()) 322 + case msg { 323 + pg.BeErrorResponse(errors) -> 324 + eval.throw(error_fields_to_parse_error(query, errors)) 325 + pg.BeParseComplete -> { 326 + use msg <- eval.try(receive()) 327 + use parameters <- eval.try(case msg { 328 + pg.BeParameterDescription(parameters) -> eval.return(parameters) 329 + _ -> unexpected_message(cannot_describe, "ParameterDescription", msg) 330 + }) 331 + 332 + use msg <- eval.try(receive()) 333 + use rows <- eval.try(case msg { 334 + pg.BeRowDescriptions(rows) -> eval.return(rows) 335 + _ -> unexpected_message(cannot_describe, "RowDescriptions", msg) 336 + }) 337 + 338 + use msg <- eval.try(receive()) 339 + use _ <- eval.try(case msg { 340 + pg.BeReadyForQuery(_) -> eval.return(Nil) 341 + _ -> unexpected_message(cannot_describe, "ReadyForQuery", msg) 342 + }) 343 + 344 + eval.return(#(parameters, rows)) 345 + } 346 + _ -> 347 + unexpected_message(cannot_describe, "ParseComplete or ErrorResponse", msg) 348 + } 349 + } 350 + 351 + /// Given an untyped query and the error fields we got back from the database in 352 + /// case it couldn't be parsed, produces an appropriate `Error`. 353 + /// 354 + fn error_fields_to_parse_error( 355 + query: UntypedQuery, 356 + errors: Set(pg.ErrorOrNoticeField), 357 + ) -> Error { 358 + // We first look for the relevant errors in the set of errors the database 359 + // returned. This way we can attach additional information explaining why the 360 + // query failed. 361 + let #(error_code, message, position, hint) = { 362 + use acc, error_field <- set.fold(errors, from: #(None, None, None, None)) 363 + let #(code, message, position, hint) = acc 364 + case error_field { 365 + pg.Code(code) -> #(Some(code), message, position, hint) 366 + pg.Message(message) -> #(code, Some(message), position, hint) 367 + pg.Hint(hint) -> #(code, message, position, Some(hint)) 368 + pg.Position(position) -> 369 + case int.parse(position) { 370 + Ok(position) -> #(code, message, Some(position), hint) 371 + Error(_) -> acc 372 + } 373 + _ -> acc 374 + } 375 + } 376 + 377 + // If we found both a `Message` and `Position` error messages then we can turn 378 + // those into a pointer that will be shown in the error message. 379 + let pointer = case message, position { 380 + Some(message), Some(position) -> 381 + Some(Pointer(point_to: ByteIndex(position), message: message)) 382 + _, _ -> None 383 + } 384 + 385 + cannot_parse_error(query, error_code, hint, pointer) 386 + } 387 + 388 + fn resolve_parameters( 389 + query: UntypedQuery, 390 + parameters: List(Int), 391 + ) -> Db(List(gleam.Type)) { 392 + use oid <- eval_extra.try_map(parameters) 393 + find_gleam_type(query, oid) 394 + } 395 + 396 + /// Looks up a type with the given id in the Postgres registry. 397 + /// 398 + /// > ⚠️ This function assumes that the oid is present in the database and 399 + /// > will crash otherwise. This should only be called with oids coming from 400 + /// > a database interrogation. 401 + /// 402 + fn find_gleam_type(query: UntypedQuery, oid: Int) -> Db(gleam.Type) { 403 + // We first look for the Gleam type corresponding to this id in the cache to 404 + // avoid hammering the db with needless queries. 405 + use <- with_cached_gleam_type(oid) 406 + 407 + // The only parameter to this query is the oid of the type to lookup: 408 + // that's a 32bit integer (its oid needed to prepare the query is 23). 409 + let params = [pg.Parameter(<<oid:32>>)] 410 + let run_query = find_postgres_type_query |> run_query(params, [23]) 411 + use res <- eval.try(run_query) 412 + 413 + // We know the output must only contain two values: the name and a boolean to 414 + // check wether it is an array or not. 415 + // It's safe to assert because this query is hard coded in our code and the 416 + // output shape cannot change without us changing that query. 417 + let assert [name, is_array] = res 418 + 419 + // We then decode the bitarrays we got as a result: 420 + // - `name` is just a string 421 + // - `is_array` is a pg boolean 422 + let assert Ok(name) = bit_array.to_string(name) 423 + let type_ = case bit_array_to_bool(is_array) { 424 + True -> PArray(PBase(name)) 425 + False -> PBase(name) 426 + } 427 + 428 + pg_to_gleam_type(type_) 429 + |> result.map_error(unsupported_type_error(query, _)) 430 + |> eval.from_result 431 + } 432 + 433 + /// Returns the query plan for a given query. 434 + /// `parameters` is the number of parameter placeholders in the query. 435 + /// 436 + fn query_plan(query: UntypedQuery, parameters: Int) -> Db(Plan) { 437 + // We ask postgres to give us the query plan. To do that we need to fill in 438 + // all the possible holes in the user supplied query with null values; 439 + // otherwise, the server would complain that it has arguments that are not 440 + // bound. 441 + let query = "explain (format json, verbose) " <> query.content 442 + let params = list.repeat(pg.Null, parameters) 443 + let run_query = run_query(query, params, []) 444 + use res <- eval.try(run_query) 445 + 446 + // We know the output will only contain a single row that is the json string 447 + // containing the query plan. 448 + let assert [plan] = res 449 + let assert Ok([plan, ..]) = json.decode_bits(plan, json_plans_decoder) 450 + eval.return(plan) 451 + } 452 + 453 + /// Given a query plan, returns a set with the indices of the output columns 454 + /// that can contain null values. 455 + /// 456 + fn nullables_from_plan(plan: Plan) -> Set(Int) { 457 + let outputs = case plan.output { 458 + Some(outputs) -> list.index_fold(outputs, dict.new(), dict.insert) 459 + None -> dict.new() 460 + } 461 + 462 + do_nullables_from_plan(plan, outputs, set.new()) 463 + } 464 + 465 + fn do_nullables_from_plan( 466 + plan: Plan, 467 + // A dict from "column name" to its position in the query output. 468 + query_outputs: Dict(String, Int), 469 + nullables: Set(Int), 470 + ) -> Set(Int) { 471 + let nullables = case plan.output, plan.join_type, plan.parent_relation { 472 + // - All the outputs of a full join must be marked as nullable 473 + // - All the outputs of an inner half join must be marked as nullable 474 + Some(outputs), Some(Full), _ | Some(outputs), _, Some(Inner) -> { 475 + use nullables, output <- list.fold(outputs, from: nullables) 476 + case dict.get(query_outputs, output) { 477 + Ok(i) -> set.insert(nullables, i) 478 + Error(_) -> nullables 479 + } 480 + } 481 + _, _, _ -> nullables 482 + } 483 + 484 + case plan.plans, plan.join_type { 485 + // If this is an inner half join we keep inspecting the children to mark 486 + // their outputs as nullable. 487 + Some(plans), Some(Left) | Some(plans), Some(Right) -> { 488 + use nullables, plan <- list.fold(plans, from: nullables) 489 + do_nullables_from_plan(plan, query_outputs, nullables) 490 + } 491 + _, _ -> nullables 492 + } 493 + } 494 + 495 + /// Given a list of `RowDescriptionFields` it turns those into Gleam fields with 496 + /// a name and a type. 497 + /// 498 + /// This also uses nullability info coming from the query plan to figure out if 499 + /// a column can be nullable or not: 500 + /// - If the column name ends with `!` it will be forced to be not nullable 501 + /// - If the column name ends with `?` it will be forced to be nullable 502 + /// - If the column appears in the `nullables` set then it will be nullable 503 + /// - Othwerwise we look for its metadata in the database and if it has a 504 + /// not-null constraint it will be not nullable; otherwise it will be nullable 505 + /// 506 + fn resolve_returns( 507 + query: UntypedQuery, 508 + returns: List(pg.RowDescriptionField), 509 + nullables: Set(Int), 510 + ) -> Db(List(gleam.Field)) { 511 + use column, i <- eval_extra.try_index_map(returns) 512 + let pg.RowDescriptionField( 513 + data_type_oid: type_oid, 514 + attr_number: column, 515 + table_oid: table, 516 + name: name, 517 + .., 518 + ) = column 519 + 520 + use type_ <- eval.try(find_gleam_type(query, type_oid)) 521 + 522 + let ends_with_exclamation_mark = string.ends_with(name, "!") 523 + let ends_with_question_mark = string.ends_with(name, "?") 524 + use nullability <- eval.try(case ends_with_exclamation_mark { 525 + True -> eval.return(NotNullable) 526 + False -> 527 + case ends_with_question_mark { 528 + True -> eval.return(Nullable) 529 + False -> 530 + case set.contains(nullables, i) { 531 + True -> eval.return(Nullable) 532 + False -> column_nullability(table: table, column: column) 533 + } 534 + } 535 + }) 536 + 537 + let type_ = case nullability { 538 + Nullable -> gleam.Option(type_) 539 + NotNullable -> type_ 540 + } 541 + 542 + let try_convert_name = 543 + // If the name ends with a `?` or `!` we don't want that to be included in 544 + // the gleam name or it would be invalid! 545 + case ends_with_exclamation_mark || ends_with_question_mark { 546 + True -> string.drop_right(name, 1) 547 + False -> name 548 + } 549 + |> gleam.identifier 550 + |> result.map_error(invalid_column_error(query, name, _)) 551 + 552 + use name <- eval.try(eval.from_result(try_convert_name)) 553 + 554 + let field = gleam.Field(label: name, type_: type_) 555 + eval.return(field) 556 + } 557 + 558 + fn column_nullability(table table: Int, column column: Int) -> Db(Nullability) { 559 + // We first check if the table+column is cached to avoid making redundant 560 + // queries to the database. 561 + use <- with_cached_column(table: table, column: column) 562 + 563 + // If the table oid is 0 that means the column doesn't come from any table so 564 + // we just assume it's not nullable. 565 + use <- bool.guard(when: table == 0, return: eval.return(NotNullable)) 566 + 567 + // This query has 2 parameters: 568 + // - the oid of the table (a 32bit integer, oid is 23) 569 + // - the index of the column (a 32 bit integer, oid is 23) 570 + let params = [pg.Parameter(<<table:32>>), pg.Parameter(<<column:32>>)] 571 + let run_query = find_column_nullability_query |> run_query(params, [23, 23]) 572 + use res <- eval.try(run_query) 573 + 574 + // We know the output will only have only one column, that is the boolean 575 + // telling us if the column has a not-null constraint. 576 + let assert [has_non_null_constraint] = res 577 + case bit_array_to_bool(has_non_null_constraint) { 578 + True -> eval.return(NotNullable) 579 + False -> eval.return(Nullable) 580 + } 581 + } 582 + 583 + // --- DB ACTION HELPERS ------------------------------------------------------- 584 + 585 + /// Runs a query against the database. 586 + /// - `parameters` are the parameter values that need to be supplied in place of 587 + /// the query placeholders 588 + /// - `parameters_object_ids` are the oids describing the type of each 589 + /// parameter. 590 + /// 591 + /// > ⚠️ The `parameters_objects_ids` should have the same length of 592 + /// > `parameters` and correctly describe each parameter's type. This function 593 + /// > makes no attempt whatsoever to verify this assumption is correct so be 594 + /// > careful! 595 + /// 596 + /// > ⚠️ This function makes the assumption that the query will only return one 597 + /// > single row. This is totally fine here because we only use this to run 598 + /// > specific hard coded queries that are guaranteed to return a single row. 599 + /// 600 + fn run_query( 601 + query: String, 602 + parameters: List(pg.ParameterValue), 603 + parameters_object_ids: List(Int), 604 + ) -> Db(List(BitArray)) { 605 + // The message exchange to run a query works as follow: 606 + // - `Parse` we ask the server to parse the query, we do not give it a name 607 + // - `Bind` we bind the query to the unnamed portal so that it is ready to 608 + // be executed. 609 + // - `Execute` we ask the server to run the unnamed portal and return all 610 + // the rows (0 means return all rows). 611 + // - `Close` we ask to close the unnamed query and the unnamed portal to free 612 + // their resources. 613 + // - `Sync` this acts as a synchronization point that needs to go at the end 614 + // of the sequence before the next one. 615 + // 616 + // As you can see in the receiving part, each message we send corresponds to a 617 + // specific answer from the server: 618 + // - `ParseComplete` the query was parsed correctly 619 + // - `BindComplete` the query was bound to a portal 620 + // - `MessageDataRow` the result(s) coming from the query execution 621 + // - `CommandComplete` when the result coming from the query is over 622 + // - `CloseComplete` the portal/statement was closed 623 + // - `ReadyForQuery` final reply to the sync message signalling we can go on 624 + // making new requests 625 + use _ <- eval.try( 626 + send_all([ 627 + pg.FeParse("", query, parameters_object_ids), 628 + pg.FeBind( 629 + portal: "", 630 + statement_name: "", 631 + parameter_format: pg.FormatAll(pg.Binary), 632 + parameters:, 633 + result_format: pg.FormatAll(pg.Binary), 634 + ), 635 + pg.FeExecute("", 0), 636 + pg.FeClose(pg.PreparedStatement, ""), 637 + pg.FeClose(pg.Portal, ""), 638 + pg.FeSync, 639 + ]), 640 + ) 641 + 642 + use msg <- eval.try(receive()) 643 + let assert pg.BeParseComplete = msg 644 + use msg <- eval.try(receive()) 645 + let assert pg.BeBindComplete = msg 646 + use msg <- eval.try(receive()) 647 + let assert pg.BeMessageDataRow(res) = msg 648 + use msg <- eval.try(receive()) 649 + let assert pg.BeCommandComplete(_, _) = msg 650 + use msg <- eval.try(receive()) 651 + let assert pg.BeCloseComplete = msg 652 + use msg <- eval.try(receive()) 653 + let assert pg.BeCloseComplete = msg 654 + use msg <- eval.try(receive()) 655 + let assert pg.BeReadyForQuery(_) = msg 656 + eval.return(res) 657 + } 658 + 659 + /// Receive a single message from the database. 660 + /// 661 + fn receive() -> Db(pg.BackendMessage) { 662 + use Context(db: db, ..) as context <- eval.from 663 + case pg.receive(db) { 664 + Ok(#(db, msg)) -> #(Context(..context, db: db), Ok(msg)) 665 + Error(pg.ReadDecodeError(error)) -> #( 666 + context, 667 + Error(PgCannotDecodeReceivedMessage(string.inspect(error))), 668 + ) 669 + Error(pg.SocketError(error)) -> #( 670 + context, 671 + Error(PgCannotReceiveMessage(string.inspect(error))), 672 + ) 673 + } 674 + } 675 + 676 + /// Send a single message to the database. 677 + /// 678 + fn send(message message: pg.FrontendMessage) -> Db(Nil) { 679 + use Context(db: db, ..) as context <- eval.from 680 + 681 + let result = 682 + message 683 + |> pg.encode_frontend_message 684 + |> pg.send(db, _) 685 + 686 + let #(db, result) = case result { 687 + Ok(db) -> #(db, Ok(Nil)) 688 + Error(error) -> #(db, Error(PgCannotSendMessage(string.inspect(error)))) 689 + } 690 + 691 + #(Context(..context, db: db), result) 692 + } 693 + 694 + /// Send many messages, one after the other. 695 + /// 696 + fn send_all(messages messages: List(pg.FrontendMessage)) -> Db(Nil) { 697 + use acc, msg <- eval_extra.try_fold(messages, from: Nil) 698 + use _ <- eval.try(send(msg)) 699 + eval.return(acc) 700 + } 701 + 702 + /// Start receiving and discarding messages until a `ReadyForQuery` message is 703 + /// received. 704 + /// 705 + fn wait_until_ready() -> Db(Nil) { 706 + use _ <- eval.try(send(pg.FeFlush)) 707 + do_wait_until_ready() 708 + } 709 + 710 + fn do_wait_until_ready() -> Db(Nil) { 711 + use msg <- eval.try(receive()) 712 + case msg { 713 + pg.BeReadyForQuery(_) -> eval.return(Nil) 714 + _ -> do_wait_until_ready() 715 + } 716 + } 717 + 718 + /// Throws an error built from an expected message and an unexpected message 719 + /// that is turned into a string. 720 + /// 721 + fn unexpected_message( 722 + builder: fn(String, String) -> Error, 723 + expected expected: String, 724 + got got: pg.BackendMessage, 725 + ) { 726 + builder(expected, string.inspect(got)) |> eval.throw 727 + } 728 + 729 + /// Looks up for a type with the given id in a global cache. 730 + /// If the type is present it immediately returns it. 731 + /// Otherwise it runs the database action to fetch it and then caches it to be 732 + /// reused later. 733 + /// 734 + fn with_cached_gleam_type( 735 + lookup oid: Int, 736 + otherwise do: fn() -> Db(gleam.Type), 737 + ) -> Db(gleam.Type) { 738 + use context: Context <- eval.from 739 + case dict.get(context.gleam_types, oid) { 740 + Ok(type_) -> #(context, Ok(type_)) 741 + Error(_) -> 742 + case eval.step(do(), context) { 743 + #(_, Error(_)) as result -> result 744 + #(Context(gleam_types: gleam_types, ..) as context, Ok(type_)) -> { 745 + let gleam_types = dict.insert(gleam_types, oid, type_) 746 + let new_context = Context(..context, gleam_types: gleam_types) 747 + #(new_context, Ok(type_)) 748 + } 749 + } 750 + } 751 + } 752 + 753 + /// Looks up for the nullability of table's column. 754 + /// If the nullability is cached it is immediately returns it. 755 + /// Otherwise it runs the database action to fetch it and then caches it to be 756 + /// reused later. 757 + /// 758 + fn with_cached_column( 759 + table table_oid: Int, 760 + column column: Int, 761 + otherwise do: fn() -> Db(Nullability), 762 + ) -> Db(Nullability) { 763 + use context: Context <- eval.from 764 + let key = #(table_oid, column) 765 + case dict.get(context.column_nullability, key) { 766 + Ok(type_) -> #(context, Ok(type_)) 767 + Error(_) -> 768 + case eval.step(do(), context) { 769 + #(_, Error(_)) as result -> result 770 + #( 771 + Context( 772 + column_nullability: column_nullability, 773 + .., 774 + ) as context, 775 + Ok(type_), 776 + ) -> { 777 + let column_nullability = dict.insert(column_nullability, key, type_) 778 + let new_context = 779 + Context(..context, column_nullability: column_nullability) 780 + #(new_context, Ok(type_)) 781 + } 782 + } 783 + } 784 + } 785 + 786 + // --- HELPERS TO BUILD ERRORS ------------------------------------------------- 787 + 788 + fn unsupported_type_error(query: UntypedQuery, type_: String) -> Error { 789 + let UntypedQuery( 790 + content: content, 791 + file: file, 792 + name: name, 793 + starting_line: starting_line, 794 + comment: _, 795 + ) = query 796 + QueryHasUnsupportedType( 797 + file: file, 798 + name: gleam.identifier_to_string(name), 799 + content: content, 800 + type_: type_, 801 + starting_line: starting_line, 802 + ) 803 + } 804 + 805 + fn cannot_parse_error( 806 + query: UntypedQuery, 807 + error_code: Option(String), 808 + hint: Option(String), 809 + pointer: Option(Pointer), 810 + ) -> Error { 811 + let UntypedQuery( 812 + content: content, 813 + file: file, 814 + name: name, 815 + starting_line: starting_line, 816 + comment: _, 817 + ) = query 818 + CannotParseQuery( 819 + content: content, 820 + file: file, 821 + name: gleam.identifier_to_string(name), 822 + error_code: error_code, 823 + hint: hint, 824 + pointer: pointer, 825 + starting_line: starting_line, 826 + ) 827 + } 828 + 829 + fn invalid_column_error( 830 + query: UntypedQuery, 831 + column_name: String, 832 + reason: ValueIdentifierError, 833 + ) -> Error { 834 + let UntypedQuery( 835 + name: _, 836 + file: file, 837 + content: content, 838 + starting_line: starting_line, 839 + comment: _, 840 + ) = query 841 + QueryHasInvalidColumn( 842 + file: file, 843 + column_name: column_name, 844 + suggested_name: gleam.similar_identifier_string(column_name) 845 + |> option.from_result, 846 + content: content, 847 + reason: reason, 848 + starting_line: starting_line, 849 + ) 850 + } 851 + 852 + // --- DECODERS ---------------------------------------------------------------- 853 + 854 + fn json_plans_decoder(data: Dynamic) -> Result(List(Plan), DecodeErrors) { 855 + d.list(d.field("Plan", plan_decoder))(data) 856 + } 857 + 858 + fn plan_decoder(data: Dynamic) -> Result(Plan, DecodeErrors) { 859 + d.decode4( 860 + Plan, 861 + d.optional_field("Join Type", join_type_decoder), 862 + d.optional_field("Parent Relationship", parent_relation_decoder), 863 + d.optional_field("Output", d.list(d.string)), 864 + d.optional_field("Plans", d.list(plan_decoder)), 865 + )(data) 866 + } 867 + 868 + fn join_type_decoder(data: Dynamic) -> Result(JoinType, DecodeErrors) { 869 + use data <- result.map(d.string(data)) 870 + case data { 871 + "Full" -> Full 872 + "Left" -> Left 873 + "Right" -> Right 874 + _ -> Other 875 + } 876 + } 877 + 878 + fn parent_relation_decoder( 879 + data: Dynamic, 880 + ) -> Result(ParentRelation, DecodeErrors) { 881 + use data <- result.map(d.string(data)) 882 + case data { 883 + "Inner" -> Inner 884 + _ -> NotInner 885 + } 886 + } 887 + 888 + // --- UTILS ------------------------------------------------------------------- 889 + 890 + /// Turns a bit array into a boolean value. 891 + /// Returns `False` if the bit array is all `0`s or empty, `True` otherwise. 892 + /// 893 + fn bit_array_to_bool(bit_array: BitArray) -> Bool { 894 + case bit_array { 895 + <<0, rest:bits>> -> bit_array_to_bool(rest) 896 + <<>> -> False 897 + _ -> True 898 + } 899 + }
+1391
src/squirrel/internal/database/postgres_protocol.gleam
··· 1 + //// Vendored version of https://hex.pm/packages/postgresql_protocol. 2 + //// Ideally this should not be touched unless some really special needs come 3 + //// up. 4 + //// 5 + //// I had to make a little change to the existing library: 6 + //// - do not fail with an unexpected Command if the outer message is ok 7 + //// 8 + //// This library parses and generates packages for the PostgreSQL Binary Protocol 9 + //// It also provides a basic connection abstraction, but this hasn't been used 10 + //// outside of tests. 11 + 12 + import gleam/bit_array 13 + import gleam/bool 14 + import gleam/dict 15 + import gleam/int 16 + import gleam/list 17 + import gleam/result.{try} 18 + import gleam/set 19 + import mug 20 + 21 + pub type Connection { 22 + Connection(socket: mug.Socket, buffer: BitArray, timeout: Int) 23 + } 24 + 25 + pub fn connect(host, port, timeout) { 26 + let assert Ok(socket) = 27 + mug.connect(mug.ConnectionOptions(host: host, port: port, timeout: timeout)) 28 + 29 + Connection(socket: socket, buffer: <<>>, timeout: timeout) 30 + } 31 + 32 + pub type StateInitial { 33 + StateInitial(parameters: dict.Dict(String, String)) 34 + } 35 + 36 + pub type State { 37 + State( 38 + process_id: Int, 39 + secret_key: Int, 40 + parameters: dict.Dict(String, String), 41 + oids: dict.Dict(Int, fn(BitArray) -> Result(Int, Nil)), 42 + ) 43 + } 44 + 45 + fn default_oids() { 46 + dict.new() 47 + |> dict.insert(23, fn(col: BitArray) { 48 + use converted <- try(bit_array.to_string(col)) 49 + int.parse(converted) 50 + }) 51 + } 52 + 53 + pub fn start(conn, params) { 54 + let assert Ok(conn) = 55 + conn 56 + |> send(encode_startup_message(params)) 57 + 58 + let assert Ok(#(conn, state)) = 59 + conn 60 + |> receive_startup_rec(StateInitial(dict.new())) 61 + 62 + #(conn, state) 63 + } 64 + 65 + fn receive_startup_rec(conn: Connection, state: StateInitial) { 66 + case receive(conn) { 67 + Ok(#(conn, BeAuthenticationOk)) -> receive_startup_rec(conn, state) 68 + Ok(#(conn, BeParameterStatus(name: name, value: value))) -> 69 + receive_startup_rec( 70 + conn, 71 + StateInitial(parameters: dict.insert(state.parameters, name, value)), 72 + ) 73 + Ok(#(conn, BeBackendKeyData(secret_key: secret_key, process_id: process_id))) -> 74 + receive_startup_1( 75 + conn, 76 + State( 77 + parameters: state.parameters, 78 + secret_key: secret_key, 79 + process_id: process_id, 80 + oids: default_oids(), 81 + ), 82 + ) 83 + Ok(#(_conn, msg)) -> Error(StartupFailedWithUnexpectedMessage(msg)) 84 + Error(err) -> Error(StartupFailedWithError(err)) 85 + } 86 + } 87 + 88 + fn receive_startup_1(conn: Connection, state: State) { 89 + case receive(conn) { 90 + Ok(#(conn, BeParameterStatus(name: name, value: value))) -> 91 + receive_startup_1( 92 + conn, 93 + State(..state, parameters: dict.insert(state.parameters, name, value)), 94 + ) 95 + Ok(#(conn, BeReadyForQuery(_))) -> Ok(#(conn, state)) 96 + Ok(#(_conn, msg)) -> Error(StartupFailedWithUnexpectedMessage(msg)) 97 + Error(err) -> Error(StartupFailedWithError(err)) 98 + } 99 + } 100 + 101 + pub type StartupFailed { 102 + StartupFailedWithUnexpectedMessage(BackendMessage) 103 + StartupFailedWithError(ReadError) 104 + } 105 + 106 + pub type ReadError { 107 + SocketError(mug.Error) 108 + ReadDecodeError(MessageDecodingError) 109 + } 110 + 111 + pub type MessageDecodingError { 112 + MessageDecodingError(String) 113 + MessageIncomplete(BitArray) 114 + UnknownMessage(data: BitArray) 115 + } 116 + 117 + fn dec_err(desc: String, data: BitArray) -> Result(a, MessageDecodingError) { 118 + Error(MessageDecodingError(desc <> "; data: " <> bit_array.inspect(data))) 119 + } 120 + 121 + fn msg_dec_err(desc: String, data: BitArray) -> MessageDecodingError { 122 + MessageDecodingError(desc <> "; data: " <> bit_array.inspect(data)) 123 + } 124 + 125 + pub type Command { 126 + Insert 127 + Delete 128 + Update 129 + Merge 130 + Select 131 + Move 132 + Fetch 133 + Copy 134 + } 135 + 136 + /// Messages originating from the PostgreSQL backend (server) 137 + pub type BackendMessage { 138 + BeBindComplete 139 + BeCloseComplete 140 + BeCommandComplete(Command, Int) 141 + BeCopyData(data: BitArray) 142 + BeCopyDone 143 + BeAuthenticationOk 144 + BeAuthenticationKerberosV5 145 + BeAuthenticationCleartextPassword 146 + BeAuthenticationMD5Password(salt: BitArray) 147 + BeAuthenticationGSS 148 + BeAuthenticationGSSContinue(auth_data: BitArray) 149 + BeAuthenticationSSPI 150 + BeAuthenticationSASL(mechanisms: List(String)) 151 + BeAuthenticationSASLContinue(data: BitArray) 152 + BeAuthenticationSASLFinal(data: BitArray) 153 + BeReadyForQuery(TransactionStatus) 154 + BeRowDescriptions(List(RowDescriptionField)) 155 + BeMessageDataRow(List(BitArray)) 156 + BeBackendKeyData(process_id: Int, secret_key: Int) 157 + BeParameterStatus(name: String, value: String) 158 + BeCopyResponse( 159 + direction: CopyDirection, 160 + overall_format: Format, 161 + codes: List(Format), 162 + ) 163 + BeNegotiateProtocolVersion( 164 + newest_minor: Int, 165 + unrecognized_options: List(String), 166 + ) 167 + BeNoData 168 + BeNoticeResponse(set.Set(ErrorOrNoticeField)) 169 + BeNotificationResponse(process_id: Int, channel: String, payload: String) 170 + BeParameterDescription(List(Int)) 171 + BeParseComplete 172 + BePortalSuspended 173 + BeErrorResponse(set.Set(ErrorOrNoticeField)) 174 + } 175 + 176 + /// Direction of a BeCopyResponse 177 + pub type CopyDirection { 178 + In 179 + Out 180 + Both 181 + } 182 + 183 + /// Indicates encoding of column values 184 + pub type Format { 185 + Text 186 + Binary 187 + } 188 + 189 + /// Lists of parameters can have different encodings. 190 + pub type FormatValue { 191 + FormatAllText 192 + FormatAll(Format) 193 + Formats(List(Format)) 194 + } 195 + 196 + fn encode_format_value(format) -> BitArray { 197 + case format { 198 + FormatAllText -> <<encode_format(Text):16>> 199 + FormatAll(Text) -> <<1:16, encode_format(Text):16>> 200 + FormatAll(Binary) -> <<1:16, encode_format(Binary):16>> 201 + Formats(formats) -> { 202 + let size = list.length(formats) 203 + list.fold(formats, <<size:16>>, fn(sum, fmt) { 204 + <<sum:bits, encode_format(fmt):16>> 205 + }) 206 + } 207 + } 208 + } 209 + 210 + pub type ParameterValues = 211 + List(ParameterValue) 212 + 213 + pub type ParameterValue { 214 + Parameter(BitArray) 215 + Null 216 + } 217 + 218 + fn parameters_to_bytes(parameters: ParameterValues) { 219 + let mapped = 220 + parameters 221 + |> list.map(fn(parameter) { 222 + case parameter { 223 + Parameter(value) -> <<bit_array.byte_size(value):32, value:bits>> 224 + Null -> <<-1:32>> 225 + } 226 + }) 227 + 228 + <<list.length(parameters):16, bit_array.concat(mapped):bits>> 229 + } 230 + 231 + pub type FrontendMessage { 232 + FeBind( 233 + portal: String, 234 + statement_name: String, 235 + parameter_format: FormatValue, 236 + parameters: ParameterValues, 237 + result_format: FormatValue, 238 + ) 239 + FeCancelRequest(process_id: Int, secret_key: Int) 240 + FeClose(what: What, name: String) 241 + FeCopyData(data: BitArray) 242 + FeCopyDone 243 + FeCopyFail(error: String) 244 + FeDescribe(what: What, name: String) 245 + FeExecute(portal: String, return_row_count: Int) 246 + FeFlush 247 + FeFunctionCall( 248 + object_id: Int, 249 + argument_format: FormatValue, 250 + arguments: ParameterValues, 251 + result_format: Format, 252 + ) 253 + FeGssEncRequest 254 + FeParse(name: String, query: String, parameter_object_ids: List(Int)) 255 + FeQuery(query: String) 256 + FeStartupMessage(params: List(#(String, String))) 257 + FeSslRequest 258 + FeTerminate 259 + FeSync 260 + FeAmbigous(FeAmbigous) 261 + } 262 + 263 + // These all share the same message type 264 + pub type FeAmbigous { 265 + FeGssResponse(data: BitArray) 266 + FeSaslInitialResponse(name: String, data: BitArray) 267 + FeSaslResponse(data: BitArray) 268 + FePasswordMessage(password: String) 269 + } 270 + 271 + pub type What { 272 + PreparedStatement 273 + Portal 274 + } 275 + 276 + fn wire_what(what) { 277 + case what { 278 + Portal -> <<"P":utf8>> 279 + PreparedStatement -> <<"S":utf8>> 280 + } 281 + } 282 + 283 + fn decode_what(binary) { 284 + case binary { 285 + <<"P":utf8, rest:bytes>> -> Ok(#(Portal, rest)) 286 + <<"S":utf8, rest:bytes>> -> Ok(#(PreparedStatement, rest)) 287 + _ -> dec_err("only portal and prepared statement are allowed", binary) 288 + } 289 + } 290 + 291 + fn encode_message_data_row(columns) { 292 + <<list.length(columns):16>> 293 + |> list.fold( 294 + columns, 295 + _, 296 + fn(sum, column) { 297 + let len = bit_array.byte_size(column) 298 + <<sum:bits, len:32, column:bits>> 299 + }, 300 + ) 301 + |> encode("D", _) 302 + } 303 + 304 + fn encode_error_response(fields: set.Set(ErrorOrNoticeField)) -> BitArray { 305 + fields 306 + |> set.fold(<<>>, fn(sum, field) { <<sum:bits, encode_field(field):bits>> }) 307 + |> encode("E", _) 308 + } 309 + 310 + fn encode_field(field) { 311 + case field { 312 + Severity(value) -> <<"S":utf8, value:utf8, 0>> 313 + SeverityLocalized(value) -> <<"V":utf8, value:utf8, 0>> 314 + Code(value) -> <<"C":utf8, value:utf8, 0>> 315 + Message(value) -> <<"M":utf8, value:utf8, 0>> 316 + Detail(value) -> <<"D":utf8, value:utf8, 0>> 317 + Hint(value) -> <<"H":utf8, value:utf8, 0>> 318 + Position(value) -> <<"P":utf8, value:utf8, 0>> 319 + InternalPosition(value) -> <<"p":utf8, value:utf8, 0>> 320 + InternalQuery(value) -> <<"q":utf8, value:utf8, 0>> 321 + Where(value) -> <<"W":utf8, value:utf8, 0>> 322 + Schema(value) -> <<"s":utf8, value:utf8, 0>> 323 + Table(value) -> <<"t":utf8, value:utf8, 0>> 324 + Column(value) -> <<"c":utf8, value:utf8, 0>> 325 + DataType(value) -> <<"d":utf8, value:utf8, 0>> 326 + Constraint(value) -> <<"n":utf8, value:utf8, 0>> 327 + File(value) -> <<"F":utf8, value:utf8, 0>> 328 + Line(value) -> <<"L":utf8, value:utf8, 0>> 329 + Routine(value) -> <<"R":utf8, value:utf8, 0>> 330 + Unknown(key, value) -> <<key:bits, 0, value:utf8, 0>> 331 + } 332 + } 333 + 334 + fn encode_authentication_sasl(mechanisms) -> BitArray { 335 + list.fold(mechanisms, <<10:32>>, fn(sum, mechanism) { 336 + <<sum:bits, encode_string(mechanism):bits>> 337 + }) 338 + |> encode("R", _) 339 + } 340 + 341 + fn encode_command_complete(command, rows_num) { 342 + let rows = int.to_string(rows_num) 343 + 344 + let data = 345 + case command { 346 + Insert -> "INSERT 0 " <> rows 347 + Delete -> "DELETE " <> rows 348 + Update -> "UPDATE " <> rows 349 + Merge -> "MERGE " <> rows 350 + Select -> "SELECT " <> rows 351 + Move -> "MOVE " <> rows 352 + Fetch -> "FETCH " <> rows 353 + Copy -> "COPY " <> rows 354 + } 355 + |> encode_string() 356 + 357 + encode("C", data) 358 + } 359 + 360 + fn encode_copy_response( 361 + direction: CopyDirection, 362 + overall_format: Format, 363 + codes: List(Format), 364 + ) { 365 + let data = encode_copy_response_rec(overall_format, codes) 366 + case direction { 367 + In -> encode("G", data) 368 + Out -> encode("H", data) 369 + Both -> encode("W", data) 370 + } 371 + } 372 + 373 + // The format codes to be used for each column. Each must presently be zero 374 + // (text) or one (binary). All must be zero if the overall copy format is 375 + // textual. 376 + fn encode_copy_response_rec(overall_format, codes) { 377 + case overall_format { 378 + Text -> 379 + codes 380 + |> list.fold( 381 + <<encode_format(overall_format):8, list.length(codes):16>>, 382 + fn(sum, _code) { <<sum:bits, encode_format(Text):16>> }, 383 + ) 384 + Binary -> 385 + codes 386 + |> list.fold( 387 + <<encode_format(overall_format):8, list.length(codes):16>>, 388 + fn(sum, code) { <<sum:bits, encode_format(code):16>> }, 389 + ) 390 + } 391 + } 392 + 393 + fn encode_parameter_status(name: String, value: String) -> BitArray { 394 + encode("S", <<encode_string(name):bits, encode_string(value):bits>>) 395 + } 396 + 397 + fn encode_negotiate_protocol_version( 398 + newest_minor: Int, 399 + unrecognized_options: List(String), 400 + ) { 401 + list.fold( 402 + unrecognized_options, 403 + <<newest_minor:32, list.length(unrecognized_options):32>>, 404 + fn(sum, option) { <<sum:bits, encode_string(option):bits>> }, 405 + ) 406 + |> encode("v", _) 407 + } 408 + 409 + fn encode_notice_response(fields: set.Set(ErrorOrNoticeField)) { 410 + fields 411 + |> set.fold(<<>>, fn(sum, field) { <<sum:bits, encode_field(field):bits>> }) 412 + |> encode("N", _) 413 + } 414 + 415 + fn encode_notification_response( 416 + process_id: Int, 417 + channel: String, 418 + payload: String, 419 + ) { 420 + encode("A", <<process_id:32, channel:utf8, 0, payload:utf8, 0>>) 421 + } 422 + 423 + fn encode_parameter_description(descriptions: List(Int)) { 424 + descriptions 425 + |> list.fold(<<list.length(descriptions):16>>, fn(sum, description) { 426 + <<sum:bits, description:32>> 427 + }) 428 + |> encode("t", _) 429 + } 430 + 431 + fn encode_row_descriptions(fields: List(RowDescriptionField)) { 432 + fields 433 + |> list.fold(<<list.length(fields):16>>, fn(sum, field) { 434 + << 435 + sum:bits, 436 + encode_string(field.name):bits, 437 + field.table_oid:32, 438 + field.attr_number:16, 439 + field.data_type_oid:32, 440 + field.data_type_size:16, 441 + field.type_modifier:32, 442 + field.format_code:16, 443 + >> 444 + }) 445 + |> encode("T", _) 446 + } 447 + 448 + pub fn encode_backend_message(message: BackendMessage) -> BitArray { 449 + case message { 450 + BeMessageDataRow(columns) -> encode_message_data_row(columns) 451 + BeErrorResponse(fields) -> encode_error_response(fields) 452 + BeAuthenticationOk -> encode("R", <<0:32>>) 453 + BeAuthenticationKerberosV5 -> encode("R", <<2:32>>) 454 + BeAuthenticationCleartextPassword -> encode("R", <<3:32>>) 455 + BeAuthenticationMD5Password(salt: salt) -> encode("R", <<5:32, salt:bits>>) 456 + BeAuthenticationGSS -> encode("R", <<7:32>>) 457 + BeAuthenticationGSSContinue(data) -> encode("R", <<8:32, data:bits>>) 458 + BeAuthenticationSSPI -> encode("R", <<9:32>>) 459 + BeAuthenticationSASL(a) -> encode_authentication_sasl(a) 460 + BeAuthenticationSASLContinue(data) -> encode("R", <<11:32, data:bits>>) 461 + BeAuthenticationSASLFinal(data: data) -> encode("R", <<12:32, data:bits>>) 462 + BeBackendKeyData(pid, sk) -> encode("K", <<pid:32, sk:32>>) 463 + BeBindComplete -> encode("2", <<>>) 464 + BeCloseComplete -> encode("3", <<>>) 465 + BeCommandComplete(a, b) -> encode_command_complete(a, b) 466 + BeCopyData(data) -> encode("d", data) 467 + BeCopyDone -> encode("c", <<>>) 468 + BeCopyResponse(a, b, c) -> encode_copy_response(a, b, c) 469 + BeParameterStatus(name, value) -> encode_parameter_status(name, value) 470 + BeNegotiateProtocolVersion(a, b) -> encode_negotiate_protocol_version(a, b) 471 + BeNoData -> encode("n", <<>>) 472 + BeNoticeResponse(data) -> encode_notice_response(data) 473 + BeNotificationResponse(a, b, c) -> encode_notification_response(a, b, c) 474 + BeParameterDescription(a) -> encode_parameter_description(a) 475 + BeParseComplete -> encode("1", <<>>) 476 + BePortalSuspended -> encode("s", <<>>) 477 + BeRowDescriptions(a) -> encode_row_descriptions(a) 478 + BeReadyForQuery(TransactionStatusIdle) -> encode("Z", <<"I":utf8>>) 479 + BeReadyForQuery(TransactionStatusInTransaction) -> encode("Z", <<"T":utf8>>) 480 + BeReadyForQuery(TransactionStatusFailed) -> encode("Z", <<"E":utf8>>) 481 + } 482 + } 483 + 484 + pub fn encode_frontend_message(message: FrontendMessage) { 485 + case message { 486 + FeBind(a, b, c, d, e) -> encode_bind(a, b, c, d, e) 487 + FeCancelRequest(process_id: process_id, secret_key: secret_key) -> << 488 + 16:32, 489 + 1234:16, 490 + 5678:16, 491 + process_id:32, 492 + secret_key:32, 493 + >> 494 + FeClose(what, name) -> 495 + encode("C", <<wire_what(what):bits, encode_string(name):bits>>) 496 + FeCopyData(data) -> encode("d", data) 497 + FeCopyDone -> <<"c":utf8, 4:32>> 498 + FeCopyFail(error) -> encode("f", encode_string(error)) 499 + FeDescribe(what, name) -> 500 + encode("D", <<wire_what(what):bits, encode_string(name):bits>>) 501 + FeExecute(portal, count) -> 502 + encode("E", <<encode_string(portal):bits, count:32>>) 503 + FeFlush -> <<"H":utf8, 4:32>> 504 + FeFunctionCall(a, b, c, d) -> encode_function_call(a, b, c, d) 505 + FeGssEncRequest -> <<8:32, 1234:16, 5680:16>> 506 + FeAmbigous(FeGssResponse(data)) -> encode("p", data) 507 + FeParse(a, b, c) -> encode_parse(a, b, c) 508 + FeAmbigous(FePasswordMessage(password)) -> 509 + encode("p", encode_string(password)) 510 + FeQuery(query) -> encode("Q", encode_string(query)) 511 + FeAmbigous(FeSaslInitialResponse(a, b)) -> 512 + encode_sasl_initial_response(a, b) 513 + FeAmbigous(FeSaslResponse(data)) -> encode("p", data) 514 + FeStartupMessage(params) -> encode_startup_message(params) 515 + FeSslRequest -> <<8:32, 1234:16, 5679:16>> 516 + FeSync -> <<"S":utf8, 4:32>> 517 + FeTerminate -> <<"X":utf8, 4:32>> 518 + } 519 + } 520 + 521 + fn encode(type_char, data) { 522 + case data { 523 + <<>> -> <<type_char:utf8, 4:32>> 524 + _ -> { 525 + let len = bit_array.byte_size(data) + 4 526 + <<type_char:utf8, len:32, data:bits>> 527 + } 528 + } 529 + } 530 + 531 + fn encode_string(str) { 532 + <<str:utf8, 0>> 533 + } 534 + 535 + pub const protocol_version_major = <<3:16>> 536 + 537 + pub const protocol_version_minor = <<0:16>> 538 + 539 + pub const protocol_version = << 540 + protocol_version_major:bits, 541 + protocol_version_minor:bits, 542 + >> 543 + 544 + fn encode_startup_message(params) { 545 + let packet = 546 + params 547 + |> list.fold(<<protocol_version:bits>>, fn(builder, element) { 548 + let #(key, value) = element 549 + <<builder:bits, encode_string(key):bits, encode_string(value):bits>> 550 + }) 551 + 552 + let size = bit_array.byte_size(packet) + 5 553 + 554 + <<size:32, packet:bits, 0>> 555 + } 556 + 557 + fn encode_sasl_initial_response(name, data) { 558 + let len = bit_array.byte_size(data) 559 + encode("p", <<encode_string(name):bits, len:32, data:bits>>) 560 + } 561 + 562 + fn encode_parse(name, query, parameter_object_ids) { 563 + let oids = 564 + list.fold(parameter_object_ids, <<>>, fn(sum, oid) { <<sum:bits, oid:32>> }) 565 + let len = list.length(parameter_object_ids) 566 + encode("P", << 567 + encode_string(name):bits, 568 + encode_string(query):bits, 569 + len:16, 570 + oids:bits, 571 + >>) 572 + } 573 + 574 + fn encode_bind( 575 + portal, 576 + statement_name, 577 + parameter_format, 578 + parameters, 579 + result_format, 580 + ) { 581 + encode("B", << 582 + portal:utf8, 583 + 0, 584 + statement_name:utf8, 585 + 0, 586 + encode_format_value(parameter_format):bits, 587 + parameters_to_bytes(parameters):bits, 588 + encode_format_value(result_format):bits, 589 + >>) 590 + } 591 + 592 + fn encode_function_call( 593 + object_id: Int, 594 + argument_format: FormatValue, 595 + arguments: ParameterValues, 596 + result_format: Format, 597 + ) { 598 + encode("F", << 599 + object_id:32, 600 + encode_format_value(argument_format):bits, 601 + parameters_to_bytes(arguments):bits, 602 + encode_format(result_format):16, 603 + >>) 604 + } 605 + 606 + /// Send a message to the database 607 + pub fn send_builder(conn: Connection, message) { 608 + case mug.send_builder(conn.socket, message) { 609 + Ok(Nil) -> Ok(conn) 610 + Error(err) -> Error(err) 611 + } 612 + } 613 + 614 + /// Send a message to the database 615 + pub fn send(conn: Connection, message) { 616 + case mug.send(conn.socket, message) { 617 + Ok(Nil) -> Ok(conn) 618 + Error(err) -> Error(err) 619 + } 620 + } 621 + 622 + /// Receive a single message from the backend 623 + pub fn receive( 624 + conn: Connection, 625 + ) -> Result(#(Connection, BackendMessage), ReadError) { 626 + case decode_backend_packet(conn.buffer) { 627 + Ok(#(message, rest)) -> Ok(#(with_buffer(conn, rest), message)) 628 + Error(MessageIncomplete(_)) -> { 629 + case mug.receive(conn.socket, conn.timeout) { 630 + Ok(packet) -> 631 + receive(with_buffer(conn, <<conn.buffer:bits, packet:bits>>)) 632 + Error(err) -> Error(SocketError(err)) 633 + } 634 + } 635 + Error(err) -> Error(ReadDecodeError(err)) 636 + } 637 + } 638 + 639 + fn with_buffer(conn: Connection, buffer: BitArray) { 640 + Connection(..conn, buffer: buffer) 641 + } 642 + 643 + // decode a single message from the packet 644 + pub fn decode_backend_packet( 645 + packet: BitArray, 646 + ) -> Result(#(BackendMessage, BitArray), MessageDecodingError) { 647 + case packet { 648 + <<message_type:bytes-size(1), length:32, tail:bytes>> -> { 649 + let len = length - 4 650 + case tail { 651 + <<data:bytes-size(len), next:bytes>> -> 652 + decode_backend_message(<<message_type:bits, data:bits>>) 653 + |> result.map(fn(msg) { #(msg, next) }) 654 + _ -> Error(MessageIncomplete(tail)) 655 + } 656 + } 657 + _ -> 658 + case bit_array.byte_size(packet) < 5 { 659 + True -> Error(MessageIncomplete(packet)) 660 + False -> dec_err("packet size too small", packet) 661 + } 662 + } 663 + } 664 + 665 + // decode a backend message 666 + pub fn decode_backend_message(binary) { 667 + case binary { 668 + <<"D":utf8, count:16, data:bytes>> -> decode_message_data_row(count, data) 669 + <<"E":utf8, data:bytes>> -> decode_error_response(data) 670 + <<"R":utf8, 0:32>> -> Ok(BeAuthenticationOk) 671 + <<"R":utf8, 2:32>> -> Ok(BeAuthenticationKerberosV5) 672 + <<"R":utf8, 3:32>> -> Ok(BeAuthenticationCleartextPassword) 673 + <<"R":utf8, 5:32, salt:bytes>> -> 674 + Ok(BeAuthenticationMD5Password(salt: salt)) 675 + <<"R":utf8, 7:32>> -> Ok(BeAuthenticationGSS) 676 + <<"R":utf8, 8:32, auth_data:bytes>> -> 677 + Ok(BeAuthenticationGSSContinue(auth_data: auth_data)) 678 + <<"R":utf8, 9:32>> -> Ok(BeAuthenticationSSPI) 679 + <<"R":utf8, 10:32, data:bytes>> -> decode_authentication_sasl(data) 680 + <<"R":utf8, 11:32, data:bytes>> -> 681 + Ok(BeAuthenticationSASLContinue(data: data)) 682 + <<"R":utf8, 12:32, data:bytes>> -> Ok(BeAuthenticationSASLFinal(data: data)) 683 + <<"K":utf8, pid:32, sk:32>> -> 684 + Ok(BeBackendKeyData(process_id: pid, secret_key: sk)) 685 + <<"2":utf8>> -> Ok(BeBindComplete) 686 + <<"3":utf8>> -> Ok(BeCloseComplete) 687 + <<"C":utf8, data:bytes>> -> decode_command_complete(data) 688 + <<"d":utf8, data:bytes>> -> Ok(BeCopyData(data)) 689 + <<"c":utf8>> -> Ok(BeCopyDone) 690 + <<"G":utf8, format:8, count:16, data:bytes>> -> 691 + decode_copy_response(In, format, count, data) 692 + <<"H":utf8, format:8, count:16, data:bytes>> -> 693 + decode_copy_response(Out, format, count, data) 694 + <<"W":utf8, format:8, count:16, data:bytes>> -> 695 + decode_copy_response(Both, format, count, data) 696 + <<"S":utf8, data:bytes>> -> decode_parameter_status(data) 697 + <<"v":utf8, version:32, count:32, data:bytes>> -> 698 + decode_negotiate_protocol_version(version, count, data) 699 + <<"n":utf8>> -> Ok(BeNoData) 700 + <<"N":utf8, data:bytes>> -> decode_notice_response(data) 701 + <<"A":utf8, process_id:32, data:bytes>> -> 702 + decode_notification_response(process_id, data) 703 + <<"t":utf8, count:16, data:bytes>> -> 704 + decode_parameter_description(count, data, []) 705 + <<"1":utf8>> -> Ok(BeParseComplete) 706 + <<"s":utf8>> -> Ok(BePortalSuspended) 707 + <<"T":utf8, count:16, data:bytes>> -> decode_row_descriptions(count, data) 708 + <<"Z":utf8, "I":utf8>> -> Ok(BeReadyForQuery(TransactionStatusIdle)) 709 + <<"Z":utf8, "T":utf8>> -> 710 + Ok(BeReadyForQuery(TransactionStatusInTransaction)) 711 + <<"Z":utf8, "E":utf8>> -> Ok(BeReadyForQuery(TransactionStatusFailed)) 712 + _ -> Error(UnknownMessage(binary)) 713 + } 714 + } 715 + 716 + /// decode a single message from the packet buffer. 717 + /// note that FeCancelRequest, FeSslRequest, FeGssEncRequest, and 718 + /// FeStartupMessage messages can only be decoded here because they don't follow 719 + /// the standard message format. 720 + pub fn decode_frontend_packet( 721 + packet: BitArray, 722 + ) -> Result(#(FrontendMessage, BitArray), MessageDecodingError) { 723 + case packet { 724 + <<16:32, 1234:16, 5678:16, process_id:32, secret_key:32, next:bytes>> -> 725 + Ok(#( 726 + FeCancelRequest(process_id: process_id, secret_key: secret_key), 727 + next, 728 + )) 729 + <<8:32, 1234:16, 5679:16, next:bytes>> -> Ok(#(FeSslRequest, next)) 730 + <<8:32, 1234:16, 5680:16, next:bytes>> -> Ok(#(FeGssEncRequest, next)) 731 + // not sure if there's a way to use the `protocol_version` constant here 732 + <<length:32, 3:16, 0:16, next:bytes>> -> 733 + decode_startup_message(next, length - 8, []) 734 + <<message_type:bytes-size(1), length:32, tail:bytes>> -> { 735 + let len = length - 4 736 + case tail { 737 + <<data:bytes-size(len), next:bytes>> -> 738 + decode_frontend_message(<<message_type:bits, data:bits>>) 739 + |> result.map(fn(msg) { #(msg, next) }) 740 + _ -> Error(MessageIncomplete(tail)) 741 + } 742 + } 743 + <<_:48>> -> Error(MessageIncomplete(packet)) 744 + _ -> dec_err("invalid message", packet) 745 + } 746 + } 747 + 748 + /// decode a frontend message (also see decode_frontend_packet for messages that 749 + /// can't be decoded here) 750 + pub fn decode_frontend_message( 751 + binary: BitArray, 752 + ) -> Result(FrontendMessage, MessageDecodingError) { 753 + case binary { 754 + <<"B":utf8, data:bytes>> -> decode_bind(data) 755 + <<"C":utf8, data:bytes>> -> decode_close(data) 756 + <<"d":utf8, data:bytes>> -> Ok(FeCopyData(data)) 757 + <<"c":utf8>> -> Ok(FeCopyDone) 758 + <<"f":utf8, data:bytes>> -> decode_copy_fail(data) 759 + <<"D":utf8, data:bytes>> -> decode_describe(data) 760 + <<"E":utf8, data:bytes>> -> decode_execute(data) 761 + <<"H":utf8>> -> Ok(FeFlush) 762 + <<"F":utf8, data:bytes>> -> decode_function_call(data) 763 + <<"p":utf8, data:bytes>> -> Ok(FeAmbigous(FeGssResponse(data))) 764 + <<"P":utf8, data:bytes>> -> decode_parse(data) 765 + <<"Q":utf8, data:bytes>> -> decode_query(data) 766 + <<"S":utf8>> -> Ok(FeSync) 767 + <<"X":utf8>> -> Ok(FeTerminate) 768 + _ -> Error(UnknownMessage(data: binary)) 769 + } 770 + } 771 + 772 + fn decode_startup_message(binary, size, result) { 773 + case binary { 774 + <<data:bytes-size(size), next:bytes>> -> 775 + decode_startup_message_pairs(data, []) 776 + |> result.map(fn(r) { #(r, next) }) 777 + _ -> dec_err("invalid startup message", binary) 778 + } 779 + } 780 + 781 + fn decode_startup_message_pairs(binary, result) { 782 + case binary { 783 + <<0>> -> Ok(FeStartupMessage(params: list.reverse(result))) 784 + _ -> { 785 + use #(key, binary) <- try(decode_string(binary)) 786 + use #(value, binary) <- try(decode_string(binary)) 787 + decode_startup_message_pairs(binary, [#(key, value), ..result]) 788 + } 789 + } 790 + } 791 + 792 + fn decode_query(binary) -> Result(FrontendMessage, MessageDecodingError) { 793 + use #(query, rest) <- try(decode_string(binary)) 794 + case rest { 795 + <<>> -> Ok(FeQuery(query)) 796 + _ -> dec_err("Query message too long", binary) 797 + } 798 + } 799 + 800 + // FeQuery(query) -> encode("Q", encode_string(query)) 801 + 802 + fn decode_parse(binary) -> Result(FrontendMessage, MessageDecodingError) { 803 + use #(name, binary) <- try(decode_string(binary)) 804 + use #(query, binary) <- try(decode_string(binary)) 805 + use parameter_object_ids <- try(decode_parameter_object_ids(binary)) 806 + Ok(FeParse( 807 + name: name, 808 + query: query, 809 + parameter_object_ids: parameter_object_ids, 810 + )) 811 + } 812 + 813 + fn decode_parameter_object_ids(binary) { 814 + case binary { 815 + <<count:16, rest:bytes>> -> decode_parameter_object_ids_rec(rest, count, []) 816 + _ -> dec_err("expected object id count", binary) 817 + } 818 + } 819 + 820 + fn decode_parameter_object_ids_rec(binary, count, result) { 821 + case count, binary { 822 + 0, <<>> -> Ok(list.reverse(result)) 823 + _, <<id:32, rest:bytes>> -> 824 + decode_parameter_object_ids_rec(rest, count - 1, [id, ..result]) 825 + _, _ -> dec_err("expected parameter object id", binary) 826 + } 827 + } 828 + 829 + fn decode_function_call(binary) -> Result(FrontendMessage, MessageDecodingError) { 830 + case binary { 831 + <<object_id:32, rest:bytes>> -> { 832 + use #(argument_format, rest) <- try(read_parameter_format(rest)) 833 + use #(arguments, rest) <- try(read_parameters(rest, argument_format)) 834 + use #(result_format, rest) <- try(read_format(rest)) 835 + case rest { 836 + <<>> -> 837 + Ok(FeFunctionCall( 838 + argument_format: argument_format, 839 + arguments: arguments, 840 + object_id: object_id, 841 + result_format: result_format, 842 + )) 843 + _ -> dec_err("invalid function call, data remains", rest) 844 + } 845 + } 846 + _ -> dec_err("invalid function call, no object id found", binary) 847 + } 848 + } 849 + 850 + fn decode_execute(binary) -> Result(FrontendMessage, MessageDecodingError) { 851 + use #(portal, binary) <- try(decode_string(binary)) 852 + case binary { 853 + <<count:32>> -> Ok(FeExecute(portal, count)) 854 + _ -> dec_err("no execute return_row_count found", binary) 855 + } 856 + } 857 + 858 + fn decode_describe(binary) -> Result(FrontendMessage, MessageDecodingError) { 859 + use #(what, binary) <- try(decode_what(binary)) 860 + use #(name, binary) <- try(decode_string(binary)) 861 + case binary { 862 + <<>> -> Ok(FeDescribe(what, name)) 863 + _ -> dec_err("Describe message too long", binary) 864 + } 865 + } 866 + 867 + fn decode_copy_fail(binary) -> Result(FrontendMessage, MessageDecodingError) { 868 + use #(error, binary) <- try(decode_string(binary)) 869 + case binary { 870 + <<>> -> Ok(FeCopyFail(error)) 871 + _ -> dec_err("CopyFail message too long", binary) 872 + } 873 + } 874 + 875 + fn decode_close(binary) -> Result(FrontendMessage, MessageDecodingError) { 876 + use #(what, binary) <- try(decode_what(binary)) 877 + use #(name, binary) <- try(decode_string(binary)) 878 + case binary { 879 + <<>> -> Ok(FeClose(what, name)) 880 + _ -> dec_err("Close message too long", binary) 881 + } 882 + } 883 + 884 + // FeClose(what, name) -> 885 + // encode("C", <<wire_what(what):bits, encode_string(name):bits>>) 886 + 887 + fn decode_bind(binary) -> Result(FrontendMessage, MessageDecodingError) { 888 + use #(portal, binary) <- try(decode_string(binary)) 889 + use #(statement_name, binary) <- try(decode_string(binary)) 890 + use #(parameter_format, binary) <- try(read_parameter_format(binary)) 891 + use #(parameters, binary) <- try(read_parameters(binary, parameter_format)) 892 + use #(result_format, binary) <- try(read_parameter_format(binary)) 893 + case binary { 894 + <<>> -> 895 + Ok(FeBind( 896 + portal: portal, 897 + statement_name: statement_name, 898 + parameter_format: parameter_format, 899 + parameters: parameters, 900 + result_format: result_format, 901 + )) 902 + _ -> dec_err("Bind message too long", binary) 903 + } 904 + } 905 + 906 + fn decode_string( 907 + binary: BitArray, 908 + ) -> Result(#(String, BitArray), MessageDecodingError) { 909 + case binary_split(binary, <<0>>, []) { 910 + [head, tail] -> 911 + case bit_array.to_string(head) { 912 + Ok(str) -> Ok(#(str, tail)) 913 + Error(Nil) -> dec_err("invalid string encoding", head) 914 + } 915 + _ -> dec_err("invalid string", binary) 916 + } 917 + } 918 + 919 + fn read_parameter_format( 920 + binary: BitArray, 921 + ) -> Result(#(FormatValue, BitArray), MessageDecodingError) { 922 + case binary { 923 + <<0:16, rest:bytes>> -> Ok(#(FormatAllText, rest)) 924 + <<1:16, 0:16, rest:bytes>> -> Ok(#(FormatAll(Text), rest)) 925 + <<1:16, 1:16, rest:bytes>> -> Ok(#(FormatAll(Binary), rest)) 926 + <<n:16, rest:bytes>> -> read_wire_formats(n, rest, []) 927 + _ -> dec_err("invalid parameter format", binary) 928 + } 929 + } 930 + 931 + fn read_parameters(binary: BitArray, parameter_format: FormatValue) { 932 + case binary { 933 + <<count:16, rest:bytes>> -> { 934 + case parameter_format { 935 + FormatAllText -> list.repeat(Text, count) 936 + FormatAll(format) -> list.repeat(format, count) 937 + Formats(formats) -> formats 938 + } 939 + |> read_parameters_rec(count, rest, []) 940 + } 941 + _ -> dec_err("parameters without count", binary) 942 + } 943 + } 944 + 945 + fn read_parameters_rec( 946 + formats: List(Format), 947 + count: Int, 948 + binary: BitArray, 949 + result: List(ParameterValue), 950 + ) -> Result(#(List(ParameterValue), BitArray), MessageDecodingError) { 951 + let actual = list.length(formats) 952 + use <- bool.guard( 953 + actual != count, 954 + dec_err( 955 + "expected " 956 + <> int.to_string(count) 957 + <> " parameters, but got " 958 + <> int.to_string(actual), 959 + binary, 960 + ), 961 + ) 962 + 963 + case count, formats, binary { 964 + 0, _, rest -> Ok(#(list.reverse(result), rest)) 965 + _, [_, ..rest_formats], <<-1:32-signed, rest:bytes>> -> { 966 + read_parameters_rec(rest_formats, count - 1, rest, [Null, ..result]) 967 + } 968 + _, [format, ..rest_formats], <<len:32, value:bytes-size(len), rest:bytes>> -> 969 + read_parameters_rec(rest_formats, count - 1, rest, [ 970 + read_parameter(format, value), 971 + ..result 972 + ]) 973 + _, _, rest -> dec_err("invalid parameter value", rest) 974 + } 975 + } 976 + 977 + fn read_parameter(format: Format, value: BitArray) { 978 + case format { 979 + Text -> Parameter(value) 980 + Binary -> Parameter(value) 981 + } 982 + } 983 + 984 + fn read_wire_formats( 985 + count: Int, 986 + binary: BitArray, 987 + result: List(Format), 988 + ) -> Result(#(FormatValue, BitArray), MessageDecodingError) { 989 + case count, binary { 990 + 0, _ -> Ok(#(Formats(list.reverse(result)), binary)) 991 + _, <<0:16, rest:bytes>> -> 992 + read_wire_formats(count - 1, rest, [Text, ..result]) 993 + _, <<1:16, rest:bytes>> -> 994 + read_wire_formats(count - 1, rest, [Binary, ..result]) 995 + _, _ -> dec_err("unknown format", binary) 996 + } 997 + } 998 + 999 + fn decode_command_complete( 1000 + binary: BitArray, 1001 + ) -> Result(BackendMessage, MessageDecodingError) { 1002 + { 1003 + use fine <- try(case binary { 1004 + <<"INSERT 0 ":utf8, rows:bytes>> -> Ok(#(Insert, rows)) 1005 + <<"DELETE ":utf8, rows:bytes>> -> Ok(#(Delete, rows)) 1006 + <<"UPDATE ":utf8, rows:bytes>> -> Ok(#(Update, rows)) 1007 + <<"MERGE ":utf8, rows:bytes>> -> Ok(#(Merge, rows)) 1008 + <<"SELECT ":utf8, rows:bytes>> -> Ok(#(Select, rows)) 1009 + <<"MOVE ":utf8, rows:bytes>> -> Ok(#(Move, rows)) 1010 + <<"FETCH ":utf8, rows:bytes>> -> Ok(#(Fetch, rows)) 1011 + <<"COPY ":utf8, rows:bytes>> -> Ok(#(Copy, rows)) 1012 + _ -> dec_err("invalid command", binary) 1013 + }) 1014 + 1015 + let #(command, rows_raw) = fine 1016 + let len = bit_array.byte_size(rows_raw) - 1 1017 + 1018 + use rows_bits <- try(case rows_raw { 1019 + <<rows_bits:bytes-size(len), 0>> -> Ok(rows_bits) 1020 + _ -> dec_err("invalid command row count", binary) 1021 + }) 1022 + 1023 + use rows_string <- try( 1024 + bit_array.to_string(rows_bits) 1025 + |> result.replace_error(msg_dec_err( 1026 + "failed to convert row count to string", 1027 + rows_bits, 1028 + )), 1029 + ) 1030 + 1031 + use rows <- try( 1032 + int.parse(rows_string) 1033 + |> result.replace_error(msg_dec_err( 1034 + "failed to convert row count to int", 1035 + rows_bits, 1036 + )), 1037 + ) 1038 + 1039 + Ok(BeCommandComplete(command, rows)) 1040 + } 1041 + |> result.or(Ok(BeCommandComplete(Insert, -1))) 1042 + } 1043 + 1044 + pub type TransactionStatus { 1045 + TransactionStatusIdle 1046 + TransactionStatusInTransaction 1047 + TransactionStatusFailed 1048 + } 1049 + 1050 + fn decode_parameter_description(count, binary, results) { 1051 + case count, binary { 1052 + 0, <<>> -> Ok(BeParameterDescription(list.reverse(results))) 1053 + _, <<value:32, tail:bytes>> -> 1054 + decode_parameter_description(count - 1, tail, [value, ..results]) 1055 + _, _ -> dec_err("invalid parameter description", binary) 1056 + } 1057 + } 1058 + 1059 + fn decode_notification_response( 1060 + process_id, 1061 + binary, 1062 + ) -> Result(BackendMessage, MessageDecodingError) { 1063 + use strings <- try(read_strings(binary, 2, [])) 1064 + case strings { 1065 + [channel, payload] -> 1066 + Ok(BeNotificationResponse( 1067 + process_id: process_id, 1068 + channel: channel, 1069 + payload: payload, 1070 + )) 1071 + _ -> dec_err("invalid notification response encoding", binary) 1072 + } 1073 + } 1074 + 1075 + fn decode_negotiate_protocol_version(version, count, binary) { 1076 + use options <- try(read_strings(binary, count, [])) 1077 + Ok(BeNegotiateProtocolVersion(version, options)) 1078 + } 1079 + 1080 + fn decode_row_descriptions(count, binary) { 1081 + use fields <- try(read_row_descriptions(count, binary, [])) 1082 + Ok(BeRowDescriptions(fields)) 1083 + } 1084 + 1085 + fn decode_parameter_status(binary) { 1086 + use strings <- try(decode_strings(binary)) 1087 + case strings { 1088 + [name, value] -> Ok(BeParameterStatus(name: name, value: value)) 1089 + _ -> dec_err("invalid parameter status", binary) 1090 + } 1091 + } 1092 + 1093 + fn decode_authentication_sasl(binary) { 1094 + use strings <- try(decode_strings(binary)) 1095 + Ok(BeAuthenticationSASL(strings)) 1096 + } 1097 + 1098 + fn decode_copy_response(direction, format_raw, count, rest) { 1099 + use overall_format <- try(decode_format(format_raw)) 1100 + use <- bool.guard( 1101 + bit_array.byte_size(rest) != count * 2, 1102 + dec_err("size must be count * 2", rest), 1103 + ) 1104 + 1105 + use codes <- try(decode_format_codes(rest, [])) 1106 + 1107 + case overall_format == Text { 1108 + False -> Ok(BeCopyResponse(direction, overall_format, codes)) 1109 + True -> 1110 + case list.all(codes, fn(code) { code == Text }) { 1111 + True -> Ok(BeCopyResponse(direction, overall_format, codes)) 1112 + False -> dec_err("invalid copy response format", rest) 1113 + } 1114 + } 1115 + } 1116 + 1117 + fn decode_format_codes( 1118 + binary: BitArray, 1119 + result: List(Format), 1120 + ) -> Result(List(Format), MessageDecodingError) { 1121 + case binary { 1122 + <<code:16, tail:bytes>> -> 1123 + case decode_format(code) { 1124 + Ok(format) -> decode_format_codes(tail, [format, ..result]) 1125 + Error(err) -> Error(err) 1126 + } 1127 + <<>> -> Ok(list.reverse(result)) 1128 + _ -> dec_err("invalid format codes", binary) 1129 + } 1130 + } 1131 + 1132 + fn decode_format(num: Int) { 1133 + case num { 1134 + 0 -> Ok(Text) 1135 + 1 -> Ok(Binary) 1136 + _ -> dec_err("invalid format code: " <> int.to_string(num), <<>>) 1137 + } 1138 + } 1139 + 1140 + fn read_format(binary) { 1141 + case binary { 1142 + <<0:16, rest:bytes>> -> Ok(#(Text, rest)) 1143 + <<1:16, rest:bytes>> -> Ok(#(Binary, rest)) 1144 + _ -> dec_err("invalid format code", binary) 1145 + } 1146 + } 1147 + 1148 + fn encode_format(format_raw) -> Int { 1149 + case format_raw { 1150 + Text -> 0 1151 + Binary -> 1 1152 + } 1153 + } 1154 + 1155 + pub type DataRow { 1156 + DataRow(List(BitArray)) 1157 + } 1158 + 1159 + fn decode_message_data_row( 1160 + count, 1161 + rest, 1162 + ) -> Result(BackendMessage, MessageDecodingError) { 1163 + case decode_message_data_row_rec(rest, count, []) { 1164 + Ok(cols) -> 1165 + case list.length(cols) == count { 1166 + True -> Ok(BeMessageDataRow(cols)) 1167 + False -> dec_err("column count doesn't match", rest) 1168 + } 1169 + Error(err) -> Error(err) 1170 + } 1171 + } 1172 + 1173 + fn decode_message_data_row_rec( 1174 + binary, 1175 + count, 1176 + result, 1177 + ) -> Result(List(BitArray), MessageDecodingError) { 1178 + case count, binary { 1179 + 0, _ -> Ok(list.reverse(result)) 1180 + _, <<length:32, value:bytes-size(length), rest:bytes>> -> 1181 + decode_message_data_row_rec(rest, count - 1, [value, ..result]) 1182 + _, _ -> 1183 + dec_err( 1184 + "failed to parse data row at count " <> int.to_string(count), 1185 + binary, 1186 + ) 1187 + } 1188 + } 1189 + 1190 + pub type RowDescriptionField { 1191 + RowDescriptionField( 1192 + // The field name. 1193 + name: String, 1194 + // If the field can be identified as a column of a specific table, the 1195 + // object ID of the table; otherwise zero. 1196 + table_oid: Int, 1197 + // If the field can be identified as a column of a specific table, the 1198 + // attribute number of the column; otherwise zero. 1199 + attr_number: Int, 1200 + // The object ID of the field's data type. 1201 + data_type_oid: Int, 1202 + // The data type size (see pg_type.typlen). Note that negative values denote 1203 + // variable-width types. 1204 + data_type_size: Int, 1205 + // The type modifier (see pg_attribute.atttypmod). The meaning of the 1206 + // modifier is type-specific. 1207 + type_modifier: Int, 1208 + // The format code being used for the field. Currently will be zero (text) 1209 + // or one (binary). In a RowDescription returned from the statement variant 1210 + // of Describe, the format code is not yet known and will always be zero. 1211 + format_code: Int, 1212 + ) 1213 + } 1214 + 1215 + fn read_row_descriptions(count, binary, result) { 1216 + case count, binary { 1217 + 0, <<>> -> Ok(list.reverse(result)) 1218 + _, <<>> -> dec_err("row description count mismatch", binary) 1219 + _, _ -> 1220 + case read_row_description_field(binary) { 1221 + Ok(#(field, tail)) -> 1222 + read_row_descriptions(count - 1, tail, [field, ..result]) 1223 + Error(err) -> Error(err) 1224 + } 1225 + } 1226 + } 1227 + 1228 + fn read_row_description_field(binary) { 1229 + case read_string(binary) { 1230 + Ok(#( 1231 + name, 1232 + << 1233 + table_oid:32, 1234 + attr_number:16, 1235 + data_type_oid:32, 1236 + data_type_size:16, 1237 + type_modifier:32, 1238 + format_code:16, 1239 + tail:bytes, 1240 + >>, 1241 + )) -> 1242 + Ok(#( 1243 + RowDescriptionField( 1244 + name: name, 1245 + table_oid: table_oid, 1246 + attr_number: attr_number, 1247 + data_type_oid: data_type_oid, 1248 + data_type_size: data_type_size, 1249 + type_modifier: type_modifier, 1250 + format_code: format_code, 1251 + ), 1252 + tail, 1253 + )) 1254 + Ok(#(_, tail)) -> dec_err("failed to parse row description field", tail) 1255 + Error(_) -> dec_err("failed to decode row description field name", binary) 1256 + } 1257 + } 1258 + 1259 + fn decode_strings(binary) { 1260 + let length = bit_array.byte_size(binary) - 1 1261 + case binary { 1262 + <<>> -> Ok([]) 1263 + <<head:bytes-size(length), 0>> -> { 1264 + binary_split(head, <<0>>, [Global]) 1265 + |> list.map(bit_array.to_string) 1266 + |> result.all() 1267 + |> result.replace_error(msg_dec_err("invalid strings encoding", binary)) 1268 + } 1269 + _ -> dec_err("string size didn't match", binary) 1270 + } 1271 + } 1272 + 1273 + fn read_strings(binary, count, result) { 1274 + case count { 1275 + 0 -> Ok(list.reverse(result)) 1276 + _ -> { 1277 + case read_string(binary) { 1278 + Ok(#(value, rest)) -> read_strings(rest, count - 1, [value, ..result]) 1279 + Error(err) -> Error(err) 1280 + } 1281 + } 1282 + } 1283 + } 1284 + 1285 + fn read_string(binary) { 1286 + case binary_split(binary, <<0>>, []) { 1287 + [<<>>, <<>>] -> Ok(#("", <<>>)) 1288 + [head, tail] -> { 1289 + bit_array.to_string(head) 1290 + |> result.replace_error(msg_dec_err("invalid string encoding", head)) 1291 + |> result.map(fn(s) { #(s, tail) }) 1292 + } 1293 + _ -> dec_err("invalid string", binary) 1294 + } 1295 + } 1296 + 1297 + fn decode_notice_response(binary) { 1298 + use fields <- try(decode_fields(binary)) 1299 + Ok(BeNoticeResponse(fields)) 1300 + } 1301 + 1302 + fn decode_error_response(binary) { 1303 + use fields <- try(decode_fields(binary)) 1304 + Ok(BeErrorResponse(fields)) 1305 + } 1306 + 1307 + pub type ErrorOrNoticeField { 1308 + Code(String) 1309 + Detail(String) 1310 + File(String) 1311 + Hint(String) 1312 + Line(String) 1313 + Message(String) 1314 + Position(String) 1315 + Routine(String) 1316 + SeverityLocalized(String) 1317 + Severity(String) 1318 + Where(String) 1319 + Column(String) 1320 + DataType(String) 1321 + Constraint(String) 1322 + InternalPosition(String) 1323 + InternalQuery(String) 1324 + Schema(String) 1325 + Table(String) 1326 + Unknown(key: BitArray, value: String) 1327 + } 1328 + 1329 + fn decode_fields(binary) { 1330 + case decode_fields_rec(binary, []) { 1331 + Ok(fields) -> 1332 + fields 1333 + |> list.map(fn(key_value_raw) { 1334 + let #(key, value) = key_value_raw 1335 + case key { 1336 + <<"S":utf8>> -> Severity(value) 1337 + <<"V":utf8>> -> SeverityLocalized(value) 1338 + <<"C":utf8>> -> Code(value) 1339 + <<"M":utf8>> -> Message(value) 1340 + <<"D":utf8>> -> Detail(value) 1341 + <<"H":utf8>> -> Hint(value) 1342 + <<"P":utf8>> -> Position(value) 1343 + <<"p":utf8>> -> InternalPosition(value) 1344 + <<"q":utf8>> -> InternalQuery(value) 1345 + <<"W":utf8>> -> Where(value) 1346 + <<"s":utf8>> -> Schema(value) 1347 + <<"t":utf8>> -> Table(value) 1348 + <<"c":utf8>> -> Column(value) 1349 + <<"d":utf8>> -> DataType(value) 1350 + <<"n":utf8>> -> Constraint(value) 1351 + <<"F":utf8>> -> File(value) 1352 + <<"L":utf8>> -> Line(value) 1353 + <<"R":utf8>> -> Routine(value) 1354 + _ -> Unknown(key, value) 1355 + } 1356 + }) 1357 + |> set.from_list() 1358 + |> Ok 1359 + Error(err) -> Error(err) 1360 + } 1361 + } 1362 + 1363 + fn decode_fields_rec(binary, result) { 1364 + case binary { 1365 + <<0>> | <<>> -> Ok(result) 1366 + <<field_type:bytes-size(1), rest:bytes>> -> { 1367 + case binary_split(rest, <<0>>, []) { 1368 + [head, tail] -> { 1369 + case bit_array.to_string(head) { 1370 + Ok(value) -> 1371 + decode_fields_rec(tail, [#(field_type, value), ..result]) 1372 + Error(Nil) -> dec_err("invalid field encoding", binary) 1373 + } 1374 + } 1375 + _ -> dec_err("invalid field separator", binary) 1376 + } 1377 + } 1378 + _ -> dec_err("invalid field", binary) 1379 + } 1380 + } 1381 + 1382 + type BinarySplitOption { 1383 + Global 1384 + } 1385 + 1386 + @external(erlang, "binary", "split") 1387 + fn binary_split( 1388 + subject: BitArray, 1389 + pattern: BitArray, 1390 + options: List(BinarySplitOption), 1391 + ) -> List(BitArray)
+638
src/squirrel/internal/error.gleam
··· 1 + import glam/doc.{type Document} 2 + import gleam/int 3 + import gleam/list 4 + import gleam/option.{type Option, None, Some} 5 + import gleam/regex 6 + import gleam/result 7 + import gleam/string 8 + import gleam_community/ansi 9 + import simplifile 10 + 11 + pub type Error { 12 + // --- POSTGRES RELATED ERRORS ----------------------------------------------- 13 + /// When authentication workflow goes wrong. 14 + /// TODO)) For now I only support no authentication so people might report 15 + /// this issue. 16 + /// 17 + PgCannotAuthenticate(expected: String, got: String) 18 + 19 + /// When there's an error with the underlying socket and I cannot send 20 + /// messages to the server. 21 + /// 22 + PgCannotSendMessage(reason: String) 23 + 24 + /// This comes from the `postgres_protocol` module, it happens if the server 25 + /// sends back a malformed message or there's a type of messages decoding is 26 + /// not implemented for. 27 + /// This should never happen and warrants a bug report! 28 + /// 29 + PgCannotDecodeReceivedMessage(reason: String) 30 + 31 + /// When there's an error with the underlying socket and I cannot receive 32 + /// messages to the server. 33 + /// 34 + PgCannotReceiveMessage(reason: String) 35 + 36 + /// When I cannot get a query description back from the postgres server. 37 + /// 38 + PgCannotDescribeQuery( 39 + file: String, 40 + query_name: String, 41 + expected: String, 42 + got: String, 43 + ) 44 + 45 + // --- OTHER GENERIC ERRORS -------------------------------------------------- 46 + /// When I cannot read a file containing queries. 47 + /// 48 + CannotReadFile(file: String, reason: simplifile.FileError) 49 + 50 + /// When the generated code cannot be written to a file. 51 + /// 52 + CannotWriteToFile(file: String, reason: simplifile.FileError) 53 + 54 + /// If an ".sql" file holding a query has a name that is not a valid Gleam 55 + /// name. 56 + /// Instead of trying to magically come up with a name we fail and report the 57 + /// error. 58 + /// 59 + QueryFileHasInvalidName( 60 + file: String, 61 + suggested_name: Option(String), 62 + reason: ValueIdentifierError, 63 + ) 64 + 65 + /// If a query returns a column that is not a valid Gleam identifier. Instead 66 + /// of trying to magically come up with a name we fail and report the error. 67 + /// 68 + QueryHasInvalidColumn( 69 + file: String, 70 + column_name: String, 71 + suggested_name: Option(String), 72 + content: String, 73 + starting_line: Int, 74 + reason: ValueIdentifierError, 75 + ) 76 + 77 + /// When there's a param/return type that cannot be converted into a Gleam 78 + /// type. 79 + /// 80 + QueryHasUnsupportedType( 81 + file: String, 82 + name: String, 83 + content: String, 84 + starting_line: Int, 85 + type_: String, 86 + ) 87 + 88 + /// If the query contains an error and cannot be parsed by the DBMS. 89 + /// 90 + CannotParseQuery( 91 + file: String, 92 + name: String, 93 + content: String, 94 + starting_line: Int, 95 + error_code: Option(String), 96 + pointer: Option(Pointer), 97 + hint: Option(String), 98 + ) 99 + } 100 + 101 + pub type ValueIdentifierError { 102 + DoesntStartWithLowercaseLetter 103 + ContainsInvalidGrapheme(at: Int, grapheme: String) 104 + IsEmpty 105 + } 106 + 107 + /// Used to literally point to a particular piece of a string and attach a 108 + /// message to that point. 109 + /// 110 + pub type Pointer { 111 + Pointer(point_to: PointerKind, message: String) 112 + } 113 + 114 + /// A pointer could either point to a specific byte of a String or it could 115 + /// point at a specific word (in that case it will point to the first occurrence 116 + /// of such word). 117 + /// 118 + pub type PointerKind { 119 + Name(name: String) 120 + ByteIndex(position: Int) 121 + } 122 + 123 + pub fn to_doc(error: Error) -> Document { 124 + // Errors as they are, are not that easy to print. What we do here is turn 125 + // each error into an easier-to-print data structure: a `PrintableError` 126 + // using a nice declarative API. So we can ignore all the gory details of how 127 + // that is actually printed and we do not have to make any effort to add and 128 + // print new errors. 129 + let printable_error = case error { 130 + PgCannotSendMessage(reason: reason) -> 131 + printable_error("Cannot send message") 132 + |> add_paragraph( 133 + "I ran into an unexpected error while trying to talk to the Postgres 134 + database server.", 135 + ) 136 + |> report_bug(reason) 137 + 138 + PgCannotDecodeReceivedMessage(reason: reason) -> 139 + printable_error("Cannot decode message") 140 + |> add_paragraph( 141 + "I ran into an unexpected error while trying to decode a message 142 + received from the Postgres database server.", 143 + ) 144 + |> report_bug(reason) 145 + 146 + PgCannotReceiveMessage(reason: reason) -> 147 + printable_error("Cannot receive message") 148 + |> add_paragraph( 149 + "I ran into an unexpected error while trying to listen to the Postgres 150 + database server.", 151 + ) 152 + |> report_bug(reason) 153 + 154 + CannotReadFile(file: file, reason: reason) -> 155 + printable_error("Cannot read file") 156 + |> add_paragraph( 157 + "I couldn't read " 158 + <> style_file(file) 159 + <> " because of the following error: " 160 + <> simplifile.describe_error(reason), 161 + ) 162 + 163 + CannotWriteToFile(file: file, reason: reason) -> 164 + printable_error("Cannot write to file") 165 + |> add_paragraph( 166 + "I couldn't write to " 167 + <> style_file(file) 168 + <> " because of the following error: " 169 + <> simplifile.describe_error(reason), 170 + ) 171 + 172 + QueryFileHasInvalidName( 173 + file: file, 174 + suggested_name: suggested_name, 175 + reason: _, 176 + ) -> 177 + printable_error("Query file with invalid name") 178 + |> add_paragraph( 179 + "File " <> style_file(file) <> " doesn't have a valid name. 180 + The name of a file is used to generate a corresponding Gleam function, so it 181 + should be a valid Gleam name.", 182 + ) 183 + |> hint("A file name must start with a lowercase letter and can only 184 + contain lowercase letters, numbers and underscores." <> case suggested_name { 185 + Some(name) -> 186 + "\nMaybe try renaming it to " <> style_inline_code(name) <> "?" 187 + None -> "" 188 + }) 189 + 190 + QueryHasInvalidColumn( 191 + file: file, 192 + column_name: column_name, 193 + suggested_name: suggested_name, 194 + content: content, 195 + reason: reason, 196 + starting_line: starting_line, 197 + ) -> 198 + case reason { 199 + IsEmpty -> 200 + printable_error("Column with empty name") 201 + |> add_code_paragraph( 202 + file: file, 203 + content: content, 204 + point: None, 205 + starting_line: starting_line, 206 + ) 207 + |> add_paragraph( 208 + "A column returned by this query has the empty string as a name, 209 + all columns should have a valid Gleam name as name.", 210 + ) 211 + 212 + _ -> 213 + printable_error("Column with invalid name") 214 + |> add_code_paragraph( 215 + file: file, 216 + content: content, 217 + starting_line: starting_line, 218 + point: Some( 219 + Pointer(point_to: Name(column_name), message: case 220 + suggested_name 221 + { 222 + None -> "This is not a valid Gleam name" 223 + Some(suggestion) -> 224 + "This is not a valid Gleam name, maybe try " 225 + <> style_inline_code(suggestion) 226 + <> "?" 227 + }), 228 + ), 229 + ) 230 + |> hint( 231 + "A column name must start with a lowercase letter and can only 232 + contain lowercase letters, numbers and underscores.", 233 + ) 234 + } 235 + 236 + QueryHasUnsupportedType( 237 + file: file, 238 + name: _, 239 + content: content, 240 + type_: type_, 241 + starting_line: starting_line, 242 + ) -> 243 + printable_error("Unsupported type") 244 + |> add_code_paragraph( 245 + file: file, 246 + content: content, 247 + point: None, 248 + starting_line: starting_line, 249 + ) 250 + |> add_paragraph( 251 + "One of the rows returned by this query has type " 252 + <> style_inline_code(type_) 253 + <> " which I cannot currently generate code for.", 254 + ) 255 + |> call_to_action(for: "this type to be supported") 256 + 257 + CannotParseQuery( 258 + file: file, 259 + name: _name, 260 + content: content, 261 + starting_line: starting_line, 262 + error_code: error_code, 263 + hint: hint, 264 + pointer: pointer, 265 + ) -> 266 + printable_error(case error_code { 267 + Some(code) -> "Invalid query [" <> code <> "]" 268 + None -> "Invalid query" 269 + }) 270 + |> add_code_paragraph( 271 + file: file, 272 + content: content, 273 + point: pointer, 274 + starting_line: starting_line, 275 + ) 276 + |> maybe_hint(hint) 277 + 278 + PgCannotAuthenticate(expected: expected, got: got) -> 279 + printable_error("Cannot authenticate") 280 + |> add_paragraph( 281 + "I ran into an unexpected problem while trying to authenticate with the 282 + Postgres server. This is most definitely a bug!", 283 + ) 284 + |> report_bug("Expected: " <> expected <> ", Got: " <> got) 285 + 286 + PgCannotDescribeQuery( 287 + file: file, 288 + query_name: query_name, 289 + expected: expected, 290 + got: got, 291 + ) -> 292 + printable_error("Cannot inspect query") 293 + |> add_paragraph("I ran into an unexpected problem while trying to figure 294 + out the types of query " <> style_inline_code(query_name) <> " 295 + defined in " <> style_file(file) <> ". This is most definitely a bug!") 296 + |> report_bug("Expected: " <> expected <> ", Got: " <> got) 297 + } 298 + 299 + printable_error_to_doc(printable_error) 300 + } 301 + 302 + fn style_file(file: String) -> String { 303 + ansi.underline(file) 304 + } 305 + 306 + fn style_inline_code(code: String) -> String { 307 + "`" <> code <> "`" 308 + } 309 + 310 + fn style_link(link: String) -> String { 311 + ansi.underline(link) 312 + } 313 + 314 + // --- ERROR PRETTY PRINTING --------------------------------------------------- 315 + 316 + const indent = 2 317 + 318 + type PrintableError { 319 + PrintableError( 320 + title: String, 321 + body: List(Paragraph), 322 + report_bug: Option(String), 323 + call_to_action: Option(String), 324 + hint: Option(String), 325 + ) 326 + } 327 + 328 + type Paragraph { 329 + Simple(String) 330 + Code( 331 + file: String, 332 + content: String, 333 + pointer: Option(Pointer), 334 + starting_line: Int, 335 + ) 336 + } 337 + 338 + /// A default printable error with just a title. 339 + /// 340 + fn printable_error(title: String) -> PrintableError { 341 + PrintableError( 342 + title: title, 343 + body: [], 344 + report_bug: None, 345 + hint: None, 346 + call_to_action: None, 347 + ) 348 + } 349 + 350 + fn add_paragraph(error: PrintableError, string: String) -> PrintableError { 351 + PrintableError(..error, body: list.append(error.body, [Simple(string)])) 352 + } 353 + 354 + fn add_code_paragraph( 355 + error: PrintableError, 356 + file file: String, 357 + content content: String, 358 + point point: Option(Pointer), 359 + starting_line starting_line: Int, 360 + ) -> PrintableError { 361 + PrintableError( 362 + ..error, 363 + body: list.append(error.body, [ 364 + Code( 365 + file: file, 366 + content: content, 367 + pointer: point, 368 + starting_line: starting_line, 369 + ), 370 + ]), 371 + ) 372 + } 373 + 374 + /// Sets a call to action to report a specific bug. 375 + /// 376 + fn report_bug(error: PrintableError, report_bug: String) -> PrintableError { 377 + PrintableError(..error, report_bug: Some(report_bug)) 378 + } 379 + 380 + /// Sets a hint that will be displayed at the bottom of the error message. 381 + /// 382 + fn hint(error: PrintableError, hint: String) -> PrintableError { 383 + PrintableError(..error, hint: Some(hint)) 384 + } 385 + 386 + /// Sets a hint that will be displayed at the bottom of the error message. 387 + /// 388 + fn maybe_hint(error: PrintableError, hint: Option(String)) -> PrintableError { 389 + PrintableError(..error, hint: hint) 390 + } 391 + 392 + /// Given something a user might want to be added to the package it sets a 393 + /// call to action message telling someone to open a ticket on the `squirrel` 394 + /// repo. 395 + /// 396 + fn call_to_action(error: PrintableError, for wanted: String) -> PrintableError { 397 + PrintableError(..error, call_to_action: Some(wanted)) 398 + } 399 + 400 + fn printable_error_to_doc(error: PrintableError) -> Document { 401 + // And now for the tricky bit... 402 + let PrintableError( 403 + title: title, 404 + body: body, 405 + report_bug: report_bug, 406 + call_to_action: call_to_action, 407 + hint: hint, 408 + ) = error 409 + 410 + [ 411 + title_doc(title), 412 + body_doc(body), 413 + option_to_doc(report_bug, report_bug_doc), 414 + option_to_doc(call_to_action, call_to_action_doc), 415 + option_to_doc(hint, hint_doc), 416 + ] 417 + |> list.filter(keeping: fn(doc) { doc != doc.empty }) 418 + |> doc.join(with: doc.lines(2)) 419 + |> doc.group 420 + } 421 + 422 + fn title_doc(title: String) -> Document { 423 + doc.from_string(ansi.red(ansi.bold("Error: ") <> title)) 424 + } 425 + 426 + fn body_doc(body: List(Paragraph)) -> Document { 427 + list.map(body, paragraph_doc) 428 + |> doc.join(with: doc.line) 429 + |> doc.group 430 + } 431 + 432 + fn paragraph_doc(paragraph: Paragraph) -> Document { 433 + case paragraph { 434 + Simple(string) -> flexible_string(string) 435 + Code( 436 + file: file, 437 + content: content, 438 + pointer: pointer, 439 + starting_line: starting_line, 440 + ) -> 441 + code_doc( 442 + file: file, 443 + content: content, 444 + pointer: pointer, 445 + starting_line: starting_line, 446 + ) 447 + } 448 + } 449 + 450 + fn code_doc( 451 + file file: String, 452 + content content: String, 453 + pointer pointer: Option(Pointer), 454 + starting_line starting_line: Int, 455 + ) { 456 + let pointer = 457 + option.to_result(pointer, Nil) 458 + |> result.then(pointer_doc(_, content)) 459 + 460 + let content = syntax_highlight(content) 461 + let lines = string.split(content, on: "\n") 462 + let lines_count = list.length(lines) 463 + let assert Ok(digits) = int.digits(lines_count + starting_line, 10) 464 + let max_digits = list.length(digits) 465 + 466 + let code_lines = { 467 + use line, i <- list.index_map(lines) 468 + let prefix = 469 + int.to_string(i + starting_line) 470 + |> string.pad_left(to: max_digits + 2, with: " ") 471 + 472 + case pointer { 473 + Ok(#(pointer_line, from, pointer_doc)) if pointer_line == i -> [ 474 + doc.from_string(ansi.dim(prefix <> " │ ")), 475 + doc.from_string(line), 476 + [doc.line, pointer_doc] 477 + |> doc.concat 478 + |> doc.nest(by: from + max_digits + 5), 479 + ] 480 + 481 + Ok(_) | Error(_) -> [ 482 + doc.from_string(ansi.dim(prefix <> " │ ")), 483 + doc.from_string(ansi.dim(line)), 484 + ] 485 + } 486 + |> doc.concat 487 + } 488 + 489 + let padding = string.repeat(" ", max_digits + 3) 490 + [ 491 + doc.from_string(padding <> ansi.dim("╭─ " <> file)), 492 + case starting_line { 493 + 1 -> doc.from_string(padding <> ansi.dim("│ ")) 494 + _ -> doc.from_string(padding <> ansi.dim("┆ ")) 495 + }, 496 + ..code_lines 497 + ] 498 + |> doc.join(with: doc.line) 499 + |> doc.append(doc.line) 500 + |> doc.append(doc.from_string(padding <> ansi.dim("┆"))) 501 + |> doc.group 502 + } 503 + 504 + fn pointer_doc( 505 + pointer: Pointer, 506 + content: String, 507 + ) -> Result(#(Int, Int, Document), Nil) { 508 + let Pointer(kind, message) = pointer 509 + use #(line, from, to) <- result.try(find_span(kind, content)) 510 + let width = to - from + 1 511 + let doc = 512 + [ 513 + doc.zero_width_string("\u{001B}[31m"), 514 + doc.from_string("┬" <> string.repeat("─", width - 1)), 515 + doc.line, 516 + doc.from_string("╰─ "), 517 + flexible_string(message) 518 + |> doc.nest(by: 3), 519 + doc.zero_width_string("\u{001B}[0m"), 520 + ] 521 + |> doc.concat 522 + |> doc.group 523 + 524 + Ok(#(line, from, doc)) 525 + } 526 + 527 + fn find_span(kind: PointerKind, string: String) -> Result(#(Int, Int, Int), Nil) { 528 + case kind { 529 + Name(name) -> find_name_span(name, string.length(name), string, 0, 0) 530 + ByteIndex(n) -> find_byte_span(n - 1, string, 0, 0) 531 + } 532 + } 533 + 534 + fn find_name_span( 535 + name: String, 536 + name_len: Int, 537 + string: String, 538 + row: Int, 539 + col: Int, 540 + ) -> Result(#(Int, Int, Int), Nil) { 541 + case string.starts_with(string, name) { 542 + True -> Ok(#(row, col, col + name_len - 1)) 543 + False -> 544 + case string.pop_grapheme(string) { 545 + Ok(#("\n", rest)) -> find_name_span(name, name_len, rest, row + 1, 0) 546 + Ok(#(_, rest)) -> find_name_span(name, name_len, rest, row, col + 1) 547 + Error(_) -> Error(Nil) 548 + } 549 + } 550 + } 551 + 552 + fn find_byte_span( 553 + position: Int, 554 + string: String, 555 + row: Int, 556 + col: Int, 557 + ) -> Result(#(Int, Int, Int), Nil) { 558 + case position { 559 + 0 -> Ok(#(row, col, col)) 560 + n -> 561 + case string.pop_grapheme(string) { 562 + Ok(#("\n", rest)) -> find_byte_span(n - 1, rest, row + 1, 0) 563 + Ok(#(_, rest)) -> find_byte_span(n - 1, rest, row, col + 1) 564 + Error(_) -> Error(Nil) 565 + } 566 + } 567 + } 568 + 569 + const keywords = [ 570 + "and", "any", "as", "asc", "begin", "between", "by", "case", "count", "desc", 571 + "distinct", "else", "end", "exists", "from", "full", "group", "having", "if", 572 + "in", "inner", "insert", "into", "join", "key", "left", "like", "not", "null", 573 + "on", "or", "order", "primary", "revert", "right", "select", "set", "table", 574 + "top", "trigger", "union", "update", "use", "values", "view", "where", "with", 575 + ] 576 + 577 + fn syntax_highlight(content: String) -> String { 578 + let keywords = string.join(keywords, with: "|") 579 + let not_inside_string = "(?=(?:[^']*'[^']*')*[^']*$)" 580 + 581 + let assert Ok(keyword) = 582 + { "\\b(" <> keywords <> ")\\b" <> not_inside_string } 583 + |> regex.compile(regex.Options(True, False)) 584 + let assert Ok(number) = 585 + regex.from_string("(?<!\\$)\\b(\\d+(\\.\\d+)?\\b)" <> not_inside_string) 586 + let assert Ok(comment) = regex.from_string("(^\\s*--.*)") 587 + let assert Ok(string) = regex.from_string("(\\'.*\\')") 588 + let assert Ok(hole) = regex.from_string("(\\$\\d+)" <> not_inside_string) 589 + 590 + content 591 + |> regex.replace(each: comment, with: "\u{001B}[2m\\1\u{001B}[0m") 592 + |> regex.replace(each: keyword, with: "\u{001B}[36m\\1\u{001B}[39m") 593 + |> regex.replace(each: string, with: "\u{001B}[33m\\1\u{001B}[39m") 594 + |> regex.replace(each: number, with: "\u{001B}[32m\\1\u{001B}[39m") 595 + |> regex.replace(each: hole, with: "\u{001B}[35m\\1\u{001B}[39m") 596 + } 597 + 598 + fn report_bug_doc(additional_info: String) -> Document { 599 + [ 600 + flexible_string( 601 + "Please open an issue at " 602 + <> style_link("https://github.com/giacomocavalieri/squirrel/issues/new") 603 + <> " with some details about what you where doing, including the following message:", 604 + ), 605 + doc.line |> doc.nest(by: indent), 606 + doc.from_string(additional_info), 607 + ] 608 + |> doc.concat 609 + |> doc.group 610 + } 611 + 612 + fn call_to_action_doc(wanted: String) -> Document { 613 + flexible_string( 614 + "If you would like for " 615 + <> wanted 616 + <> ", please open an issue at " 617 + <> style_link("https://github.com/giacomocavalieri/squirrel/issues/new"), 618 + ) 619 + } 620 + 621 + fn hint_doc(hint: String) -> Document { 622 + flexible_string("Hint: " <> hint) 623 + } 624 + 625 + fn flexible_string(string: String) -> Document { 626 + string.split(string, on: "\n") 627 + |> list.flat_map(string.split(_, on: " ")) 628 + |> list.map(doc.from_string) 629 + |> doc.join(with: doc.flex_space) 630 + |> doc.group 631 + } 632 + 633 + fn option_to_doc(option: Option(a), fun: fn(a) -> Document) -> Document { 634 + case option { 635 + Some(a) -> fun(a) 636 + None -> doc.empty 637 + } 638 + }
+77
src/squirrel/internal/eval_extra.gleam
··· 1 + //// This package has some additional helpers to work with the `Eval` package. 2 + //// 3 + 4 + import eval.{type Eval} 5 + import gleam/list 6 + 7 + pub fn try_map( 8 + list: List(a), 9 + fun: fn(a) -> Eval(b, _, _), 10 + ) -> Eval(List(b), _, _) { 11 + try_fold(list, [], fn(acc, item) { 12 + use mapped_item <- eval.try(fun(item)) 13 + eval.return([mapped_item, ..acc]) 14 + }) 15 + |> eval.map(list.reverse) 16 + } 17 + 18 + /// Runs a list of `Eval` actions in sequence sharing the same context. 19 + /// 20 + pub fn run_all(list: List(Eval(a, b, c)), context: c) -> List(Result(a, b)) { 21 + let acc = #([], context) 22 + let #(results, _) = { 23 + use #(results, context), script <- list.fold(list, acc) 24 + let #(context, result) = eval.step(script, context) 25 + #([result, ..results], context) 26 + } 27 + 28 + results 29 + } 30 + 31 + pub fn try_index_map( 32 + list: List(a), 33 + fun: fn(a, Int) -> Eval(b, _, _), 34 + ) -> Eval(List(b), _, _) { 35 + try_index_fold(list, [], fn(acc, item, i) { 36 + use mapped_item <- eval.try(fun(item, i)) 37 + eval.return([mapped_item, ..acc]) 38 + }) 39 + |> eval.map(list.reverse) 40 + } 41 + 42 + pub fn try_fold( 43 + over list: List(a), 44 + from acc: b, 45 + with fun: fn(b, a) -> Eval(b, _, _), 46 + ) -> Eval(b, _, _) { 47 + case list { 48 + [] -> eval.return(acc) 49 + [first, ..rest] -> { 50 + use acc <- eval.try(fun(acc, first)) 51 + try_fold(rest, acc, fun) 52 + } 53 + } 54 + } 55 + 56 + fn try_index_fold( 57 + over list: List(a), 58 + from acc: b, 59 + with fun: fn(b, a, Int) -> Eval(b, _, _), 60 + ) -> Eval(b, _, _) { 61 + do_try_index_fold(0, over: list, from: acc, with: fun) 62 + } 63 + 64 + fn do_try_index_fold( 65 + index: Int, 66 + over list: List(a), 67 + from acc: b, 68 + with fun: fn(b, a, Int) -> Eval(b, _, _), 69 + ) -> Eval(b, _, _) { 70 + case list { 71 + [] -> eval.return(acc) 72 + [first, ..rest] -> { 73 + use acc <- eval.try(fun(acc, first, index)) 74 + do_try_index_fold(index + 1, rest, acc, fun) 75 + } 76 + } 77 + }
+224
src/squirrel/internal/gleam.gleam
··· 1 + import gleam/list 2 + import gleam/string 3 + import justin 4 + import squirrel/internal/error.{ 5 + type ValueIdentifierError, ContainsInvalidGrapheme, IsEmpty, 6 + } 7 + 8 + /// A Gleam type. 9 + /// 10 + pub type Type { 11 + List(Type) 12 + Option(Type) 13 + Int 14 + Float 15 + Bool 16 + String 17 + } 18 + 19 + /// The labelled field of a Gleam record. 20 + /// 21 + pub type Field { 22 + Field(label: ValueIdentifier, type_: Type) 23 + } 24 + 25 + /// A Gleam identifier, that is a string that starts with a lowercase letter, 26 + /// is in snake_case and can only contain lowercase letters, numbers and 27 + /// underscores. 28 + /// 29 + /// > 💡 This can only be built using the `gleam.identifier` function that 30 + /// > ensures that a string is a valid Gleam identifier. 31 + /// 32 + pub opaque type ValueIdentifier { 33 + ValueIdentifier(String) 34 + } 35 + 36 + /// Returns true if the given string is a valid Gleam identifier (that is not 37 + /// a discard identifier, that is starting with an '_'). 38 + /// 39 + /// > 💡 A valid identifier can be described by the following regex: 40 + /// > `[a-z][a-z0-9_]*`. 41 + pub fn identifier( 42 + from name: String, 43 + ) -> Result(ValueIdentifier, ValueIdentifierError) { 44 + // A valid identifier needs to start with a lowercase letter. 45 + // We do not accept _discard identifier as valid. 46 + case name { 47 + "a" <> rest 48 + | "b" <> rest 49 + | "c" <> rest 50 + | "d" <> rest 51 + | "e" <> rest 52 + | "f" <> rest 53 + | "g" <> rest 54 + | "h" <> rest 55 + | "i" <> rest 56 + | "j" <> rest 57 + | "k" <> rest 58 + | "l" <> rest 59 + | "m" <> rest 60 + | "n" <> rest 61 + | "o" <> rest 62 + | "p" <> rest 63 + | "q" <> rest 64 + | "r" <> rest 65 + | "s" <> rest 66 + | "t" <> rest 67 + | "u" <> rest 68 + | "v" <> rest 69 + | "w" <> rest 70 + | "x" <> rest 71 + | "y" <> rest 72 + | "z" <> rest -> to_identifier_rest(name, rest, 1) 73 + _ -> 74 + case string.pop_grapheme(name) { 75 + Ok(#(g, _)) -> Error(ContainsInvalidGrapheme(0, g)) 76 + Error(_) -> Error(IsEmpty) 77 + } 78 + } 79 + } 80 + 81 + fn to_identifier_rest( 82 + name: String, 83 + rest: String, 84 + position: Int, 85 + ) -> Result(ValueIdentifier, ValueIdentifierError) { 86 + // The rest of an identifier can only contain lowercase letters, _, numbers, 87 + // or be empty. In all other cases it's not valid. 88 + case rest { 89 + "a" <> rest 90 + | "b" <> rest 91 + | "c" <> rest 92 + | "d" <> rest 93 + | "e" <> rest 94 + | "f" <> rest 95 + | "g" <> rest 96 + | "h" <> rest 97 + | "i" <> rest 98 + | "j" <> rest 99 + | "k" <> rest 100 + | "l" <> rest 101 + | "m" <> rest 102 + | "n" <> rest 103 + | "o" <> rest 104 + | "p" <> rest 105 + | "q" <> rest 106 + | "r" <> rest 107 + | "s" <> rest 108 + | "t" <> rest 109 + | "u" <> rest 110 + | "v" <> rest 111 + | "w" <> rest 112 + | "x" <> rest 113 + | "y" <> rest 114 + | "z" <> rest 115 + | "_" <> rest 116 + | "0" <> rest 117 + | "1" <> rest 118 + | "2" <> rest 119 + | "3" <> rest 120 + | "4" <> rest 121 + | "5" <> rest 122 + | "6" <> rest 123 + | "7" <> rest 124 + | "8" <> rest 125 + | "9" <> rest -> to_identifier_rest(name, rest, position + 1) 126 + "" -> Ok(ValueIdentifier(name)) 127 + _ -> 128 + case string.pop_grapheme(rest) { 129 + Ok(#(g, _)) -> Error(ContainsInvalidGrapheme(position, g)) 130 + Error(_) -> panic as "unreachable: empty identifier rest should be ok" 131 + } 132 + } 133 + } 134 + 135 + /// Turns an identifier back into a String. 136 + /// 137 + pub fn identifier_to_string(identifier: ValueIdentifier) -> String { 138 + let ValueIdentifier(name) = identifier 139 + name 140 + } 141 + 142 + /// Turns a Gleam identifier into a type name. That is it strips it of all its 143 + /// underscores and makes it PascalCase. 144 + /// 145 + pub fn identifier_to_type_name(identifier: ValueIdentifier) -> String { 146 + let ValueIdentifier(name) = identifier 147 + 148 + justin.pascal_case(name) 149 + |> string.to_graphemes 150 + // We want to remove any leftover "_" that might still be present after the 151 + // conversion if the identifier had consecutive "_". 152 + |> list.filter(keeping: fn(c) { c != "_" }) 153 + |> string.join(with: "") 154 + } 155 + 156 + /// Tries to suggest a valid Gleam identifier as similar as possible to a given 157 + /// String. 158 + /// 159 + /// If it cannot come up with a suggestion, it returns `Error(Nil)`. 160 + /// 161 + pub fn similar_identifier_string(string: String) -> Result(String, Nil) { 162 + let proposal = 163 + string.trim(string) 164 + |> justin.snake_case 165 + |> string.to_graphemes 166 + |> list.drop_while(fn(g) { g == "_" || is_digit(g) }) 167 + |> list.filter(keeping: is_identifier_char) 168 + |> string.join(with: "") 169 + 170 + case proposal { 171 + "" -> Error(Nil) 172 + _ -> Ok(proposal) 173 + } 174 + } 175 + 176 + fn is_digit(char: String) -> Bool { 177 + case char { 178 + "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True 179 + _ -> False 180 + } 181 + } 182 + 183 + fn is_identifier_char(char: String) -> Bool { 184 + case char { 185 + "a" 186 + | "b" 187 + | "c" 188 + | "d" 189 + | "e" 190 + | "f" 191 + | "g" 192 + | "h" 193 + | "i" 194 + | "j" 195 + | "k" 196 + | "l" 197 + | "m" 198 + | "n" 199 + | "o" 200 + | "p" 201 + | "q" 202 + | "r" 203 + | "s" 204 + | "t" 205 + | "u" 206 + | "v" 207 + | "w" 208 + | "x" 209 + | "y" 210 + | "z" 211 + | "_" 212 + | "0" 213 + | "1" 214 + | "2" 215 + | "3" 216 + | "4" 217 + | "5" 218 + | "6" 219 + | "7" 220 + | "8" 221 + | "9" -> True 222 + _ -> False 223 + } 224 + }
+418
src/squirrel/internal/query.gleam
··· 1 + import filepath 2 + import glam/doc.{type Document} 3 + import gleam/int 4 + import gleam/list 5 + import gleam/option.{type Option} 6 + import gleam/result 7 + import gleam/string 8 + import simplifile 9 + import squirrel/internal/error.{ 10 + type Error, CannotReadFile, QueryFileHasInvalidName, 11 + } 12 + import squirrel/internal/gleam 13 + 14 + /// A query that still needs to go through the type checking process. 15 + /// 16 + pub type UntypedQuery { 17 + UntypedQuery( 18 + /// The file the query comes from. 19 + /// 20 + file: String, 21 + /// The starting line in the source file where the query is defined. 22 + /// 23 + starting_line: Int, 24 + /// The name of the query, it must be a valid Gleam identifier. 25 + /// 26 + name: gleam.ValueIdentifier, 27 + /// Any comment lines that were preceding the query in the file. 28 + /// 29 + comment: List(String), 30 + /// The text of the query itself. 31 + /// 32 + content: String, 33 + ) 34 + } 35 + 36 + /// This is exactly the same as an untyped query with the difference that it 37 + /// has also been annotated with the type of its parameters and returned values. 38 + /// 39 + pub type TypedQuery { 40 + TypedQuery( 41 + file: String, 42 + starting_line: Int, 43 + name: gleam.ValueIdentifier, 44 + comment: List(String), 45 + content: String, 46 + params: List(gleam.Type), 47 + returns: List(gleam.Field), 48 + ) 49 + } 50 + 51 + /// Turns an untyped query into a typed one. 52 + /// 53 + pub fn add_types( 54 + to query: UntypedQuery, 55 + params params: List(gleam.Type), 56 + returns returns: List(gleam.Field), 57 + ) -> TypedQuery { 58 + let UntypedQuery( 59 + file: file, 60 + name: name, 61 + comment: comment, 62 + content: content, 63 + starting_line: starting_line, 64 + ) = query 65 + TypedQuery( 66 + file: file, 67 + name: name, 68 + comment: comment, 69 + content: content, 70 + starting_line: starting_line, 71 + params: params, 72 + returns: returns, 73 + ) 74 + } 75 + 76 + // --- PARSING ----------------------------------------------------------------- 77 + 78 + /// Reads a query from a file. 79 + /// This expects the user to follow the convention of having a single query per 80 + /// file. 81 + /// 82 + pub fn from_file(file: String) -> Result(UntypedQuery, Error) { 83 + let read_file = 84 + simplifile.read(file) 85 + |> result.map_error(CannotReadFile(file, _)) 86 + 87 + use content <- result.try(read_file) 88 + 89 + // A query always starts at the top of the file. 90 + // If in the future I want to add support for many queries per file this 91 + // field will be handy to properly show error messages. 92 + let file_name = 93 + filepath.base_name(file) 94 + |> filepath.strip_extension 95 + let name = 96 + gleam.identifier(file_name) 97 + |> result.map_error(QueryFileHasInvalidName( 98 + file: file, 99 + reason: _, 100 + suggested_name: gleam.similar_identifier_string(file_name) 101 + |> option.from_result, 102 + )) 103 + 104 + use name <- result.try(name) 105 + Ok(UntypedQuery( 106 + file: file, 107 + starting_line: 1, 108 + name: name, 109 + content: content, 110 + comment: take_comment(content), 111 + )) 112 + } 113 + 114 + fn take_comment(query: String) -> List(String) { 115 + do_take_comment(query, []) 116 + } 117 + 118 + fn do_take_comment(query: String, lines: List(String)) -> List(String) { 119 + case string.trim_left(query) { 120 + "--" <> rest -> 121 + case string.split_once(rest, on: "\n") { 122 + Ok(#(line, rest)) -> do_take_comment(rest, [string.trim(line), ..lines]) 123 + _ -> do_take_comment("", [string.trim(rest), ..lines]) 124 + } 125 + _ -> list.reverse(lines) 126 + } 127 + } 128 + 129 + // --- CODE GENERATION --------------------------------------------------------- 130 + 131 + pub fn generate_code(version: String, query: TypedQuery) -> String { 132 + let TypedQuery( 133 + file: file, 134 + name: name, 135 + content: content, 136 + comment: comment, 137 + params: params, 138 + returns: returns, 139 + starting_line: _, 140 + ) = query 141 + 142 + let arg_name = fn(i) { "arg_" <> int.to_string(i + 1) } 143 + let inputs = list.index_map(params, fn(_, i) { arg_name(i) }) 144 + let inputs_encoders = 145 + list.index_map(params, fn(p, i) { 146 + gleam_type_to_encoder(p, arg_name(i)) |> doc.from_string 147 + }) 148 + 149 + let function_name = gleam.identifier_to_string(name) 150 + let constructor_name = gleam.identifier_to_type_name(name) <> "Row" 151 + 152 + let record_doc = 153 + "/// A row you get from running the `" <> function_name <> "` query 154 + /// defined in `" <> file <> "`. 155 + /// 156 + /// > 🐿️ This type definition was generated automatically using " <> version <> " of the 157 + /// > [squirrel package](https://github.com/giacomocavalieri/squirrel). 158 + ///" 159 + 160 + let fun_doc = case comment { 161 + [] -> "/// Runs the `" <> function_name <> "` query 162 + /// defined in `" <> file <> "`." 163 + [_, ..] -> 164 + list.map(comment, string.append("/// ", _)) 165 + |> string.join(with: "\n") 166 + } 167 + let fun_doc = fun_doc <> " 168 + /// 169 + /// > 🐿️ This function was generated automatically using " <> version <> " of 170 + /// > the [squirrel package](https://github.com/giacomocavalieri/squirrel). 171 + ///" 172 + 173 + [ 174 + doc.from_string(record_doc), 175 + doc.line, 176 + record(constructor_name, returns), 177 + doc.lines(2), 178 + doc.from_string(fun_doc), 179 + doc.line, 180 + fun(function_name, ["db", ..inputs], [ 181 + var("decoder", decoder(constructor_name, returns)), 182 + pipe_call("pgo.execute", string(content), [ 183 + doc.from_string("db"), 184 + list(inputs_encoders), 185 + doc.from_string("decode.from(decoder, _)"), 186 + ]), 187 + ]), 188 + ] 189 + |> doc.concat 190 + |> doc.to_string(80) 191 + } 192 + 193 + fn gleam_type_to_decoder(type_: gleam.Type) -> String { 194 + case type_ { 195 + gleam.List(type_) -> "decode.list(" <> gleam_type_to_decoder(type_) <> ")" 196 + gleam.Int -> "decode.int" 197 + gleam.Float -> "decode.float" 198 + gleam.Bool -> "decode.bool" 199 + gleam.String -> "decode.string" 200 + gleam.Option(type_) -> 201 + "decode.optional(" <> gleam_type_to_decoder(type_) <> ")" 202 + } 203 + } 204 + 205 + fn gleam_type_to_encoder(type_: gleam.Type, name: String) { 206 + case type_ { 207 + gleam.List(type_) -> 208 + "pgo.array(list.map(" 209 + <> name 210 + <> ", fn(a) {" 211 + <> gleam_type_to_encoder(type_, "a") 212 + <> "}))" 213 + gleam.Option(type_) -> 214 + "pgo.nullable(fn(a) {" 215 + <> gleam_type_to_encoder(type_, "a") 216 + <> "}, " 217 + <> name 218 + <> ")" 219 + gleam.Int -> "pgo.int(" <> name <> ")" 220 + gleam.Float -> "pgo.float(" <> name <> ")" 221 + gleam.Bool -> "pgo.bool(" <> name <> ")" 222 + gleam.String -> "pgo.text(" <> name <> ")" 223 + } 224 + } 225 + 226 + // --- CODE GENERATION PRETTY PRINTING ----------------------------------------- 227 + // These are just a couple of handy helpers to make it easier to generate code 228 + // for a query. 229 + // 230 + // It makes a best effort to also make the generated code look nice. 231 + // Due to some missing features in `glam`, it doesn't reimplement 100% of 232 + // Gleam's own pretty printer so it might have a different look in some places. 233 + // 234 + 235 + const indent = 2 236 + 237 + pub fn record(name: String, fields: List(gleam.Field)) -> Document { 238 + let fields = 239 + list.map(fields, fn(field) { 240 + let label = gleam.identifier_to_string(field.label) 241 + 242 + [doc.from_string(label <> ": "), pretty_gleam_type(field.type_)] 243 + |> doc.concat 244 + |> doc.group 245 + }) 246 + 247 + [ 248 + doc.from_string("pub type " <> name <> " {"), 249 + [doc.line, call(name, fields)] 250 + |> doc.concat 251 + |> doc.nest(by: indent), 252 + doc.line, 253 + doc.from_string("}"), 254 + ] 255 + |> doc.concat 256 + |> doc.group 257 + } 258 + 259 + fn pretty_gleam_type(type_: gleam.Type) -> Document { 260 + case type_ { 261 + gleam.List(type_) -> call("List", [pretty_gleam_type(type_)]) 262 + gleam.Option(type_) -> call("Option", [pretty_gleam_type(type_)]) 263 + gleam.Int -> doc.from_string("Int") 264 + gleam.Float -> doc.from_string("Float") 265 + gleam.Bool -> doc.from_string("Bool") 266 + gleam.String -> doc.from_string("String") 267 + } 268 + } 269 + 270 + /// A pretty printed public function definition. 271 + /// 272 + pub fn fun(name: String, args: List(String), body: List(Document)) -> Document { 273 + let args = list.map(args, doc.from_string) 274 + 275 + [ 276 + doc.from_string("pub fn " <> name), 277 + comma_list("(", args, ") "), 278 + block([body |> doc.join(with: doc.lines(2))]), 279 + doc.line, 280 + ] 281 + |> doc.concat 282 + |> doc.group 283 + } 284 + 285 + /// A pretty printed let assignment. 286 + /// 287 + pub fn var(name: String, body: Document) -> Document { 288 + [ 289 + doc.from_string("let " <> name <> " ="), 290 + [doc.space, body] 291 + |> doc.concat 292 + |> doc.group 293 + |> doc.nest(by: indent), 294 + ] 295 + |> doc.concat 296 + } 297 + 298 + /// A pretty printed Gleam string. 299 + /// 300 + /// > ⚠️ This function escapes all `\` and `"` inside the original string to 301 + /// > avoid generating invalid Gleam code. 302 + /// 303 + pub fn string(content: String) -> Document { 304 + let escaped_string = 305 + content 306 + |> string.replace(each: "\\", with: "\\\\") 307 + |> string.replace(each: "\"", with: "\\\"") 308 + |> doc.from_string 309 + 310 + [doc.from_string("\""), escaped_string, doc.from_string("\"")] 311 + |> doc.concat 312 + } 313 + 314 + /// A pretty printed Gleam list. 315 + /// 316 + pub fn list(elems: List(Document)) -> Document { 317 + comma_list("[", elems, "]") 318 + } 319 + 320 + /// A pretty printed decoder that decodes an n-item dynamic tuple using the 321 + /// `decode` package. 322 + /// 323 + pub fn decoder(constructor: String, returns: List(gleam.Field)) -> Document { 324 + let parameters = 325 + list.map(returns, fn(field) { 326 + let label = gleam.identifier_to_string(field.label) 327 + doc.from_string("use " <> label <> " <- decode.parameter") 328 + }) 329 + 330 + let pipes = 331 + list.index_map(returns, fn(field, i) { 332 + let position = int.to_string(i) |> doc.from_string 333 + let decoder = gleam_type_to_decoder(field.type_) |> doc.from_string 334 + call("|> decode.field", [position, decoder]) 335 + }) 336 + 337 + let labelled_names = 338 + list.map(returns, fn(field) { 339 + let label = gleam.identifier_to_string(field.label) 340 + doc.from_string(label <> ": " <> label) 341 + }) 342 + 343 + [ 344 + call_block("decode.into", [ 345 + doc.join(parameters, with: doc.line), 346 + doc.line, 347 + call(constructor, labelled_names), 348 + ]), 349 + doc.line, 350 + doc.join(pipes, with: doc.line), 351 + ] 352 + |> doc.concat() 353 + |> doc.group 354 + } 355 + 356 + /// A pretty printed function call where the first argument is piped into 357 + /// the function. 358 + /// 359 + pub fn pipe_call( 360 + function: String, 361 + first: Document, 362 + rest: List(Document), 363 + ) -> Document { 364 + [first, doc.line, call("|> " <> function, rest)] 365 + |> doc.concat 366 + } 367 + 368 + /// A pretty printed function call. 369 + /// 370 + fn call(function: String, args: List(Document)) -> Document { 371 + [doc.from_string(function), comma_list("(", args, ")")] 372 + |> doc.concat 373 + |> doc.group 374 + } 375 + 376 + /// A pretty printed function call where the only argument is a single block. 377 + /// 378 + fn call_block(function: String, body: List(Document)) -> Document { 379 + [doc.from_string(function <> "("), block(body), doc.from_string(")")] 380 + |> doc.concat 381 + |> doc.group 382 + } 383 + 384 + /// A pretty printed Gleam block. 385 + /// 386 + fn block(body: List(Document)) -> Document { 387 + [ 388 + doc.from_string("{"), 389 + [doc.line, ..body] 390 + |> doc.concat 391 + |> doc.nest(by: indent), 392 + doc.line, 393 + doc.from_string("}"), 394 + ] 395 + |> doc.concat 396 + |> doc.force_break 397 + } 398 + 399 + /// A comma separated list of items with some given open and closed delimiters. 400 + /// 401 + fn comma_list(open: String, content: List(Document), close: String) -> Document { 402 + [ 403 + doc.from_string(open), 404 + [ 405 + // We want the first break to be nested 406 + // in case the group is broken. 407 + doc.soft_break, 408 + doc.join(content, doc.break(", ", ",")), 409 + ] 410 + |> doc.concat 411 + |> doc.group 412 + |> doc.nest(by: indent), 413 + doc.break("", ","), 414 + doc.from_string(close), 415 + ] 416 + |> doc.concat 417 + |> doc.group 418 + }
+216
test/squirrel_test.gleam
··· 1 + import birdie 2 + import filepath 3 + import gleam/dynamic 4 + import gleam/list 5 + import gleam/pgo 6 + import gleam/string 7 + import gleeunit 8 + import simplifile 9 + import squirrel/internal/database/postgres 10 + import squirrel/internal/error.{type Error} 11 + import squirrel/internal/query.{type TypedQuery} 12 + import temporary 13 + 14 + pub fn main() { 15 + setup_database() 16 + gleeunit.main() 17 + } 18 + 19 + // --- TEST SETUP -------------------------------------------------------------- 20 + 21 + const host = "localhost" 22 + 23 + const user = "squirrel_test" 24 + 25 + const database = "squirrel_test" 26 + 27 + const port = 5432 28 + 29 + fn setup_database() { 30 + let config = 31 + pgo.Config( 32 + ..pgo.default_config(), 33 + port: port, 34 + user: user, 35 + host: host, 36 + database: database, 37 + ) 38 + let db = pgo.connect(config) 39 + 40 + let assert Ok(_) = 41 + " 42 + create table if not exists squirrel( 43 + name text primary key, 44 + acorns int 45 + ) 46 + " 47 + |> pgo.execute(db, [], dynamic.dynamic) 48 + 49 + pgo.disconnect(db) 50 + } 51 + 52 + // --- ASSERTION HELPERS ------------------------------------------------------- 53 + 54 + fn should_codegen(query: String) -> String { 55 + // We assert everything went smoothly and we have no errors in the query. 56 + let assert Ok(#(queries, [])) = codegen_queries([#("query", query)]) 57 + list.map(queries, query.generate_code("v-test", _)) 58 + |> string.join(with: "\n\n") 59 + } 60 + 61 + fn codegen_queries( 62 + queries: List(#(String, String)), 63 + ) -> Result(#(List(TypedQuery), List(Error)), Error) { 64 + // If there's any error with the temporary package we just fail the test, 65 + // there's no reason to try and keep going. 66 + let assert Ok(result) = { 67 + use temp_dir <- temporary.create(temporary.directory()) 68 + 69 + // We parse all the queries. 70 + let queries = { 71 + use #(file, query) <- list.map(queries) 72 + let out_file = filepath.join(temp_dir, file <> ".sql") 73 + let assert Ok(_) = simplifile.write(to: out_file, contents: query) 74 + let assert Ok(query) = query.from_file(out_file) 75 + // We manually change the file name here: we do not want to use the full 76 + // out_file name in tests because that will change across different runs, 77 + // causing the snapshot tests to fail. 78 + let query = query.UntypedQuery(..query, file: file <> ".sql") 79 + query 80 + } 81 + 82 + // We can then ask squirrel to type check all the queries. 83 + postgres.main( 84 + queries, 85 + postgres.ConnectionOptions( 86 + host: host, 87 + port: port, 88 + user: user, 89 + database: database, 90 + password: "", 91 + timeout: 1000, 92 + ), 93 + ) 94 + } 95 + 96 + result 97 + } 98 + 99 + // --- ENCODING/DECODING CODEGEN TESTS ----------------------------------------- 100 + // This is a group of tests to ensure the generated encoders/decoders are what 101 + // we expect for all the supported data types. 102 + // 103 + 104 + pub fn int_decoding_test() { 105 + "select 11 as res" 106 + |> should_codegen 107 + |> birdie.snap(title: "int decoding") 108 + } 109 + 110 + pub fn int_encoding_test() { 111 + "select true as res where $1 = 11" 112 + |> should_codegen 113 + |> birdie.snap(title: "int encoding") 114 + } 115 + 116 + pub fn float_decoding_test() { 117 + "select 1.1 as res" 118 + |> should_codegen 119 + |> birdie.snap(title: "float decoding") 120 + } 121 + 122 + pub fn float_encoding_test() { 123 + "select true as res where $1 = 1.1" 124 + |> should_codegen 125 + |> birdie.snap(title: "float encoding") 126 + } 127 + 128 + pub fn string_decoding_test() { 129 + "select 'wibble' as res" 130 + |> should_codegen 131 + |> birdie.snap(title: "string decoding") 132 + } 133 + 134 + pub fn string_encoding_test() { 135 + "select true as res where $1 = 'wibble'" 136 + |> should_codegen 137 + |> birdie.snap(title: "string encoding") 138 + } 139 + 140 + pub fn bool_decoding_test() { 141 + "select true as res" 142 + |> should_codegen 143 + |> birdie.snap(title: "bool decoding") 144 + } 145 + 146 + pub fn bool_encoding_test() { 147 + "select true as res where $1 = true" 148 + |> should_codegen 149 + |> birdie.snap(title: "bool encoding") 150 + } 151 + 152 + pub fn array_decoding_test() { 153 + "select array[1, 2, 3] as res" 154 + |> should_codegen 155 + |> birdie.snap(title: "array decoding") 156 + } 157 + 158 + pub fn array_encoding_test() { 159 + "select true as res where $1 = array[1, 2, 3]" 160 + |> should_codegen 161 + |> birdie.snap(title: "array encoding") 162 + } 163 + 164 + pub fn optional_decoding_test() { 165 + "select acorns from squirrel" 166 + |> should_codegen 167 + |> birdie.snap(title: "optional decoding") 168 + } 169 + 170 + // --- CODEGEN STRUCTURE TESTS ------------------------------------------------- 171 + // This is a group of tests to ensure the generated code has some specific 172 + // structure (e.g. the names and comments are what we expect...) 173 + // 174 + 175 + pub fn query_with_comment_test() { 176 + " 177 + -- This is a comment 178 + select true as res 179 + " 180 + |> should_codegen 181 + |> birdie.snap(title: "query with comment") 182 + } 183 + 184 + pub fn query_with_multiline_comment_test() { 185 + " 186 + -- This is a comment 187 + -- that goes over multiple lines! 188 + select true as res 189 + " 190 + |> should_codegen 191 + |> birdie.snap(title: "query with multiline comment") 192 + } 193 + 194 + pub fn generated_type_has_the_same_name_as_the_function_but_in_pascal_case_test() { 195 + " 196 + select true as res 197 + " 198 + |> should_codegen 199 + |> birdie.snap( 200 + title: "generated type has the same name as the function but in pascal case", 201 + ) 202 + } 203 + 204 + pub fn generated_type_fields_are_labelled_with_their_name_in_the_select_list_test() { 205 + " 206 + select 207 + acorns, 208 + name as squirrel_name 209 + from 210 + squirrel 211 + " 212 + |> should_codegen 213 + |> birdie.snap( 214 + title: "generated type fields are labelled with their name in the select list", 215 + ) 216 + }