···11+# Contributing
22+33+If you're reading this, thank you so much for trying to contribute to
44+`squirrel`!
55+I tried to do my best and comment the code as much as possible and make it easy
66+to understand. Each module also starts with a small comment to explain what it
77+does, so it should be easier to dive in the codebase.
88+99+> 💡 If you feel like some pieces of code are not commented enough or are too
1010+> obscure, than that's a bug! Please do reach out, I'd love to hear your
1111+> feedback and make `squirrel` easier to contribute to!
1212+1313+## Running the tests
1414+1515+Most of the tests are snapshot tests that directly call the `postgres.main`
1616+function to let it type the queries. In order to do that `squirrel` will have to
1717+connect to a postgres server that must be running during the tests.
1818+1919+- In CI this is taken care of automatically
2020+- Locally you'll need a little bit of setup:
2121+ - There must be a user called `squirrel_test`
2222+ - It must be able to read and write to a database called `squirrel_test`
2323+ - It will use the empty password to connect at localhost's port 5432
2424+2525+## Writing tests
2626+2727+`squirrel` uses a lot of snapshot tests, to add new tests for the code
2828+generation bits you can have a look and copy the existing ones.
2929+There's no hard requirements but I have some suggestion to write good snapshot
3030+tests:
3131+3232+- Have at most one snapshot per test function
3333+- Try to keep the snapshots as small as possible.
3434+ Ideally one snapshot should assert a single property of the generated code so
3535+ that it is easier to focus on a specific aspect of the code when reviewing it
3636+- Use a long descriptive title for the snapshots: a title should describe what
3737+ one expects to see in the produced snapshot to guide the review process
+201
LICENSE
···11+ Apache License
22+ Version 2.0, January 2004
33+ http://www.apache.org/licenses/
44+55+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
66+77+ 1. Definitions.
88+99+ "License" shall mean the terms and conditions for use, reproduction,
1010+ and distribution as defined by Sections 1 through 9 of this document.
1111+1212+ "Licensor" shall mean the copyright owner or entity authorized by
1313+ the copyright owner that is granting the License.
1414+1515+ "Legal Entity" shall mean the union of the acting entity and all
1616+ other entities that control, are controlled by, or are under common
1717+ control with that entity. For the purposes of this definition,
1818+ "control" means (i) the power, direct or indirect, to cause the
1919+ direction or management of such entity, whether by contract or
2020+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
2121+ outstanding shares, or (iii) beneficial ownership of such entity.
2222+2323+ "You" (or "Your") shall mean an individual or Legal Entity
2424+ exercising permissions granted by this License.
2525+2626+ "Source" form shall mean the preferred form for making modifications,
2727+ including but not limited to software source code, documentation
2828+ source, and configuration files.
2929+3030+ "Object" form shall mean any form resulting from mechanical
3131+ transformation or translation of a Source form, including but
3232+ not limited to compiled object code, generated documentation,
3333+ and conversions to other media types.
3434+3535+ "Work" shall mean the work of authorship, whether in Source or
3636+ Object form, made available under the License, as indicated by a
3737+ copyright notice that is included in or attached to the work
3838+ (an example is provided in the Appendix below).
3939+4040+ "Derivative Works" shall mean any work, whether in Source or Object
4141+ form, that is based on (or derived from) the Work and for which the
4242+ editorial revisions, annotations, elaborations, or other modifications
4343+ represent, as a whole, an original work of authorship. For the purposes
4444+ of this License, Derivative Works shall not include works that remain
4545+ separable from, or merely link (or bind by name) to the interfaces of,
4646+ the Work and Derivative Works thereof.
4747+4848+ "Contribution" shall mean any work of authorship, including
4949+ the original version of the Work and any modifications or additions
5050+ to that Work or Derivative Works thereof, that is intentionally
5151+ submitted to Licensor for inclusion in the Work by the copyright owner
5252+ or by an individual or Legal Entity authorized to submit on behalf of
5353+ the copyright owner. For the purposes of this definition, "submitted"
5454+ means any form of electronic, verbal, or written communication sent
5555+ to the Licensor or its representatives, including but not limited to
5656+ communication on electronic mailing lists, source code control systems,
5757+ and issue tracking systems that are managed by, or on behalf of, the
5858+ Licensor for the purpose of discussing and improving the Work, but
5959+ excluding communication that is conspicuously marked or otherwise
6060+ designated in writing by the copyright owner as "Not a Contribution."
6161+6262+ "Contributor" shall mean Licensor and any individual or Legal Entity
6363+ on behalf of whom a Contribution has been received by Licensor and
6464+ subsequently incorporated within the Work.
6565+6666+ 2. Grant of Copyright License. Subject to the terms and conditions of
6767+ this License, each Contributor hereby grants to You a perpetual,
6868+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
6969+ copyright license to reproduce, prepare Derivative Works of,
7070+ publicly display, publicly perform, sublicense, and distribute the
7171+ Work and such Derivative Works in Source or Object form.
7272+7373+ 3. Grant of Patent License. Subject to the terms and conditions of
7474+ this License, each Contributor hereby grants to You a perpetual,
7575+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
7676+ (except as stated in this section) patent license to make, have made,
7777+ use, offer to sell, sell, import, and otherwise transfer the Work,
7878+ where such license applies only to those patent claims licensable
7979+ by such Contributor that are necessarily infringed by their
8080+ Contribution(s) alone or by combination of their Contribution(s)
8181+ with the Work to which such Contribution(s) was submitted. If You
8282+ institute patent litigation against any entity (including a
8383+ cross-claim or counterclaim in a lawsuit) alleging that the Work
8484+ or a Contribution incorporated within the Work constitutes direct
8585+ or contributory patent infringement, then any patent licenses
8686+ granted to You under this License for that Work shall terminate
8787+ as of the date such litigation is filed.
8888+8989+ 4. Redistribution. You may reproduce and distribute copies of the
9090+ Work or Derivative Works thereof in any medium, with or without
9191+ modifications, and in Source or Object form, provided that You
9292+ meet the following conditions:
9393+9494+ (a) You must give any other recipients of the Work or
9595+ Derivative Works a copy of this License; and
9696+9797+ (b) You must cause any modified files to carry prominent notices
9898+ stating that You changed the files; and
9999+100100+ (c) You must retain, in the Source form of any Derivative Works
101101+ that You distribute, all copyright, patent, trademark, and
102102+ attribution notices from the Source form of the Work,
103103+ excluding those notices that do not pertain to any part of
104104+ the Derivative Works; and
105105+106106+ (d) If the Work includes a "NOTICE" text file as part of its
107107+ distribution, then any Derivative Works that You distribute must
108108+ include a readable copy of the attribution notices contained
109109+ within such NOTICE file, excluding those notices that do not
110110+ pertain to any part of the Derivative Works, in at least one
111111+ of the following places: within a NOTICE text file distributed
112112+ as part of the Derivative Works; within the Source form or
113113+ documentation, if provided along with the Derivative Works; or,
114114+ within a display generated by the Derivative Works, if and
115115+ wherever such third-party notices normally appear. The contents
116116+ of the NOTICE file are for informational purposes only and
117117+ do not modify the License. You may add Your own attribution
118118+ notices within Derivative Works that You distribute, alongside
119119+ or as an addendum to the NOTICE text from the Work, provided
120120+ that such additional attribution notices cannot be construed
121121+ as modifying the License.
122122+123123+ You may add Your own copyright statement to Your modifications and
124124+ may provide additional or different license terms and conditions
125125+ for use, reproduction, or distribution of Your modifications, or
126126+ for any such Derivative Works as a whole, provided Your use,
127127+ reproduction, and distribution of the Work otherwise complies with
128128+ the conditions stated in this License.
129129+130130+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131131+ any Contribution intentionally submitted for inclusion in the Work
132132+ by You to the Licensor shall be under the terms and conditions of
133133+ this License, without any additional terms or conditions.
134134+ Notwithstanding the above, nothing herein shall supersede or modify
135135+ the terms of any separate license agreement you may have executed
136136+ with Licensor regarding such Contributions.
137137+138138+ 6. Trademarks. This License does not grant permission to use the trade
139139+ names, trademarks, service marks, or product names of the Licensor,
140140+ except as required for reasonable and customary use in describing the
141141+ origin of the Work and reproducing the content of the NOTICE file.
142142+143143+ 7. Disclaimer of Warranty. Unless required by applicable law or
144144+ agreed to in writing, Licensor provides the Work (and each
145145+ Contributor provides its Contributions) on an "AS IS" BASIS,
146146+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147147+ implied, including, without limitation, any warranties or conditions
148148+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149149+ PARTICULAR PURPOSE. You are solely responsible for determining the
150150+ appropriateness of using or redistributing the Work and assume any
151151+ risks associated with Your exercise of permissions under this License.
152152+153153+ 8. Limitation of Liability. In no event and under no legal theory,
154154+ whether in tort (including negligence), contract, or otherwise,
155155+ unless required by applicable law (such as deliberate and grossly
156156+ negligent acts) or agreed to in writing, shall any Contributor be
157157+ liable to You for damages, including any direct, indirect, special,
158158+ incidental, or consequential damages of any character arising as a
159159+ result of this License or out of the use or inability to use the
160160+ Work (including but not limited to damages for loss of goodwill,
161161+ work stoppage, computer failure or malfunction, or any and all
162162+ other commercial damages or losses), even if such Contributor
163163+ has been advised of the possibility of such damages.
164164+165165+ 9. Accepting Warranty or Additional Liability. While redistributing
166166+ the Work or Derivative Works thereof, You may choose to offer,
167167+ and charge a fee for, acceptance of support, warranty, indemnity,
168168+ or other liability obligations and/or rights consistent with this
169169+ License. However, in accepting such obligations, You may act only
170170+ on Your own behalf and on Your sole responsibility, not on behalf
171171+ of any other Contributor, and only if You agree to indemnify,
172172+ defend, and hold each Contributor harmless for any liability
173173+ incurred by, or claims asserted against, such Contributor by reason
174174+ of your accepting any such warranty or additional liability.
175175+176176+ END OF TERMS AND CONDITIONS
177177+178178+ APPENDIX: How to apply the Apache License to your work.
179179+180180+ To apply the Apache License to your work, attach the following
181181+ boilerplate notice, with the fields enclosed by brackets "[]"
182182+ replaced with your own identifying information. (Don't include
183183+ the brackets!) The text should be enclosed in the appropriate
184184+ comment syntax for the file format. We also recommend that a
185185+ file or class name and description of purpose be included on the
186186+ same "printed page" as the copyright notice for easier
187187+ identification within third-party archives.
188188+189189+ Copyright 2024 Giacomo Cavalieri
190190+191191+ Licensed under the Apache License, Version 2.0 (the "License");
192192+ you may not use this file except in compliance with the License.
193193+ You may obtain a copy of the License at
194194+195195+ http://www.apache.org/licenses/LICENSE-2.0
196196+197197+ Unless required by applicable law or agreed to in writing, software
198198+ distributed under the License is distributed on an "AS IS" BASIS,
199199+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200200+ See the License for the specific language governing permissions and
201201+ limitations under the License.
+186
README.md
···11+# 🐿️ squirrel - type safe SQL in Gleam
22+33+[](https://hex.pm/packages/squirrel)
44+[](https://hexdocs.pm/squirrel/)
55+66+## What's Squirrel?
77+88+If you need to talk with a database in Gleam you'll have to write something like
99+this:
1010+1111+```gleam
1212+import gleam/pgo
1313+import decode
1414+1515+pub type FindSquirrelRow {
1616+ FindSquirrelRow(name: String, owned_acorns: Int)
1717+}
1818+1919+/// Find a squirrel and its owned acorns given its name.
2020+///
2121+pub fn find_squirrel(db: pgo.Connection, name: String) {
2222+ let squirrel_row_decoder =
2323+ decode.into({
2424+ use name <- decode.parameter
2525+ use owned_acorns <- decode.parameter
2626+ FindSquirrelRow(name: name, owned_acorns: owned_acorns)
2727+ })
2828+ |> decode.field(0, decode.string)
2929+ |> decode.field(1, decode.int)
3030+3131+ "select name, owned_acorns
3232+ from squirrel
3333+ where name = $1"
3434+ |> pgo.execute(db, [pgo.text(name)], squirrel_row_decoder)
3535+}
3636+```
3737+3838+This is probably fine if you have a few small queries but it can become quite
3939+the burden when you have a lot of queries:
4040+4141+- The SQL query you write is just a plain string, you do not get syntax
4242+ highlighting, auto formatting, suggestions... all the little niceties you
4343+ would otherwise get if you where writing a plain `*.sql` file.
4444+- This also means you loose the ability to run these queries on their own with
4545+ other external tools, inspect them and so on.
4646+- You have to manually keep in sync the decoder with the query's output.
4747+4848+One might be tempted to hide all of this by reaching for something like an ORM.
4949+Squirrel proposes a different approach: instead of trying to hide the SQL it
5050+_embraces it and leaves you in control._
5151+You write the SQL queries in plain old `*.sql` files and Squirrel will take care
5252+of generating all the corresponding functions.
5353+5454+A code snippet is worth a thousand words, so let's have a look at an example.
5555+Instead of the hand written example shown earlier you can instead just write the
5656+following query:
5757+5858+```sql
5959+-- we're in file `src/squirrels/sql/find_squirrel.sql`
6060+-- Find a squirrel and its owned acorns given its name.
6161+select
6262+ name,
6363+ owned_acorns
6464+from
6565+ squirrel
6666+where
6767+ name = $1
6868+```
6969+7070+And run `gleam run -m squirrel`. Just like magic you'll now have a type-safe
7171+function `find_squirrel` you can use just as you'd expect:
7272+7373+```gleam
7474+import squirrels/sql
7575+7676+pub fn main() {
7777+ let db = todo as "the pgo connection"
7878+ // And it just works as you'd expect:
7979+ let assert Ok(#(_rows_count, rows)) = sql.find_squirrel("sandy")
8080+ let assert [FindSquirrelRow(name: "sandy", owned_acorns: 11_111)] = rows
8181+}
8282+```
8383+8484+Behind the scenes Squirrel generates the decoders and functions you need; and
8585+it's pretty-printed, standard Gleam code (actually it's exactly like the hand
8686+written example I showed you earlier)!
8787+So now you get the best of both worlds:
8888+8989+- You don't have to take care of keeping encoders and decoders in sync, Squirrel
9090+ does that for you.
9191+- And you're not compromising on type safety either: Squirrel is able to
9292+ understand the types of your query and produce a correct decoder.
9393+- You can stick to writing plain SQL in `*.sql` files. You'll have better
9494+ editor support, syntax highlighting and completions.
9595+- You can run each query on its own: need to `explain` a query?
9696+ No big deal, it's just a plain old `*.sql` file.
9797+9898+## Usage
9999+100100+First you'll need to add Squirrel to your project as a dev dependency:
101101+102102+```sh
103103+gleam add squirrel --dev
104104+105105+# Remember to add these packages if you haven't yet, they are needed by the
106106+# generated code to run and decode the read rows!
107107+gleam add gleam_pgo
108108+gleam add decode
109109+```
110110+111111+Then you can ask it to generate code running the `squirrel` module:
112112+113113+```sh
114114+gleam run -m squirrel
115115+```
116116+117117+And that's it! As long as you follow a couple of conventions Squirrel will just
118118+work:
119119+120120+- Squirrel will look for all `*.sql` files in any `sql` directory under your
121121+ project's `src` directory.
122122+- Each `sql` directory will be turned into a single Gleam module containing a
123123+ function for each `*.sql` file inside it. The generated Gleam module is going
124124+ to be located in the same directory as the corresponding `sql` directory and
125125+ it's name is `sql.gleam`.
126126+- Each `*.sql` file _must contain a single SQL query._ And the name of the file
127127+ is going to be the name of the corresponding Gleam function to run that query.
128128+129129+> Let's make an example. Imagine you have a Gleam project that looks like this
130130+>
131131+> ```txt
132132+> ├── src
133133+> │ ├── squirrels
134134+> │ │ └── sql
135135+> │ │ ├── find_squirrel.sql
136136+> │ │ └── list_squirrels.sql
137137+> │ └── squirrels.gleam
138138+> └── test
139139+> └── squirrels_test.gleam
140140+> ```
141141+>
142142+> Running `gleam run -m squirrel` will create a `src/squirrels/sql.gleam` file
143143+> defining two functions `find_squirrel` and `list_squirrels` you can then
144144+> import and use in your code.
145145+146146+### Talking to the database
147147+148148+In order to understand the type of your queries, Squirrel needs to connect to
149149+the Postgres server where the database is defined. To connect, it will read the
150150+[Postgres env variables](https://www.postgresql.org/docs/current/libpq-envars.html)
151151+and use the following defaults if one is not set:
152152+153153+- `PGHOST`: `"localhost"`
154154+- `PGPORT`: `5432`
155155+- `PGUSER`: `"root"`
156156+- `PGDATABASE`: `"database"`
157157+- `PGPASSWORD`: `""`
158158+159159+## FAQ
160160+161161+### What flavour of SQL does squirrel support?
162162+163163+Squirrel only has support for Postgres.
164164+165165+### Why isn't squirrel configurable in any way?
166166+167167+By going the "convention over configuration" route, Squirrel enforces that all
168168+projects adopting it will always have the same structure.
169169+If you need to contribute to a project using Squirrel you'll immediately know
170170+which directories and modules to look for.
171171+172172+This makes it easier to get started with a new project and cuts down on all the
173173+bike shedding: _"Where should I put my queries?",_
174174+_"How many queries should go in on file?",_ ...
175175+176176+## References
177177+178178+This package draws a lot of inspiration from the amazing
179179+[yesql](https://github.com/krisajenkins/yesql) and
180180+[sqlx](https://github.com/launchbadge/sqlx).
181181+182182+## Contributing
183183+184184+If you think there’s any way to improve this package, or if you spot a bug don’t
185185+be afraid to open PRs, issues or requests of any kind! Any contribution is
186186+welcome 💜
+33
birdie_snapshots/array_decoding.accepted
···11+---
22+version: 1.1.8
33+title: array decoding
44+file: ./test/squirrel_test.gleam
55+test_name: array_decoding_test
66+---
77+/// A row you get from running the `query` query
88+/// defined in `query.sql`.
99+///
1010+/// > 🐿️ This type definition was generated automatically using v-test of the
1111+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
1212+///
1313+pub type QueryRow {
1414+ QueryRow(res: List(Int))
1515+}
1616+1717+/// Runs the `query` query
1818+/// defined in `query.sql`.
1919+///
2020+/// > 🐿️ This function was generated automatically using v-test of
2121+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
2222+///
2323+pub fn query(db) {
2424+ let decoder =
2525+ decode.into({
2626+ use res <- decode.parameter
2727+ QueryRow(res: res)
2828+ })
2929+ |> decode.field(0, decode.list(decode.int))
3030+3131+ "select array[1, 2, 3] as res"
3232+ |> pgo.execute(db, [], decode.from(decoder, _))
3333+}
+37
birdie_snapshots/array_encoding.accepted
···11+---
22+version: 1.1.8
33+title: array encoding
44+file: ./test/squirrel_test.gleam
55+test_name: array_encoding_test
66+---
77+/// A row you get from running the `query` query
88+/// defined in `query.sql`.
99+///
1010+/// > 🐿️ This type definition was generated automatically using v-test of the
1111+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
1212+///
1313+pub type QueryRow {
1414+ QueryRow(res: Bool)
1515+}
1616+1717+/// Runs the `query` query
1818+/// defined in `query.sql`.
1919+///
2020+/// > 🐿️ This function was generated automatically using v-test of
2121+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
2222+///
2323+pub fn query(db, arg_1) {
2424+ let decoder =
2525+ decode.into({
2626+ use res <- decode.parameter
2727+ QueryRow(res: res)
2828+ })
2929+ |> decode.field(0, decode.bool)
3030+3131+ "select true as res where $1 = array[1, 2, 3]"
3232+ |> pgo.execute(
3333+ db,
3434+ [pgo.array(list.map(arg_1, fn(a) {pgo.int(a)}))],
3535+ decode.from(decoder, _),
3636+ )
3737+}
+33
birdie_snapshots/bool_decoding.accepted
···11+---
22+version: 1.1.8
33+title: bool decoding
44+file: ./test/squirrel_test.gleam
55+test_name: bool_decoding_test
66+---
77+/// A row you get from running the `query` query
88+/// defined in `query.sql`.
99+///
1010+/// > 🐿️ This type definition was generated automatically using v-test of the
1111+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
1212+///
1313+pub type QueryRow {
1414+ QueryRow(res: Bool)
1515+}
1616+1717+/// Runs the `query` query
1818+/// defined in `query.sql`.
1919+///
2020+/// > 🐿️ This function was generated automatically using v-test of
2121+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
2222+///
2323+pub fn query(db) {
2424+ let decoder =
2525+ decode.into({
2626+ use res <- decode.parameter
2727+ QueryRow(res: res)
2828+ })
2929+ |> decode.field(0, decode.bool)
3030+3131+ "select true as res"
3232+ |> pgo.execute(db, [], decode.from(decoder, _))
3333+}
+33
birdie_snapshots/bool_encoding.accepted
···11+---
22+version: 1.1.8
33+title: bool encoding
44+file: ./test/squirrel_test.gleam
55+test_name: bool_encoding_test
66+---
77+/// A row you get from running the `query` query
88+/// defined in `query.sql`.
99+///
1010+/// > 🐿️ This type definition was generated automatically using v-test of the
1111+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
1212+///
1313+pub type QueryRow {
1414+ QueryRow(res: Bool)
1515+}
1616+1717+/// Runs the `query` query
1818+/// defined in `query.sql`.
1919+///
2020+/// > 🐿️ This function was generated automatically using v-test of
2121+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
2222+///
2323+pub fn query(db, arg_1) {
2424+ let decoder =
2525+ decode.into({
2626+ use res <- decode.parameter
2727+ QueryRow(res: res)
2828+ })
2929+ |> decode.field(0, decode.bool)
3030+3131+ "select true as res where $1 = true"
3232+ |> pgo.execute(db, [pgo.bool(arg_1)], decode.from(decoder, _))
3333+}
+33
birdie_snapshots/float_decoding.accepted
···11+---
22+version: 1.1.8
33+title: float decoding
44+file: ./test/squirrel_test.gleam
55+test_name: float_decoding_test
66+---
77+/// A row you get from running the `query` query
88+/// defined in `query.sql`.
99+///
1010+/// > 🐿️ This type definition was generated automatically using v-test of the
1111+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
1212+///
1313+pub type QueryRow {
1414+ QueryRow(res: Float)
1515+}
1616+1717+/// Runs the `query` query
1818+/// defined in `query.sql`.
1919+///
2020+/// > 🐿️ This function was generated automatically using v-test of
2121+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
2222+///
2323+pub fn query(db) {
2424+ let decoder =
2525+ decode.into({
2626+ use res <- decode.parameter
2727+ QueryRow(res: res)
2828+ })
2929+ |> decode.field(0, decode.float)
3030+3131+ "select 1.1 as res"
3232+ |> pgo.execute(db, [], decode.from(decoder, _))
3333+}
+33
birdie_snapshots/float_encoding.accepted
···11+---
22+version: 1.1.8
33+title: float encoding
44+file: ./test/squirrel_test.gleam
55+test_name: float_encoding_test
66+---
77+/// A row you get from running the `query` query
88+/// defined in `query.sql`.
99+///
1010+/// > 🐿️ This type definition was generated automatically using v-test of the
1111+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
1212+///
1313+pub type QueryRow {
1414+ QueryRow(res: Bool)
1515+}
1616+1717+/// Runs the `query` query
1818+/// defined in `query.sql`.
1919+///
2020+/// > 🐿️ This function was generated automatically using v-test of
2121+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
2222+///
2323+pub fn query(db, arg_1) {
2424+ let decoder =
2525+ decode.into({
2626+ use res <- decode.parameter
2727+ QueryRow(res: res)
2828+ })
2929+ |> decode.field(0, decode.bool)
3030+3131+ "select true as res where $1 = 1.1"
3232+ |> pgo.execute(db, [pgo.float(arg_1)], decode.from(decoder, _))
3333+}
···11+---
22+version: 1.1.8
33+title: generated type fields are labelled with their name in the select list
44+file: ./test/squirrel_test.gleam
55+test_name: generated_type_fields_are_labelled_with_their_name_in_the_select_list_test
66+---
77+/// A row you get from running the `query` query
88+/// defined in `query.sql`.
99+///
1010+/// > 🐿️ This type definition was generated automatically using v-test of the
1111+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
1212+///
1313+pub type QueryRow {
1414+ QueryRow(acorns: Option(Int), squirrel_name: String)
1515+}
1616+1717+/// Runs the `query` query
1818+/// defined in `query.sql`.
1919+///
2020+/// > 🐿️ This function was generated automatically using v-test of
2121+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
2222+///
2323+pub fn query(db) {
2424+ let decoder =
2525+ decode.into({
2626+ use acorns <- decode.parameter
2727+ use squirrel_name <- decode.parameter
2828+ QueryRow(acorns: acorns, squirrel_name: squirrel_name)
2929+ })
3030+ |> decode.field(0, decode.optional(decode.int))
3131+ |> decode.field(1, decode.string)
3232+3333+ "
3434+select
3535+ acorns,
3636+ name as squirrel_name
3737+from
3838+ squirrel
3939+"
4040+ |> pgo.execute(db, [], decode.from(decoder, _))
4141+}
···11+---
22+version: 1.1.8
33+title: generated type has the same name as the function but in pascal case
44+file: ./test/squirrel_test.gleam
55+test_name: generated_type_has_the_same_name_as_the_function_but_in_pascal_case_test
66+---
77+/// A row you get from running the `query` query
88+/// defined in `query.sql`.
99+///
1010+/// > 🐿️ This type definition was generated automatically using v-test of the
1111+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
1212+///
1313+pub type QueryRow {
1414+ QueryRow(res: Bool)
1515+}
1616+1717+/// Runs the `query` query
1818+/// defined in `query.sql`.
1919+///
2020+/// > 🐿️ This function was generated automatically using v-test of
2121+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
2222+///
2323+pub fn query(db) {
2424+ let decoder =
2525+ decode.into({
2626+ use res <- decode.parameter
2727+ QueryRow(res: res)
2828+ })
2929+ |> decode.field(0, decode.bool)
3030+3131+ "
3232+select true as res
3333+"
3434+ |> pgo.execute(db, [], decode.from(decoder, _))
3535+}
+33
birdie_snapshots/int_decoding.accepted
···11+---
22+version: 1.1.8
33+title: int decoding
44+file: ./test/squirrel_test.gleam
55+test_name: int_decoding_test
66+---
77+/// A row you get from running the `query` query
88+/// defined in `query.sql`.
99+///
1010+/// > 🐿️ This type definition was generated automatically using v-test of the
1111+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
1212+///
1313+pub type QueryRow {
1414+ QueryRow(res: Int)
1515+}
1616+1717+/// Runs the `query` query
1818+/// defined in `query.sql`.
1919+///
2020+/// > 🐿️ This function was generated automatically using v-test of
2121+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
2222+///
2323+pub fn query(db) {
2424+ let decoder =
2525+ decode.into({
2626+ use res <- decode.parameter
2727+ QueryRow(res: res)
2828+ })
2929+ |> decode.field(0, decode.int)
3030+3131+ "select 11 as res"
3232+ |> pgo.execute(db, [], decode.from(decoder, _))
3333+}
+33
birdie_snapshots/int_encoding.accepted
···11+---
22+version: 1.1.8
33+title: int encoding
44+file: ./test/squirrel_test.gleam
55+test_name: int_encoding_test
66+---
77+/// A row you get from running the `query` query
88+/// defined in `query.sql`.
99+///
1010+/// > 🐿️ This type definition was generated automatically using v-test of the
1111+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
1212+///
1313+pub type QueryRow {
1414+ QueryRow(res: Bool)
1515+}
1616+1717+/// Runs the `query` query
1818+/// defined in `query.sql`.
1919+///
2020+/// > 🐿️ This function was generated automatically using v-test of
2121+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
2222+///
2323+pub fn query(db, arg_1) {
2424+ let decoder =
2525+ decode.into({
2626+ use res <- decode.parameter
2727+ QueryRow(res: res)
2828+ })
2929+ |> decode.field(0, decode.bool)
3030+3131+ "select true as res where $1 = 11"
3232+ |> pgo.execute(db, [pgo.int(arg_1)], decode.from(decoder, _))
3333+}
+33
birdie_snapshots/optional_decoding.accepted
···11+---
22+version: 1.1.8
33+title: optional decoding
44+file: ./test/squirrel_test.gleam
55+test_name: optional_decoding_test
66+---
77+/// A row you get from running the `query` query
88+/// defined in `query.sql`.
99+///
1010+/// > 🐿️ This type definition was generated automatically using v-test of the
1111+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
1212+///
1313+pub type QueryRow {
1414+ QueryRow(acorns: Option(Int))
1515+}
1616+1717+/// Runs the `query` query
1818+/// defined in `query.sql`.
1919+///
2020+/// > 🐿️ This function was generated automatically using v-test of
2121+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
2222+///
2323+pub fn query(db) {
2424+ let decoder =
2525+ decode.into({
2626+ use acorns <- decode.parameter
2727+ QueryRow(acorns: acorns)
2828+ })
2929+ |> decode.field(0, decode.optional(decode.int))
3030+3131+ "select acorns from squirrel"
3232+ |> pgo.execute(db, [], decode.from(decoder, _))
3333+}
+35
birdie_snapshots/query_with_comment.accepted
···11+---
22+version: 1.1.8
33+title: query with comment
44+file: ./test/squirrel_test.gleam
55+test_name: query_with_comment_test
66+---
77+/// A row you get from running the `query` query
88+/// defined in `query.sql`.
99+///
1010+/// > 🐿️ This type definition was generated automatically using v-test of the
1111+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
1212+///
1313+pub type QueryRow {
1414+ QueryRow(res: Bool)
1515+}
1616+1717+/// This is a comment
1818+///
1919+/// > 🐿️ This function was generated automatically using v-test of
2020+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
2121+///
2222+pub fn query(db) {
2323+ let decoder =
2424+ decode.into({
2525+ use res <- decode.parameter
2626+ QueryRow(res: res)
2727+ })
2828+ |> decode.field(0, decode.bool)
2929+3030+ "
3131+-- This is a comment
3232+select true as res
3333+"
3434+ |> pgo.execute(db, [], decode.from(decoder, _))
3535+}
···11+---
22+version: 1.1.8
33+title: query with multiline comment
44+file: ./test/squirrel_test.gleam
55+test_name: query_with_multiline_comment_test
66+---
77+/// A row you get from running the `query` query
88+/// defined in `query.sql`.
99+///
1010+/// > 🐿️ This type definition was generated automatically using v-test of the
1111+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
1212+///
1313+pub type QueryRow {
1414+ QueryRow(res: Bool)
1515+}
1616+1717+/// This is a comment
1818+/// that goes over multiple lines!
1919+///
2020+/// > 🐿️ This function was generated automatically using v-test of
2121+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
2222+///
2323+pub fn query(db) {
2424+ let decoder =
2525+ decode.into({
2626+ use res <- decode.parameter
2727+ QueryRow(res: res)
2828+ })
2929+ |> decode.field(0, decode.bool)
3030+3131+ "
3232+-- This is a comment
3333+-- that goes over multiple lines!
3434+select true as res
3535+"
3636+ |> pgo.execute(db, [], decode.from(decoder, _))
3737+}
+33
birdie_snapshots/string_decoding.accepted
···11+---
22+version: 1.1.8
33+title: string decoding
44+file: ./test/squirrel_test.gleam
55+test_name: string_decoding_test
66+---
77+/// A row you get from running the `query` query
88+/// defined in `query.sql`.
99+///
1010+/// > 🐿️ This type definition was generated automatically using v-test of the
1111+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
1212+///
1313+pub type QueryRow {
1414+ QueryRow(res: String)
1515+}
1616+1717+/// Runs the `query` query
1818+/// defined in `query.sql`.
1919+///
2020+/// > 🐿️ This function was generated automatically using v-test of
2121+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
2222+///
2323+pub fn query(db) {
2424+ let decoder =
2525+ decode.into({
2626+ use res <- decode.parameter
2727+ QueryRow(res: res)
2828+ })
2929+ |> decode.field(0, decode.string)
3030+3131+ "select 'wibble' as res"
3232+ |> pgo.execute(db, [], decode.from(decoder, _))
3333+}
+33
birdie_snapshots/string_encoding.accepted
···11+---
22+version: 1.1.8
33+title: string encoding
44+file: ./test/squirrel_test.gleam
55+test_name: string_encoding_test
66+---
77+/// A row you get from running the `query` query
88+/// defined in `query.sql`.
99+///
1010+/// > 🐿️ This type definition was generated automatically using v-test of the
1111+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
1212+///
1313+pub type QueryRow {
1414+ QueryRow(res: Bool)
1515+}
1616+1717+/// Runs the `query` query
1818+/// defined in `query.sql`.
1919+///
2020+/// > 🐿️ This function was generated automatically using v-test of
2121+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
2222+///
2323+pub fn query(db, arg_1) {
2424+ let decoder =
2525+ decode.into({
2626+ use res <- decode.parameter
2727+ QueryRow(res: res)
2828+ })
2929+ |> decode.field(0, decode.bool)
3030+3131+ "select true as res where $1 = 'wibble'"
3232+ |> pgo.execute(db, [pgo.text(arg_1)], decode.from(decoder, _))
3333+}
+25
gleam.toml
···11+name = "squirrel"
22+version = "1.0.0"
33+description = "🐿️ Type safe SQL in Gleam"
44+licences = ["Apache-2.0"]
55+repository = { type = "github", user = "giacomocavalieri", repo = "squirrel" }
66+77+[dependencies]
88+gleam_stdlib = ">= 0.34.0 and < 2.0.0"
99+simplifile = ">= 2.0.1 and < 3.0.0"
1010+eval = ">= 1.0.0 and < 2.0.0"
1111+gleam_json = ">= 1.0.0 and < 3.0.0"
1212+mug = ">= 1.1.0 and < 2.0.0"
1313+glam = ">= 2.0.1 and < 3.0.0"
1414+justin = ">= 1.0.1 and < 2.0.0"
1515+filepath = ">= 1.0.0 and < 2.0.0"
1616+gleam_community_ansi = ">= 1.4.0 and < 2.0.0"
1717+term_size = ">= 1.0.1 and < 2.0.0"
1818+argv = ">= 1.0.2 and < 2.0.0"
1919+envoy = ">= 1.0.1 and < 2.0.0"
2020+2121+[dev-dependencies]
2222+gleeunit = ">= 1.0.0 and < 2.0.0"
2323+birdie = ">= 1.1.8 and < 2.0.0"
2424+temporary = ">= 1.0.0 and < 2.0.0"
2525+gleam_pgo = ">= 0.13.0 and < 1.0.0"
+53
manifest.toml
···11+# This file was generated by Gleam
22+# You typically do not need to edit this file
33+44+packages = [
55+ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" },
66+ { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" },
77+ { 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" },
88+ { name = "envoy", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "CFAACCCFC47654F7E8B75E614746ED924C65BD08B1DE21101548AC314A8B6A41" },
99+ { name = "eval", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "eval", source = "hex", outer_checksum = "264DAF4B49DF807F303CA4A4E4EBC012070429E40BE384C58FE094C4958F9BDA" },
1010+ { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" },
1111+ { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" },
1212+ { name = "glam", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glam", source = "hex", outer_checksum = "66EC3BCD632E51EED029678F8DF419659C1E57B1A93D874C5131FE220DFAD2B2" },
1313+ { name = "glance", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "8F3314D27773B7C3B9FB58D8C02C634290422CE531988C0394FA0DF8676B964D" },
1414+ { 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" },
1515+ { 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" },
1616+ { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" },
1717+ { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" },
1818+ { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" },
1919+ { name = "gleam_pgo", version = "0.13.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "pgo"], otp_app = "gleam_pgo", source = "hex", outer_checksum = "6A1E7F3E717C077788254871E4EF4A8DFF58FEC07D7FA7C7702C2CCF66095AC8" },
2020+ { name = "gleam_stdlib", version = "0.39.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "2D7DE885A6EA7F1D5015D1698920C9BAF7241102836CE0C3837A4F160128A9C4" },
2121+ { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" },
2222+ { name = "glexer", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glexer", source = "hex", outer_checksum = "BD477AD657C2B637FEF75F2405FAEFFA533F277A74EF1A5E17B55B1178C228FB" },
2323+ { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" },
2424+ { name = "mug", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "mug", source = "hex", outer_checksum = "85A61E67A7A8C25F4460D9CBEF1C09C68FC06ABBC6FF893B0A1F42AE01CBB546" },
2525+ { name = "opentelemetry_api", version = "1.3.0", build_tools = ["rebar3", "mix"], requirements = ["opentelemetry_semantic_conventions"], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "B9E5FF775FD064FA098DBA3C398490B77649A352B40B0B730A6B7DC0BDD68858" },
2626+ { name = "opentelemetry_semantic_conventions", version = "0.2.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_semantic_conventions", source = "hex", outer_checksum = "D61FA1F5639EE8668D74B527E6806E0503EFC55A42DB7B5F39939D84C07D6895" },
2727+ { name = "pg_types", version = "0.4.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "B02EFA785CAECECF9702C681C80A9CA12A39F9161A846CE17B01FB20AEEED7EB" },
2828+ { name = "pgo", version = "0.14.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "71016C22599936E042DC0012EE4589D24C71427D266292F775EBF201D97DF9C9" },
2929+ { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" },
3030+ { name = "simplifile", version = "2.0.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "5FFEBD0CAB39BDD343C3E1CCA6438B2848847DC170BA2386DF9D7064F34DF000" },
3131+ { 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" },
3232+ { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" },
3333+ { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" },
3434+ { name = "trie_again", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "5B19176F52B1BD98831B57FDC97BD1F88C8A403D6D8C63471407E78598E27184" },
3535+]
3636+3737+[requirements]
3838+argv = { version = ">= 1.0.2 and < 2.0.0" }
3939+birdie = { version = ">= 1.1.8 and < 2.0.0" }
4040+envoy = { version = ">= 1.0.1 and < 2.0.0" }
4141+eval = { version = ">= 1.0.0 and < 2.0.0" }
4242+filepath = { version = ">= 1.0.0 and < 2.0.0" }
4343+glam = { version = ">= 2.0.1 and < 3.0.0" }
4444+gleam_community_ansi = { version = ">= 1.4.0 and < 2.0.0" }
4545+gleam_json = { version = ">= 1.0.0 and < 3.0.0" }
4646+gleam_pgo = { version = ">= 0.13.0 and < 1.0.0" }
4747+gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" }
4848+gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
4949+justin = { version = ">= 1.0.1 and < 2.0.0" }
5050+mug = { version = ">= 1.1.0 and < 2.0.0" }
5151+simplifile = { version = ">= 2.0.1 and < 3.0.0" }
5252+temporary = { version = ">= 1.0.0 and < 2.0.0" }
5353+term_size = { version = ">= 1.0.1 and < 2.0.0" }
+250
src/squirrel.gleam
···11+import envoy
22+import filepath
33+import glam/doc.{type Document}
44+import gleam/bool
55+import gleam/dict.{type Dict}
66+import gleam/int
77+import gleam/io
88+import gleam/list
99+import gleam/result
1010+import gleam/string
1111+import gleam_community/ansi
1212+import simplifile
1313+import squirrel/internal/database/postgres
1414+import squirrel/internal/error.{type Error, CannotWriteToFile}
1515+import squirrel/internal/query.{type TypedQuery}
1616+import term_size
1717+1818+const squirrel_version = "v1.0.0"
1919+2020+/// 🐿️ Performs code generation for your Gleam project.
2121+///
2222+/// `squirrel` is not configurable and will discover the queries to generate
2323+/// code for by relying on a conventional project's structure:
2424+/// - `squirrel` first looks for all directories called `sql` under the `src`
2525+/// directory of your Gleam project, and reads all the `*.sql` files in there
2626+/// (in glob terms `src/**/sql/*.sql`).
2727+/// - Each `*.sql` file _must contain a single query_ as it is turned into a
2828+/// Gleam function with the same name.
2929+/// - All functions coming from the same `sql` directory will be grouped under
3030+/// a Gleam file called `sql.gleam` at the same level: given a `src/$PATH/sql`
3131+/// directory, you'll end up with a generated `src/$PATH/sql.gleam` file.
3232+///
3333+/// > ⚠️ In order to generate type safe code, `squirrel` has to connect
3434+/// > to your Postgres database. To know what host, user, etc. values to use
3535+/// > when connecting, it will read your
3636+/// > [Postgres env variables.](https://www.postgresql.org/docs/current/libpq-envars.html)
3737+/// >
3838+/// > If a variable is not set it will go with the following defaults:
3939+/// > - `PGHOST`: `"localhost"`
4040+/// > - `PGPORT`: `5432`
4141+/// > - `PGUSER`: `"root"`
4242+/// > - `PGDATABASE`: `"database"`
4343+/// > - `PGPASSWORD`: `""`
4444+///
4545+/// > ⚠️ The generated code relies on the
4646+/// > [`gleam_pgo`](https://hexdocs.pm/gleam_pgo/) and
4747+/// > [`decode`](https://hexdocs.pm/decode/) packages to work, so make sure to
4848+/// > add those as dependencies to your project.
4949+///
5050+pub fn main() {
5151+ walk("src")
5252+ |> run(read_connection_options())
5353+ |> pretty_report
5454+ |> io.println
5555+}
5656+5757+fn read_connection_options() -> postgres.ConnectionOptions {
5858+ let host = envoy.get("PGHOST") |> result.unwrap("localhost")
5959+ let user = envoy.get("PGUSER") |> result.unwrap("root")
6060+ let database = envoy.get("PGDATABASE") |> result.unwrap("database")
6161+ let password = envoy.get("PGPASSWORD") |> result.unwrap("")
6262+ let port =
6363+ envoy.get("PGPORT")
6464+ |> result.then(int.parse)
6565+ |> result.unwrap(5432)
6666+6767+ postgres.ConnectionOptions(
6868+ host: host,
6969+ port: port,
7070+ user: user,
7171+ password: password,
7272+ database: database,
7373+ timeout: 1000,
7474+ )
7575+}
7676+7777+/// Finds all `from/**/sql` directories and lists the full paths of the `*.sql`
7878+/// files inside each one.
7979+///
8080+fn walk(from: String) -> Dict(String, List(String)) {
8181+ case filepath.base_name(from) {
8282+ "sql" -> {
8383+ let assert Ok(files) = simplifile.read_directory(from)
8484+ let files = {
8585+ use file <- list.filter_map(files)
8686+ use extension <- result.try(filepath.extension(file))
8787+ use <- bool.guard(when: extension != "sql", return: Error(Nil))
8888+ let file_name = filepath.join(from, file)
8989+ case simplifile.is_file(file_name) {
9090+ Ok(True) -> Ok(file_name)
9191+ Ok(False) | Error(_) -> Error(Nil)
9292+ }
9393+ }
9494+ dict.from_list([#(from, files)])
9595+ }
9696+9797+ _ -> {
9898+ let assert Ok(files) = simplifile.read_directory(from)
9999+ let directories = {
100100+ use file <- list.filter_map(files)
101101+ let file_name = filepath.join(from, file)
102102+ case simplifile.is_directory(file_name) {
103103+ Ok(True) -> Ok(file_name)
104104+ Ok(False) | Error(_) -> Error(Nil)
105105+ }
106106+ }
107107+108108+ list.map(directories, walk)
109109+ |> list.fold(from: dict.new(), with: dict.merge)
110110+ }
111111+ }
112112+}
113113+114114+/// Given a dict of directories and their `*.sql` files, performs code
115115+/// generation for each one, bundling all `*.sql` files under the same directory
116116+/// into a single Gleam module.
117117+///
118118+fn run(
119119+ directories: Dict(String, List(String)),
120120+ connection: postgres.ConnectionOptions,
121121+) -> Dict(String, #(Int, List(Error))) {
122122+ use directory, files <- dict.map_values(directories)
123123+124124+ let #(queries, errors) =
125125+ list.map(files, query.from_file)
126126+ |> result.partition
127127+128128+ let #(queries, errors) = case postgres.main(queries, connection) {
129129+ Error(error) -> #([], [error, ..errors])
130130+ Ok(#(queries, type_errors)) -> #(queries, list.append(errors, type_errors))
131131+ }
132132+133133+ let output_file =
134134+ filepath.directory_name(directory)
135135+ |> filepath.join("sql.gleam")
136136+137137+ case write_queries(queries, to: output_file) {
138138+ Ok(n) -> #(n, errors)
139139+ Error(error) -> #(list.length(queries), [error, ..errors])
140140+ }
141141+}
142142+143143+fn write_queries(
144144+ queries: List(TypedQuery),
145145+ to file: String,
146146+) -> Result(Int, Error) {
147147+ use <- bool.guard(when: queries == [], return: Ok(0))
148148+149149+ let directory = filepath.directory_name(file)
150150+ let _ = simplifile.create_directory_all(directory)
151151+152152+ // We need the top level imports.
153153+ let imports = "import gleam/pgo\nimport decode\n"
154154+ let #(count, code) = {
155155+ use #(count, code), query <- list.fold(queries, #(0, imports))
156156+ #(count + 1, code <> "\n" <> query.generate_code(squirrel_version, query))
157157+ }
158158+159159+ let try_write =
160160+ simplifile.write(code, to: file)
161161+ |> result.map_error(CannotWriteToFile(file, _))
162162+163163+ use _ <- result.try(try_write)
164164+ Ok(count)
165165+}
166166+167167+// --- PRETTY REPORT PRINTING --------------------------------------------------
168168+169169+fn pretty_report(dirs: Dict(String, #(Int, List(Error)))) -> String {
170170+ let width = term_size.columns() |> result.unwrap(80)
171171+ let #(ok, errors) = {
172172+ use #(all_ok, all_errors), _, result <- dict.fold(dirs, #(0, []))
173173+ let #(ok, errors) = result
174174+ #(all_ok + ok, errors |> list.append(all_errors))
175175+ }
176176+ let errors_doc =
177177+ list.map(errors, error.to_doc)
178178+ |> doc.join(with: doc.lines(2))
179179+180180+ case ok, errors {
181181+ 0, [_, ..] -> doc.to_string(errors_doc, width)
182182+ 0, [] ->
183183+ text_with_header(
184184+ "🐿️ ",
185185+ "I couldn't find any `*.sql` file to generate queries from",
186186+ )
187187+ |> doc.to_string(width)
188188+ |> ansi.yellow
189189+190190+ n, [] ->
191191+ text_with_header(
192192+ "🐿️ ",
193193+ "Generated "
194194+ <> int.to_string(n)
195195+ <> " "
196196+ <> pluralise(n, "query", "queries"),
197197+ )
198198+ |> doc.to_string(width)
199199+ |> ansi.green
200200+ |> string.append("\n")
201201+ |> string.append(
202202+ text_with_header(
203203+ "🥜 ",
204204+ "Don't forget to run `gleam add decode gleam_pgo` if you haven't yet!",
205205+ )
206206+ |> doc.to_string(width)
207207+ |> ansi.cyan,
208208+ )
209209+210210+ n, [_, ..] ->
211211+ [
212212+ errors_doc,
213213+ doc.lines(2),
214214+ text_with_header(
215215+ "🥜 ",
216216+ "I could still generate "
217217+ <> int.to_string(n)
218218+ <> " "
219219+ <> pluralise(n, "query", "queries"),
220220+ ),
221221+ ]
222222+ |> doc.concat
223223+ |> doc.to_string(width)
224224+ }
225225+}
226226+227227+fn text_with_header(header: String, text: String) {
228228+ [
229229+ doc.from_string(header),
230230+ flexible_string(text)
231231+ |> doc.nest(by: string.length(header)),
232232+ ]
233233+ |> doc.concat
234234+ |> doc.group
235235+}
236236+237237+fn pluralise(count: Int, singular: String, plural: String) -> String {
238238+ case count {
239239+ 1 -> singular
240240+ _ -> plural
241241+ }
242242+}
243243+244244+fn flexible_string(string: String) -> Document {
245245+ string.split(string, on: "\n")
246246+ |> list.flat_map(string.split(_, on: " "))
247247+ |> list.map(doc.from_string)
248248+ |> doc.join(with: doc.flex_space)
249249+ |> doc.group
250250+}
+899
src/squirrel/internal/database/postgres.gleam
···11+//// In this module lies the core of `squirrel`.
22+//// It exposes a single public function called `main` that is used to turn a
33+//// list of untyped queries into typed ones.
44+////
55+//// To do so, `squirrel` will try to connect to a database, have it parse the
66+//// queries and reply with the types it could infer.
77+//// Then it's as simple as (not that simple in practice 😆) converting the
88+//// Postgres types into Gleam types.
99+////
1010+//// > 💡 I tried to do my best to comment everything as much as possible and
1111+//// > make things easy to read.
1212+//// > If you feel something is poorly commented or hard to understand, then
1313+//// > that is a bug! Please do reach out, I'd love to hear your feedback.
1414+////
1515+1616+import eval
1717+import gleam/bit_array
1818+import gleam/bool
1919+import gleam/dict.{type Dict}
2020+import gleam/dynamic.{type DecodeErrors, type Dynamic} as d
2121+import gleam/int
2222+import gleam/json
2323+import gleam/list
2424+import gleam/option.{type Option, None, Some}
2525+import gleam/result
2626+import gleam/set.{type Set}
2727+import gleam/string
2828+import squirrel/internal/database/postgres_protocol as pg
2929+import squirrel/internal/error.{
3030+ type Error, type Pointer, type ValueIdentifierError, ByteIndex,
3131+ CannotParseQuery, PgCannotAuthenticate, PgCannotDecodeReceivedMessage,
3232+ PgCannotDescribeQuery, PgCannotReceiveMessage, PgCannotSendMessage, Pointer,
3333+ QueryHasInvalidColumn, QueryHasUnsupportedType,
3434+}
3535+import squirrel/internal/eval_extra
3636+import squirrel/internal/gleam
3737+import squirrel/internal/query.{
3838+ type TypedQuery, type UntypedQuery, TypedQuery, UntypedQuery,
3939+}
4040+4141+const find_postgres_type_query = "
4242+select
4343+ -- The name of the type or, if the type is an array, the name of its
4444+ -- elements' type.
4545+ case
4646+ when elem.typname is null then type.typname
4747+ else elem.typname
4848+ end as type,
4949+ -- Tells us how to interpret the firs column: if this is true then the first
5050+ -- column is the type of the elements of the array type.
5151+ -- Otherwise it means we've found a base type.
5252+ case
5353+ when elem.typname is null then false
5454+ else true
5555+ end as is_array
5656+from
5757+ pg_type as type
5858+ left join pg_type as elem on type.typelem = elem.oid
5959+where
6060+ type.oid = $1
6161+"
6262+6363+const find_column_nullability_query = "
6464+select
6565+ -- Whether the column has a not-null constraint.
6666+ attnotnull
6767+from
6868+ pg_attribute
6969+where
7070+ -- The oid of the table the column comes from.
7171+ attrelid = $1
7272+ -- The index of the column we're looking for.
7373+ and attnum = $2
7474+"
7575+7676+// --- TYPES -------------------------------------------------------------------
7777+7878+/// A Postgres type.
7979+///
8080+/// > ⚠️Postgres has loads of types and this might not cover the more exotic
8181+/// > ones but for now it feels more than enough.
8282+///
8383+type PgType {
8484+ /// A base type, like `integer`, `text`, `char`, ...
8585+ ///
8686+ PBase(name: String)
8787+8888+ /// An array type like `int[]`, `text[]`, ...
8989+ ///
9090+ PArray(inner: PgType)
9191+9292+ /// A type that could also be `NULL`, this is particularly common for columns
9393+ /// that do not have a `not null` constraint; or for those coming from partial
9494+ /// joins.
9595+ ///
9696+ POption(inner: PgType)
9797+}
9898+9999+/// The context in which all database-related actions will take place.
100100+///
101101+type Context {
102102+ Context(
103103+ /// A connection to the database. Squirrel does nothing fancy and just uses
104104+ /// a single connection to run all the queries.
105105+ ///
106106+ db: pg.Connection,
107107+ /// A cache from `oid` to corresponding Gleam type.
108108+ /// We use this to avoid having to reach to the database every time we need
109109+ /// to infer a type.
110110+ ///
111111+ /// > 💡 An oid is an integer identifier that is used by Postgres to
112112+ /// > uniquely identify types (and a lot of other various objects, see
113113+ /// > [this documentation page](https://www.postgresql.org/docs/current/datatype-oid.html)).
114114+ ///
115115+ gleam_types: Dict(Int, gleam.Type),
116116+ /// A cache from table `oid` and column index to its nullability.
117117+ /// We use this to avoid having to reach to the database every time we need
118118+ /// to type a column.
119119+ ///
120120+ column_nullability: Dict(#(Int, Int), Nullability),
121121+ )
122122+}
123123+124124+/// Information about a column's nullability.
125125+/// If a column has a `not null` constraint then it will be `NotNullable`, in
126126+/// all other cases it will be `Nullable`.
127127+///
128128+/// > ⚠️ A column with a `not null` constraint might still be considered
129129+/// > nullable if it comes from a left/right join!
130130+///
131131+type Nullability {
132132+ Nullable
133133+ NotNullable
134134+}
135135+136136+/// A query plan produced by Postgres when we ask it to `explain` a query.
137137+///
138138+type Plan {
139139+ Plan(
140140+ join_type: Option(JoinType),
141141+ parent_relation: Option(ParentRelation),
142142+ output: Option(List(String)),
143143+ plans: Option(List(Plan)),
144144+ )
145145+}
146146+147147+type JoinType {
148148+ Full
149149+ Left
150150+ Right
151151+ Other
152152+}
153153+154154+type ParentRelation {
155155+ Inner
156156+ NotInner
157157+}
158158+159159+/// This is the type of a database-related action.
160160+/// In order to be carried out it needs to have access to the database `Context`
161161+/// and could fail with an `Error`.
162162+///
163163+type Db(a) =
164164+ eval.Eval(a, Error, Context)
165165+166166+/// The options used to establish a connection to the Postgres database.
167167+///
168168+pub type ConnectionOptions {
169169+ ConnectionOptions(
170170+ host: String,
171171+ port: Int,
172172+ user: String,
173173+ password: String,
174174+ database: String,
175175+ timeout: Int,
176176+ )
177177+}
178178+179179+// --- POSTGRES TO GLEAM TYPES CONVERSIONS -------------------------------------
180180+181181+/// This function turns a Postgres type into a Gleam one, returning an error
182182+/// with the type name if it is not currently supported.
183183+///
184184+fn pg_to_gleam_type(type_: PgType) -> Result(gleam.Type, String) {
185185+ case type_ {
186186+ PArray(inner: inner) ->
187187+ pg_to_gleam_type(inner)
188188+ |> result.map(gleam.List)
189189+ |> result.map_error(fn(inner) { inner <> "[]" })
190190+191191+ POption(inner: inner) ->
192192+ pg_to_gleam_type(inner)
193193+ |> result.map(gleam.Option)
194194+ |> result.map_error(fn(inner) { inner <> "?" })
195195+196196+ PBase(name: name) ->
197197+ case name {
198198+ "bool" -> Ok(gleam.Bool)
199199+ "text" | "char" -> Ok(gleam.String)
200200+ "float4" | "float8" | "numeric" -> Ok(gleam.Float)
201201+ "int2" | "int4" | "int8" -> Ok(gleam.Int)
202202+ _ -> Error(name)
203203+ }
204204+ }
205205+}
206206+207207+// --- CLI ENTRY POINT ---------------------------------------------------------
208208+209209+/// Connects to a Postgres database (using the given options) and types a list
210210+/// of queries.
211211+///
212212+/// This might fail with an `Error` if a database connection cannot be
213213+/// established, making it impossible to type any of the queries.
214214+/// Otherwise, it will try typing all the queries, retuning a list of typed ones
215215+/// and a list of possible errors for the ones it couldn't type.
216216+///
217217+pub fn main(
218218+ queries: List(UntypedQuery),
219219+ connection: ConnectionOptions,
220220+) -> Result(#(List(TypedQuery), List(Error)), Error) {
221221+ let context =
222222+ Context(
223223+ db: pg.connect(connection.host, connection.port, connection.timeout),
224224+ gleam_types: dict.new(),
225225+ column_nullability: dict.new(),
226226+ )
227227+228228+ // Once the server has confirmed that it is ready to accept query requests we
229229+ // can start gathering information about all the different queries.
230230+ // After each one we need to make sure the server is ready to go on with the
231231+ // next one.
232232+ //
233233+ // https://www.postgresql.org/docs/current/protocol-flow.html#PROTOCOL-FLOW-EXT-QUERY
234234+ //
235235+ let #(context, connection) = eval.step(authenticate(connection), context)
236236+ case connection {
237237+ Error(error) -> Error(error)
238238+ // After successfully authenticating we can try and type all the queries.
239239+ Ok(_) ->
240240+ list.map(queries, infer_types)
241241+ |> eval_extra.run_all(context)
242242+ |> result.partition
243243+ |> Ok
244244+ }
245245+}
246246+247247+fn authenticate(connection: ConnectionOptions) -> Db(Nil) {
248248+ let params = [#("user", connection.user), #("database", connection.database)]
249249+ use _ <- eval.try(send(pg.FeStartupMessage(params)))
250250+251251+ use msg <- eval.try(receive())
252252+ use _ <- eval.try(case msg {
253253+ pg.BeAuthenticationOk -> eval.return(Nil)
254254+ _ -> unexpected_message(PgCannotAuthenticate, "AuthenticationOk", msg)
255255+ })
256256+257257+ use _ <- eval.try(wait_until_ready())
258258+ eval.return(Nil)
259259+}
260260+261261+/// Returns type information about a query.
262262+///
263263+fn infer_types(query: UntypedQuery) -> Db(TypedQuery) {
264264+ // Postgres doesn't give us 100% accurate data regardin a query's type.
265265+ // We'll need to perform a couple of database interrogations and do some
266266+ // guessing.
267267+ //
268268+ // The big picture idea is the following:
269269+ // - We ask the server to prepare the query.
270270+ // - Postgres will reply with type information about the returned rows and the
271271+ // query's parameters.
272272+ let action = parameters_and_returns(query)
273273+ use #(parameters, returns) <- eval.try(action)
274274+ // - The parameters' types are just OIDs so we need to interrogate the
275275+ // database to learn the actual corresponding Gleam type.
276276+ use parameters <- eval.try(resolve_parameters(query, parameters))
277277+ // - The returns' types are just OIDs so we have to do the same for those.
278278+ // - Here comes the tricky part: we can't know if a column is nullable just
279279+ // from the server's previous answer.
280280+ // - For columns coming from a database table we'll look it up and see if
281281+ // the column is nullable or not
282282+ // - But this is not enough! If a returned column comes from a left/right
283283+ // join it will be nullable even if it is not in the original table.
284284+ // To work around this we'll have to inspect the query plan.
285285+ use plan <- eval.try(query_plan(query, list.length(parameters)))
286286+ let nullables = nullables_from_plan(plan)
287287+ use returns <- eval.try(resolve_returns(query, returns, nullables))
288288+289289+ query
290290+ |> query.add_types(parameters, returns)
291291+ |> eval.return
292292+}
293293+294294+fn parameters_and_returns(query: UntypedQuery) -> Db(_) {
295295+ // We need to send three messages:
296296+ // - `Parse` with the query to parse
297297+ // - `Describe` to ask the server to reply with a description of the query's
298298+ // return types and parameter types. This is what we need to understand the
299299+ // SQL inferred types and generate the corresponding Gleam types.
300300+ // - `Sync` to ask the server to immediately reply with the results of parsing
301301+ //
302302+ use _ <- eval.try(
303303+ send_all([
304304+ pg.FeParse("", query.content, []),
305305+ pg.FeDescribe(pg.PreparedStatement, ""),
306306+ pg.FeSync,
307307+ ]),
308308+ )
309309+310310+ // Error builder used in the following steps in case the message sequence
311311+ // doesn't go as planned.
312312+ let cannot_describe = fn(expected, got) {
313313+ PgCannotDescribeQuery(
314314+ file: query.file,
315315+ query_name: gleam.identifier_to_string(query.name),
316316+ expected: expected,
317317+ got: got,
318318+ )
319319+ }
320320+321321+ use msg <- eval.try(receive())
322322+ case msg {
323323+ pg.BeErrorResponse(errors) ->
324324+ eval.throw(error_fields_to_parse_error(query, errors))
325325+ pg.BeParseComplete -> {
326326+ use msg <- eval.try(receive())
327327+ use parameters <- eval.try(case msg {
328328+ pg.BeParameterDescription(parameters) -> eval.return(parameters)
329329+ _ -> unexpected_message(cannot_describe, "ParameterDescription", msg)
330330+ })
331331+332332+ use msg <- eval.try(receive())
333333+ use rows <- eval.try(case msg {
334334+ pg.BeRowDescriptions(rows) -> eval.return(rows)
335335+ _ -> unexpected_message(cannot_describe, "RowDescriptions", msg)
336336+ })
337337+338338+ use msg <- eval.try(receive())
339339+ use _ <- eval.try(case msg {
340340+ pg.BeReadyForQuery(_) -> eval.return(Nil)
341341+ _ -> unexpected_message(cannot_describe, "ReadyForQuery", msg)
342342+ })
343343+344344+ eval.return(#(parameters, rows))
345345+ }
346346+ _ ->
347347+ unexpected_message(cannot_describe, "ParseComplete or ErrorResponse", msg)
348348+ }
349349+}
350350+351351+/// Given an untyped query and the error fields we got back from the database in
352352+/// case it couldn't be parsed, produces an appropriate `Error`.
353353+///
354354+fn error_fields_to_parse_error(
355355+ query: UntypedQuery,
356356+ errors: Set(pg.ErrorOrNoticeField),
357357+) -> Error {
358358+ // We first look for the relevant errors in the set of errors the database
359359+ // returned. This way we can attach additional information explaining why the
360360+ // query failed.
361361+ let #(error_code, message, position, hint) = {
362362+ use acc, error_field <- set.fold(errors, from: #(None, None, None, None))
363363+ let #(code, message, position, hint) = acc
364364+ case error_field {
365365+ pg.Code(code) -> #(Some(code), message, position, hint)
366366+ pg.Message(message) -> #(code, Some(message), position, hint)
367367+ pg.Hint(hint) -> #(code, message, position, Some(hint))
368368+ pg.Position(position) ->
369369+ case int.parse(position) {
370370+ Ok(position) -> #(code, message, Some(position), hint)
371371+ Error(_) -> acc
372372+ }
373373+ _ -> acc
374374+ }
375375+ }
376376+377377+ // If we found both a `Message` and `Position` error messages then we can turn
378378+ // those into a pointer that will be shown in the error message.
379379+ let pointer = case message, position {
380380+ Some(message), Some(position) ->
381381+ Some(Pointer(point_to: ByteIndex(position), message: message))
382382+ _, _ -> None
383383+ }
384384+385385+ cannot_parse_error(query, error_code, hint, pointer)
386386+}
387387+388388+fn resolve_parameters(
389389+ query: UntypedQuery,
390390+ parameters: List(Int),
391391+) -> Db(List(gleam.Type)) {
392392+ use oid <- eval_extra.try_map(parameters)
393393+ find_gleam_type(query, oid)
394394+}
395395+396396+/// Looks up a type with the given id in the Postgres registry.
397397+///
398398+/// > ⚠️ This function assumes that the oid is present in the database and
399399+/// > will crash otherwise. This should only be called with oids coming from
400400+/// > a database interrogation.
401401+///
402402+fn find_gleam_type(query: UntypedQuery, oid: Int) -> Db(gleam.Type) {
403403+ // We first look for the Gleam type corresponding to this id in the cache to
404404+ // avoid hammering the db with needless queries.
405405+ use <- with_cached_gleam_type(oid)
406406+407407+ // The only parameter to this query is the oid of the type to lookup:
408408+ // that's a 32bit integer (its oid needed to prepare the query is 23).
409409+ let params = [pg.Parameter(<<oid:32>>)]
410410+ let run_query = find_postgres_type_query |> run_query(params, [23])
411411+ use res <- eval.try(run_query)
412412+413413+ // We know the output must only contain two values: the name and a boolean to
414414+ // check wether it is an array or not.
415415+ // It's safe to assert because this query is hard coded in our code and the
416416+ // output shape cannot change without us changing that query.
417417+ let assert [name, is_array] = res
418418+419419+ // We then decode the bitarrays we got as a result:
420420+ // - `name` is just a string
421421+ // - `is_array` is a pg boolean
422422+ let assert Ok(name) = bit_array.to_string(name)
423423+ let type_ = case bit_array_to_bool(is_array) {
424424+ True -> PArray(PBase(name))
425425+ False -> PBase(name)
426426+ }
427427+428428+ pg_to_gleam_type(type_)
429429+ |> result.map_error(unsupported_type_error(query, _))
430430+ |> eval.from_result
431431+}
432432+433433+/// Returns the query plan for a given query.
434434+/// `parameters` is the number of parameter placeholders in the query.
435435+///
436436+fn query_plan(query: UntypedQuery, parameters: Int) -> Db(Plan) {
437437+ // We ask postgres to give us the query plan. To do that we need to fill in
438438+ // all the possible holes in the user supplied query with null values;
439439+ // otherwise, the server would complain that it has arguments that are not
440440+ // bound.
441441+ let query = "explain (format json, verbose) " <> query.content
442442+ let params = list.repeat(pg.Null, parameters)
443443+ let run_query = run_query(query, params, [])
444444+ use res <- eval.try(run_query)
445445+446446+ // We know the output will only contain a single row that is the json string
447447+ // containing the query plan.
448448+ let assert [plan] = res
449449+ let assert Ok([plan, ..]) = json.decode_bits(plan, json_plans_decoder)
450450+ eval.return(plan)
451451+}
452452+453453+/// Given a query plan, returns a set with the indices of the output columns
454454+/// that can contain null values.
455455+///
456456+fn nullables_from_plan(plan: Plan) -> Set(Int) {
457457+ let outputs = case plan.output {
458458+ Some(outputs) -> list.index_fold(outputs, dict.new(), dict.insert)
459459+ None -> dict.new()
460460+ }
461461+462462+ do_nullables_from_plan(plan, outputs, set.new())
463463+}
464464+465465+fn do_nullables_from_plan(
466466+ plan: Plan,
467467+ // A dict from "column name" to its position in the query output.
468468+ query_outputs: Dict(String, Int),
469469+ nullables: Set(Int),
470470+) -> Set(Int) {
471471+ let nullables = case plan.output, plan.join_type, plan.parent_relation {
472472+ // - All the outputs of a full join must be marked as nullable
473473+ // - All the outputs of an inner half join must be marked as nullable
474474+ Some(outputs), Some(Full), _ | Some(outputs), _, Some(Inner) -> {
475475+ use nullables, output <- list.fold(outputs, from: nullables)
476476+ case dict.get(query_outputs, output) {
477477+ Ok(i) -> set.insert(nullables, i)
478478+ Error(_) -> nullables
479479+ }
480480+ }
481481+ _, _, _ -> nullables
482482+ }
483483+484484+ case plan.plans, plan.join_type {
485485+ // If this is an inner half join we keep inspecting the children to mark
486486+ // their outputs as nullable.
487487+ Some(plans), Some(Left) | Some(plans), Some(Right) -> {
488488+ use nullables, plan <- list.fold(plans, from: nullables)
489489+ do_nullables_from_plan(plan, query_outputs, nullables)
490490+ }
491491+ _, _ -> nullables
492492+ }
493493+}
494494+495495+/// Given a list of `RowDescriptionFields` it turns those into Gleam fields with
496496+/// a name and a type.
497497+///
498498+/// This also uses nullability info coming from the query plan to figure out if
499499+/// a column can be nullable or not:
500500+/// - If the column name ends with `!` it will be forced to be not nullable
501501+/// - If the column name ends with `?` it will be forced to be nullable
502502+/// - If the column appears in the `nullables` set then it will be nullable
503503+/// - Othwerwise we look for its metadata in the database and if it has a
504504+/// not-null constraint it will be not nullable; otherwise it will be nullable
505505+///
506506+fn resolve_returns(
507507+ query: UntypedQuery,
508508+ returns: List(pg.RowDescriptionField),
509509+ nullables: Set(Int),
510510+) -> Db(List(gleam.Field)) {
511511+ use column, i <- eval_extra.try_index_map(returns)
512512+ let pg.RowDescriptionField(
513513+ data_type_oid: type_oid,
514514+ attr_number: column,
515515+ table_oid: table,
516516+ name: name,
517517+ ..,
518518+ ) = column
519519+520520+ use type_ <- eval.try(find_gleam_type(query, type_oid))
521521+522522+ let ends_with_exclamation_mark = string.ends_with(name, "!")
523523+ let ends_with_question_mark = string.ends_with(name, "?")
524524+ use nullability <- eval.try(case ends_with_exclamation_mark {
525525+ True -> eval.return(NotNullable)
526526+ False ->
527527+ case ends_with_question_mark {
528528+ True -> eval.return(Nullable)
529529+ False ->
530530+ case set.contains(nullables, i) {
531531+ True -> eval.return(Nullable)
532532+ False -> column_nullability(table: table, column: column)
533533+ }
534534+ }
535535+ })
536536+537537+ let type_ = case nullability {
538538+ Nullable -> gleam.Option(type_)
539539+ NotNullable -> type_
540540+ }
541541+542542+ let try_convert_name =
543543+ // If the name ends with a `?` or `!` we don't want that to be included in
544544+ // the gleam name or it would be invalid!
545545+ case ends_with_exclamation_mark || ends_with_question_mark {
546546+ True -> string.drop_right(name, 1)
547547+ False -> name
548548+ }
549549+ |> gleam.identifier
550550+ |> result.map_error(invalid_column_error(query, name, _))
551551+552552+ use name <- eval.try(eval.from_result(try_convert_name))
553553+554554+ let field = gleam.Field(label: name, type_: type_)
555555+ eval.return(field)
556556+}
557557+558558+fn column_nullability(table table: Int, column column: Int) -> Db(Nullability) {
559559+ // We first check if the table+column is cached to avoid making redundant
560560+ // queries to the database.
561561+ use <- with_cached_column(table: table, column: column)
562562+563563+ // If the table oid is 0 that means the column doesn't come from any table so
564564+ // we just assume it's not nullable.
565565+ use <- bool.guard(when: table == 0, return: eval.return(NotNullable))
566566+567567+ // This query has 2 parameters:
568568+ // - the oid of the table (a 32bit integer, oid is 23)
569569+ // - the index of the column (a 32 bit integer, oid is 23)
570570+ let params = [pg.Parameter(<<table:32>>), pg.Parameter(<<column:32>>)]
571571+ let run_query = find_column_nullability_query |> run_query(params, [23, 23])
572572+ use res <- eval.try(run_query)
573573+574574+ // We know the output will only have only one column, that is the boolean
575575+ // telling us if the column has a not-null constraint.
576576+ let assert [has_non_null_constraint] = res
577577+ case bit_array_to_bool(has_non_null_constraint) {
578578+ True -> eval.return(NotNullable)
579579+ False -> eval.return(Nullable)
580580+ }
581581+}
582582+583583+// --- DB ACTION HELPERS -------------------------------------------------------
584584+585585+/// Runs a query against the database.
586586+/// - `parameters` are the parameter values that need to be supplied in place of
587587+/// the query placeholders
588588+/// - `parameters_object_ids` are the oids describing the type of each
589589+/// parameter.
590590+///
591591+/// > ⚠️ The `parameters_objects_ids` should have the same length of
592592+/// > `parameters` and correctly describe each parameter's type. This function
593593+/// > makes no attempt whatsoever to verify this assumption is correct so be
594594+/// > careful!
595595+///
596596+/// > ⚠️ This function makes the assumption that the query will only return one
597597+/// > single row. This is totally fine here because we only use this to run
598598+/// > specific hard coded queries that are guaranteed to return a single row.
599599+///
600600+fn run_query(
601601+ query: String,
602602+ parameters: List(pg.ParameterValue),
603603+ parameters_object_ids: List(Int),
604604+) -> Db(List(BitArray)) {
605605+ // The message exchange to run a query works as follow:
606606+ // - `Parse` we ask the server to parse the query, we do not give it a name
607607+ // - `Bind` we bind the query to the unnamed portal so that it is ready to
608608+ // be executed.
609609+ // - `Execute` we ask the server to run the unnamed portal and return all
610610+ // the rows (0 means return all rows).
611611+ // - `Close` we ask to close the unnamed query and the unnamed portal to free
612612+ // their resources.
613613+ // - `Sync` this acts as a synchronization point that needs to go at the end
614614+ // of the sequence before the next one.
615615+ //
616616+ // As you can see in the receiving part, each message we send corresponds to a
617617+ // specific answer from the server:
618618+ // - `ParseComplete` the query was parsed correctly
619619+ // - `BindComplete` the query was bound to a portal
620620+ // - `MessageDataRow` the result(s) coming from the query execution
621621+ // - `CommandComplete` when the result coming from the query is over
622622+ // - `CloseComplete` the portal/statement was closed
623623+ // - `ReadyForQuery` final reply to the sync message signalling we can go on
624624+ // making new requests
625625+ use _ <- eval.try(
626626+ send_all([
627627+ pg.FeParse("", query, parameters_object_ids),
628628+ pg.FeBind(
629629+ portal: "",
630630+ statement_name: "",
631631+ parameter_format: pg.FormatAll(pg.Binary),
632632+ parameters:,
633633+ result_format: pg.FormatAll(pg.Binary),
634634+ ),
635635+ pg.FeExecute("", 0),
636636+ pg.FeClose(pg.PreparedStatement, ""),
637637+ pg.FeClose(pg.Portal, ""),
638638+ pg.FeSync,
639639+ ]),
640640+ )
641641+642642+ use msg <- eval.try(receive())
643643+ let assert pg.BeParseComplete = msg
644644+ use msg <- eval.try(receive())
645645+ let assert pg.BeBindComplete = msg
646646+ use msg <- eval.try(receive())
647647+ let assert pg.BeMessageDataRow(res) = msg
648648+ use msg <- eval.try(receive())
649649+ let assert pg.BeCommandComplete(_, _) = msg
650650+ use msg <- eval.try(receive())
651651+ let assert pg.BeCloseComplete = msg
652652+ use msg <- eval.try(receive())
653653+ let assert pg.BeCloseComplete = msg
654654+ use msg <- eval.try(receive())
655655+ let assert pg.BeReadyForQuery(_) = msg
656656+ eval.return(res)
657657+}
658658+659659+/// Receive a single message from the database.
660660+///
661661+fn receive() -> Db(pg.BackendMessage) {
662662+ use Context(db: db, ..) as context <- eval.from
663663+ case pg.receive(db) {
664664+ Ok(#(db, msg)) -> #(Context(..context, db: db), Ok(msg))
665665+ Error(pg.ReadDecodeError(error)) -> #(
666666+ context,
667667+ Error(PgCannotDecodeReceivedMessage(string.inspect(error))),
668668+ )
669669+ Error(pg.SocketError(error)) -> #(
670670+ context,
671671+ Error(PgCannotReceiveMessage(string.inspect(error))),
672672+ )
673673+ }
674674+}
675675+676676+/// Send a single message to the database.
677677+///
678678+fn send(message message: pg.FrontendMessage) -> Db(Nil) {
679679+ use Context(db: db, ..) as context <- eval.from
680680+681681+ let result =
682682+ message
683683+ |> pg.encode_frontend_message
684684+ |> pg.send(db, _)
685685+686686+ let #(db, result) = case result {
687687+ Ok(db) -> #(db, Ok(Nil))
688688+ Error(error) -> #(db, Error(PgCannotSendMessage(string.inspect(error))))
689689+ }
690690+691691+ #(Context(..context, db: db), result)
692692+}
693693+694694+/// Send many messages, one after the other.
695695+///
696696+fn send_all(messages messages: List(pg.FrontendMessage)) -> Db(Nil) {
697697+ use acc, msg <- eval_extra.try_fold(messages, from: Nil)
698698+ use _ <- eval.try(send(msg))
699699+ eval.return(acc)
700700+}
701701+702702+/// Start receiving and discarding messages until a `ReadyForQuery` message is
703703+/// received.
704704+///
705705+fn wait_until_ready() -> Db(Nil) {
706706+ use _ <- eval.try(send(pg.FeFlush))
707707+ do_wait_until_ready()
708708+}
709709+710710+fn do_wait_until_ready() -> Db(Nil) {
711711+ use msg <- eval.try(receive())
712712+ case msg {
713713+ pg.BeReadyForQuery(_) -> eval.return(Nil)
714714+ _ -> do_wait_until_ready()
715715+ }
716716+}
717717+718718+/// Throws an error built from an expected message and an unexpected message
719719+/// that is turned into a string.
720720+///
721721+fn unexpected_message(
722722+ builder: fn(String, String) -> Error,
723723+ expected expected: String,
724724+ got got: pg.BackendMessage,
725725+) {
726726+ builder(expected, string.inspect(got)) |> eval.throw
727727+}
728728+729729+/// Looks up for a type with the given id in a global cache.
730730+/// If the type is present it immediately returns it.
731731+/// Otherwise it runs the database action to fetch it and then caches it to be
732732+/// reused later.
733733+///
734734+fn with_cached_gleam_type(
735735+ lookup oid: Int,
736736+ otherwise do: fn() -> Db(gleam.Type),
737737+) -> Db(gleam.Type) {
738738+ use context: Context <- eval.from
739739+ case dict.get(context.gleam_types, oid) {
740740+ Ok(type_) -> #(context, Ok(type_))
741741+ Error(_) ->
742742+ case eval.step(do(), context) {
743743+ #(_, Error(_)) as result -> result
744744+ #(Context(gleam_types: gleam_types, ..) as context, Ok(type_)) -> {
745745+ let gleam_types = dict.insert(gleam_types, oid, type_)
746746+ let new_context = Context(..context, gleam_types: gleam_types)
747747+ #(new_context, Ok(type_))
748748+ }
749749+ }
750750+ }
751751+}
752752+753753+/// Looks up for the nullability of table's column.
754754+/// If the nullability is cached it is immediately returns it.
755755+/// Otherwise it runs the database action to fetch it and then caches it to be
756756+/// reused later.
757757+///
758758+fn with_cached_column(
759759+ table table_oid: Int,
760760+ column column: Int,
761761+ otherwise do: fn() -> Db(Nullability),
762762+) -> Db(Nullability) {
763763+ use context: Context <- eval.from
764764+ let key = #(table_oid, column)
765765+ case dict.get(context.column_nullability, key) {
766766+ Ok(type_) -> #(context, Ok(type_))
767767+ Error(_) ->
768768+ case eval.step(do(), context) {
769769+ #(_, Error(_)) as result -> result
770770+ #(
771771+ Context(
772772+ column_nullability: column_nullability,
773773+ ..,
774774+ ) as context,
775775+ Ok(type_),
776776+ ) -> {
777777+ let column_nullability = dict.insert(column_nullability, key, type_)
778778+ let new_context =
779779+ Context(..context, column_nullability: column_nullability)
780780+ #(new_context, Ok(type_))
781781+ }
782782+ }
783783+ }
784784+}
785785+786786+// --- HELPERS TO BUILD ERRORS -------------------------------------------------
787787+788788+fn unsupported_type_error(query: UntypedQuery, type_: String) -> Error {
789789+ let UntypedQuery(
790790+ content: content,
791791+ file: file,
792792+ name: name,
793793+ starting_line: starting_line,
794794+ comment: _,
795795+ ) = query
796796+ QueryHasUnsupportedType(
797797+ file: file,
798798+ name: gleam.identifier_to_string(name),
799799+ content: content,
800800+ type_: type_,
801801+ starting_line: starting_line,
802802+ )
803803+}
804804+805805+fn cannot_parse_error(
806806+ query: UntypedQuery,
807807+ error_code: Option(String),
808808+ hint: Option(String),
809809+ pointer: Option(Pointer),
810810+) -> Error {
811811+ let UntypedQuery(
812812+ content: content,
813813+ file: file,
814814+ name: name,
815815+ starting_line: starting_line,
816816+ comment: _,
817817+ ) = query
818818+ CannotParseQuery(
819819+ content: content,
820820+ file: file,
821821+ name: gleam.identifier_to_string(name),
822822+ error_code: error_code,
823823+ hint: hint,
824824+ pointer: pointer,
825825+ starting_line: starting_line,
826826+ )
827827+}
828828+829829+fn invalid_column_error(
830830+ query: UntypedQuery,
831831+ column_name: String,
832832+ reason: ValueIdentifierError,
833833+) -> Error {
834834+ let UntypedQuery(
835835+ name: _,
836836+ file: file,
837837+ content: content,
838838+ starting_line: starting_line,
839839+ comment: _,
840840+ ) = query
841841+ QueryHasInvalidColumn(
842842+ file: file,
843843+ column_name: column_name,
844844+ suggested_name: gleam.similar_identifier_string(column_name)
845845+ |> option.from_result,
846846+ content: content,
847847+ reason: reason,
848848+ starting_line: starting_line,
849849+ )
850850+}
851851+852852+// --- DECODERS ----------------------------------------------------------------
853853+854854+fn json_plans_decoder(data: Dynamic) -> Result(List(Plan), DecodeErrors) {
855855+ d.list(d.field("Plan", plan_decoder))(data)
856856+}
857857+858858+fn plan_decoder(data: Dynamic) -> Result(Plan, DecodeErrors) {
859859+ d.decode4(
860860+ Plan,
861861+ d.optional_field("Join Type", join_type_decoder),
862862+ d.optional_field("Parent Relationship", parent_relation_decoder),
863863+ d.optional_field("Output", d.list(d.string)),
864864+ d.optional_field("Plans", d.list(plan_decoder)),
865865+ )(data)
866866+}
867867+868868+fn join_type_decoder(data: Dynamic) -> Result(JoinType, DecodeErrors) {
869869+ use data <- result.map(d.string(data))
870870+ case data {
871871+ "Full" -> Full
872872+ "Left" -> Left
873873+ "Right" -> Right
874874+ _ -> Other
875875+ }
876876+}
877877+878878+fn parent_relation_decoder(
879879+ data: Dynamic,
880880+) -> Result(ParentRelation, DecodeErrors) {
881881+ use data <- result.map(d.string(data))
882882+ case data {
883883+ "Inner" -> Inner
884884+ _ -> NotInner
885885+ }
886886+}
887887+888888+// --- UTILS -------------------------------------------------------------------
889889+890890+/// Turns a bit array into a boolean value.
891891+/// Returns `False` if the bit array is all `0`s or empty, `True` otherwise.
892892+///
893893+fn bit_array_to_bool(bit_array: BitArray) -> Bool {
894894+ case bit_array {
895895+ <<0, rest:bits>> -> bit_array_to_bool(rest)
896896+ <<>> -> False
897897+ _ -> True
898898+ }
899899+}
···11+import glam/doc.{type Document}
22+import gleam/int
33+import gleam/list
44+import gleam/option.{type Option, None, Some}
55+import gleam/regex
66+import gleam/result
77+import gleam/string
88+import gleam_community/ansi
99+import simplifile
1010+1111+pub type Error {
1212+ // --- POSTGRES RELATED ERRORS -----------------------------------------------
1313+ /// When authentication workflow goes wrong.
1414+ /// TODO)) For now I only support no authentication so people might report
1515+ /// this issue.
1616+ ///
1717+ PgCannotAuthenticate(expected: String, got: String)
1818+1919+ /// When there's an error with the underlying socket and I cannot send
2020+ /// messages to the server.
2121+ ///
2222+ PgCannotSendMessage(reason: String)
2323+2424+ /// This comes from the `postgres_protocol` module, it happens if the server
2525+ /// sends back a malformed message or there's a type of messages decoding is
2626+ /// not implemented for.
2727+ /// This should never happen and warrants a bug report!
2828+ ///
2929+ PgCannotDecodeReceivedMessage(reason: String)
3030+3131+ /// When there's an error with the underlying socket and I cannot receive
3232+ /// messages to the server.
3333+ ///
3434+ PgCannotReceiveMessage(reason: String)
3535+3636+ /// When I cannot get a query description back from the postgres server.
3737+ ///
3838+ PgCannotDescribeQuery(
3939+ file: String,
4040+ query_name: String,
4141+ expected: String,
4242+ got: String,
4343+ )
4444+4545+ // --- OTHER GENERIC ERRORS --------------------------------------------------
4646+ /// When I cannot read a file containing queries.
4747+ ///
4848+ CannotReadFile(file: String, reason: simplifile.FileError)
4949+5050+ /// When the generated code cannot be written to a file.
5151+ ///
5252+ CannotWriteToFile(file: String, reason: simplifile.FileError)
5353+5454+ /// If an ".sql" file holding a query has a name that is not a valid Gleam
5555+ /// name.
5656+ /// Instead of trying to magically come up with a name we fail and report the
5757+ /// error.
5858+ ///
5959+ QueryFileHasInvalidName(
6060+ file: String,
6161+ suggested_name: Option(String),
6262+ reason: ValueIdentifierError,
6363+ )
6464+6565+ /// If a query returns a column that is not a valid Gleam identifier. Instead
6666+ /// of trying to magically come up with a name we fail and report the error.
6767+ ///
6868+ QueryHasInvalidColumn(
6969+ file: String,
7070+ column_name: String,
7171+ suggested_name: Option(String),
7272+ content: String,
7373+ starting_line: Int,
7474+ reason: ValueIdentifierError,
7575+ )
7676+7777+ /// When there's a param/return type that cannot be converted into a Gleam
7878+ /// type.
7979+ ///
8080+ QueryHasUnsupportedType(
8181+ file: String,
8282+ name: String,
8383+ content: String,
8484+ starting_line: Int,
8585+ type_: String,
8686+ )
8787+8888+ /// If the query contains an error and cannot be parsed by the DBMS.
8989+ ///
9090+ CannotParseQuery(
9191+ file: String,
9292+ name: String,
9393+ content: String,
9494+ starting_line: Int,
9595+ error_code: Option(String),
9696+ pointer: Option(Pointer),
9797+ hint: Option(String),
9898+ )
9999+}
100100+101101+pub type ValueIdentifierError {
102102+ DoesntStartWithLowercaseLetter
103103+ ContainsInvalidGrapheme(at: Int, grapheme: String)
104104+ IsEmpty
105105+}
106106+107107+/// Used to literally point to a particular piece of a string and attach a
108108+/// message to that point.
109109+///
110110+pub type Pointer {
111111+ Pointer(point_to: PointerKind, message: String)
112112+}
113113+114114+/// A pointer could either point to a specific byte of a String or it could
115115+/// point at a specific word (in that case it will point to the first occurrence
116116+/// of such word).
117117+///
118118+pub type PointerKind {
119119+ Name(name: String)
120120+ ByteIndex(position: Int)
121121+}
122122+123123+pub fn to_doc(error: Error) -> Document {
124124+ // Errors as they are, are not that easy to print. What we do here is turn
125125+ // each error into an easier-to-print data structure: a `PrintableError`
126126+ // using a nice declarative API. So we can ignore all the gory details of how
127127+ // that is actually printed and we do not have to make any effort to add and
128128+ // print new errors.
129129+ let printable_error = case error {
130130+ PgCannotSendMessage(reason: reason) ->
131131+ printable_error("Cannot send message")
132132+ |> add_paragraph(
133133+ "I ran into an unexpected error while trying to talk to the Postgres
134134+database server.",
135135+ )
136136+ |> report_bug(reason)
137137+138138+ PgCannotDecodeReceivedMessage(reason: reason) ->
139139+ printable_error("Cannot decode message")
140140+ |> add_paragraph(
141141+ "I ran into an unexpected error while trying to decode a message
142142+received from the Postgres database server.",
143143+ )
144144+ |> report_bug(reason)
145145+146146+ PgCannotReceiveMessage(reason: reason) ->
147147+ printable_error("Cannot receive message")
148148+ |> add_paragraph(
149149+ "I ran into an unexpected error while trying to listen to the Postgres
150150+database server.",
151151+ )
152152+ |> report_bug(reason)
153153+154154+ CannotReadFile(file: file, reason: reason) ->
155155+ printable_error("Cannot read file")
156156+ |> add_paragraph(
157157+ "I couldn't read "
158158+ <> style_file(file)
159159+ <> " because of the following error: "
160160+ <> simplifile.describe_error(reason),
161161+ )
162162+163163+ CannotWriteToFile(file: file, reason: reason) ->
164164+ printable_error("Cannot write to file")
165165+ |> add_paragraph(
166166+ "I couldn't write to "
167167+ <> style_file(file)
168168+ <> " because of the following error: "
169169+ <> simplifile.describe_error(reason),
170170+ )
171171+172172+ QueryFileHasInvalidName(
173173+ file: file,
174174+ suggested_name: suggested_name,
175175+ reason: _,
176176+ ) ->
177177+ printable_error("Query file with invalid name")
178178+ |> add_paragraph(
179179+ "File " <> style_file(file) <> " doesn't have a valid name.
180180+The name of a file is used to generate a corresponding Gleam function, so it
181181+should be a valid Gleam name.",
182182+ )
183183+ |> hint("A file name must start with a lowercase letter and can only
184184+contain lowercase letters, numbers and underscores." <> case suggested_name {
185185+ Some(name) ->
186186+ "\nMaybe try renaming it to " <> style_inline_code(name) <> "?"
187187+ None -> ""
188188+ })
189189+190190+ QueryHasInvalidColumn(
191191+ file: file,
192192+ column_name: column_name,
193193+ suggested_name: suggested_name,
194194+ content: content,
195195+ reason: reason,
196196+ starting_line: starting_line,
197197+ ) ->
198198+ case reason {
199199+ IsEmpty ->
200200+ printable_error("Column with empty name")
201201+ |> add_code_paragraph(
202202+ file: file,
203203+ content: content,
204204+ point: None,
205205+ starting_line: starting_line,
206206+ )
207207+ |> add_paragraph(
208208+ "A column returned by this query has the empty string as a name,
209209+all columns should have a valid Gleam name as name.",
210210+ )
211211+212212+ _ ->
213213+ printable_error("Column with invalid name")
214214+ |> add_code_paragraph(
215215+ file: file,
216216+ content: content,
217217+ starting_line: starting_line,
218218+ point: Some(
219219+ Pointer(point_to: Name(column_name), message: case
220220+ suggested_name
221221+ {
222222+ None -> "This is not a valid Gleam name"
223223+ Some(suggestion) ->
224224+ "This is not a valid Gleam name, maybe try "
225225+ <> style_inline_code(suggestion)
226226+ <> "?"
227227+ }),
228228+ ),
229229+ )
230230+ |> hint(
231231+ "A column name must start with a lowercase letter and can only
232232+contain lowercase letters, numbers and underscores.",
233233+ )
234234+ }
235235+236236+ QueryHasUnsupportedType(
237237+ file: file,
238238+ name: _,
239239+ content: content,
240240+ type_: type_,
241241+ starting_line: starting_line,
242242+ ) ->
243243+ printable_error("Unsupported type")
244244+ |> add_code_paragraph(
245245+ file: file,
246246+ content: content,
247247+ point: None,
248248+ starting_line: starting_line,
249249+ )
250250+ |> add_paragraph(
251251+ "One of the rows returned by this query has type "
252252+ <> style_inline_code(type_)
253253+ <> " which I cannot currently generate code for.",
254254+ )
255255+ |> call_to_action(for: "this type to be supported")
256256+257257+ CannotParseQuery(
258258+ file: file,
259259+ name: _name,
260260+ content: content,
261261+ starting_line: starting_line,
262262+ error_code: error_code,
263263+ hint: hint,
264264+ pointer: pointer,
265265+ ) ->
266266+ printable_error(case error_code {
267267+ Some(code) -> "Invalid query [" <> code <> "]"
268268+ None -> "Invalid query"
269269+ })
270270+ |> add_code_paragraph(
271271+ file: file,
272272+ content: content,
273273+ point: pointer,
274274+ starting_line: starting_line,
275275+ )
276276+ |> maybe_hint(hint)
277277+278278+ PgCannotAuthenticate(expected: expected, got: got) ->
279279+ printable_error("Cannot authenticate")
280280+ |> add_paragraph(
281281+ "I ran into an unexpected problem while trying to authenticate with the
282282+Postgres server. This is most definitely a bug!",
283283+ )
284284+ |> report_bug("Expected: " <> expected <> ", Got: " <> got)
285285+286286+ PgCannotDescribeQuery(
287287+ file: file,
288288+ query_name: query_name,
289289+ expected: expected,
290290+ got: got,
291291+ ) ->
292292+ printable_error("Cannot inspect query")
293293+ |> add_paragraph("I ran into an unexpected problem while trying to figure
294294+out the types of query " <> style_inline_code(query_name) <> "
295295+defined in " <> style_file(file) <> ". This is most definitely a bug!")
296296+ |> report_bug("Expected: " <> expected <> ", Got: " <> got)
297297+ }
298298+299299+ printable_error_to_doc(printable_error)
300300+}
301301+302302+fn style_file(file: String) -> String {
303303+ ansi.underline(file)
304304+}
305305+306306+fn style_inline_code(code: String) -> String {
307307+ "`" <> code <> "`"
308308+}
309309+310310+fn style_link(link: String) -> String {
311311+ ansi.underline(link)
312312+}
313313+314314+// --- ERROR PRETTY PRINTING ---------------------------------------------------
315315+316316+const indent = 2
317317+318318+type PrintableError {
319319+ PrintableError(
320320+ title: String,
321321+ body: List(Paragraph),
322322+ report_bug: Option(String),
323323+ call_to_action: Option(String),
324324+ hint: Option(String),
325325+ )
326326+}
327327+328328+type Paragraph {
329329+ Simple(String)
330330+ Code(
331331+ file: String,
332332+ content: String,
333333+ pointer: Option(Pointer),
334334+ starting_line: Int,
335335+ )
336336+}
337337+338338+/// A default printable error with just a title.
339339+///
340340+fn printable_error(title: String) -> PrintableError {
341341+ PrintableError(
342342+ title: title,
343343+ body: [],
344344+ report_bug: None,
345345+ hint: None,
346346+ call_to_action: None,
347347+ )
348348+}
349349+350350+fn add_paragraph(error: PrintableError, string: String) -> PrintableError {
351351+ PrintableError(..error, body: list.append(error.body, [Simple(string)]))
352352+}
353353+354354+fn add_code_paragraph(
355355+ error: PrintableError,
356356+ file file: String,
357357+ content content: String,
358358+ point point: Option(Pointer),
359359+ starting_line starting_line: Int,
360360+) -> PrintableError {
361361+ PrintableError(
362362+ ..error,
363363+ body: list.append(error.body, [
364364+ Code(
365365+ file: file,
366366+ content: content,
367367+ pointer: point,
368368+ starting_line: starting_line,
369369+ ),
370370+ ]),
371371+ )
372372+}
373373+374374+/// Sets a call to action to report a specific bug.
375375+///
376376+fn report_bug(error: PrintableError, report_bug: String) -> PrintableError {
377377+ PrintableError(..error, report_bug: Some(report_bug))
378378+}
379379+380380+/// Sets a hint that will be displayed at the bottom of the error message.
381381+///
382382+fn hint(error: PrintableError, hint: String) -> PrintableError {
383383+ PrintableError(..error, hint: Some(hint))
384384+}
385385+386386+/// Sets a hint that will be displayed at the bottom of the error message.
387387+///
388388+fn maybe_hint(error: PrintableError, hint: Option(String)) -> PrintableError {
389389+ PrintableError(..error, hint: hint)
390390+}
391391+392392+/// Given something a user might want to be added to the package it sets a
393393+/// call to action message telling someone to open a ticket on the `squirrel`
394394+/// repo.
395395+///
396396+fn call_to_action(error: PrintableError, for wanted: String) -> PrintableError {
397397+ PrintableError(..error, call_to_action: Some(wanted))
398398+}
399399+400400+fn printable_error_to_doc(error: PrintableError) -> Document {
401401+ // And now for the tricky bit...
402402+ let PrintableError(
403403+ title: title,
404404+ body: body,
405405+ report_bug: report_bug,
406406+ call_to_action: call_to_action,
407407+ hint: hint,
408408+ ) = error
409409+410410+ [
411411+ title_doc(title),
412412+ body_doc(body),
413413+ option_to_doc(report_bug, report_bug_doc),
414414+ option_to_doc(call_to_action, call_to_action_doc),
415415+ option_to_doc(hint, hint_doc),
416416+ ]
417417+ |> list.filter(keeping: fn(doc) { doc != doc.empty })
418418+ |> doc.join(with: doc.lines(2))
419419+ |> doc.group
420420+}
421421+422422+fn title_doc(title: String) -> Document {
423423+ doc.from_string(ansi.red(ansi.bold("Error: ") <> title))
424424+}
425425+426426+fn body_doc(body: List(Paragraph)) -> Document {
427427+ list.map(body, paragraph_doc)
428428+ |> doc.join(with: doc.line)
429429+ |> doc.group
430430+}
431431+432432+fn paragraph_doc(paragraph: Paragraph) -> Document {
433433+ case paragraph {
434434+ Simple(string) -> flexible_string(string)
435435+ Code(
436436+ file: file,
437437+ content: content,
438438+ pointer: pointer,
439439+ starting_line: starting_line,
440440+ ) ->
441441+ code_doc(
442442+ file: file,
443443+ content: content,
444444+ pointer: pointer,
445445+ starting_line: starting_line,
446446+ )
447447+ }
448448+}
449449+450450+fn code_doc(
451451+ file file: String,
452452+ content content: String,
453453+ pointer pointer: Option(Pointer),
454454+ starting_line starting_line: Int,
455455+) {
456456+ let pointer =
457457+ option.to_result(pointer, Nil)
458458+ |> result.then(pointer_doc(_, content))
459459+460460+ let content = syntax_highlight(content)
461461+ let lines = string.split(content, on: "\n")
462462+ let lines_count = list.length(lines)
463463+ let assert Ok(digits) = int.digits(lines_count + starting_line, 10)
464464+ let max_digits = list.length(digits)
465465+466466+ let code_lines = {
467467+ use line, i <- list.index_map(lines)
468468+ let prefix =
469469+ int.to_string(i + starting_line)
470470+ |> string.pad_left(to: max_digits + 2, with: " ")
471471+472472+ case pointer {
473473+ Ok(#(pointer_line, from, pointer_doc)) if pointer_line == i -> [
474474+ doc.from_string(ansi.dim(prefix <> " │ ")),
475475+ doc.from_string(line),
476476+ [doc.line, pointer_doc]
477477+ |> doc.concat
478478+ |> doc.nest(by: from + max_digits + 5),
479479+ ]
480480+481481+ Ok(_) | Error(_) -> [
482482+ doc.from_string(ansi.dim(prefix <> " │ ")),
483483+ doc.from_string(ansi.dim(line)),
484484+ ]
485485+ }
486486+ |> doc.concat
487487+ }
488488+489489+ let padding = string.repeat(" ", max_digits + 3)
490490+ [
491491+ doc.from_string(padding <> ansi.dim("╭─ " <> file)),
492492+ case starting_line {
493493+ 1 -> doc.from_string(padding <> ansi.dim("│ "))
494494+ _ -> doc.from_string(padding <> ansi.dim("┆ "))
495495+ },
496496+ ..code_lines
497497+ ]
498498+ |> doc.join(with: doc.line)
499499+ |> doc.append(doc.line)
500500+ |> doc.append(doc.from_string(padding <> ansi.dim("┆")))
501501+ |> doc.group
502502+}
503503+504504+fn pointer_doc(
505505+ pointer: Pointer,
506506+ content: String,
507507+) -> Result(#(Int, Int, Document), Nil) {
508508+ let Pointer(kind, message) = pointer
509509+ use #(line, from, to) <- result.try(find_span(kind, content))
510510+ let width = to - from + 1
511511+ let doc =
512512+ [
513513+ doc.zero_width_string("\u{001B}[31m"),
514514+ doc.from_string("┬" <> string.repeat("─", width - 1)),
515515+ doc.line,
516516+ doc.from_string("╰─ "),
517517+ flexible_string(message)
518518+ |> doc.nest(by: 3),
519519+ doc.zero_width_string("\u{001B}[0m"),
520520+ ]
521521+ |> doc.concat
522522+ |> doc.group
523523+524524+ Ok(#(line, from, doc))
525525+}
526526+527527+fn find_span(kind: PointerKind, string: String) -> Result(#(Int, Int, Int), Nil) {
528528+ case kind {
529529+ Name(name) -> find_name_span(name, string.length(name), string, 0, 0)
530530+ ByteIndex(n) -> find_byte_span(n - 1, string, 0, 0)
531531+ }
532532+}
533533+534534+fn find_name_span(
535535+ name: String,
536536+ name_len: Int,
537537+ string: String,
538538+ row: Int,
539539+ col: Int,
540540+) -> Result(#(Int, Int, Int), Nil) {
541541+ case string.starts_with(string, name) {
542542+ True -> Ok(#(row, col, col + name_len - 1))
543543+ False ->
544544+ case string.pop_grapheme(string) {
545545+ Ok(#("\n", rest)) -> find_name_span(name, name_len, rest, row + 1, 0)
546546+ Ok(#(_, rest)) -> find_name_span(name, name_len, rest, row, col + 1)
547547+ Error(_) -> Error(Nil)
548548+ }
549549+ }
550550+}
551551+552552+fn find_byte_span(
553553+ position: Int,
554554+ string: String,
555555+ row: Int,
556556+ col: Int,
557557+) -> Result(#(Int, Int, Int), Nil) {
558558+ case position {
559559+ 0 -> Ok(#(row, col, col))
560560+ n ->
561561+ case string.pop_grapheme(string) {
562562+ Ok(#("\n", rest)) -> find_byte_span(n - 1, rest, row + 1, 0)
563563+ Ok(#(_, rest)) -> find_byte_span(n - 1, rest, row, col + 1)
564564+ Error(_) -> Error(Nil)
565565+ }
566566+ }
567567+}
568568+569569+const keywords = [
570570+ "and", "any", "as", "asc", "begin", "between", "by", "case", "count", "desc",
571571+ "distinct", "else", "end", "exists", "from", "full", "group", "having", "if",
572572+ "in", "inner", "insert", "into", "join", "key", "left", "like", "not", "null",
573573+ "on", "or", "order", "primary", "revert", "right", "select", "set", "table",
574574+ "top", "trigger", "union", "update", "use", "values", "view", "where", "with",
575575+]
576576+577577+fn syntax_highlight(content: String) -> String {
578578+ let keywords = string.join(keywords, with: "|")
579579+ let not_inside_string = "(?=(?:[^']*'[^']*')*[^']*$)"
580580+581581+ let assert Ok(keyword) =
582582+ { "\\b(" <> keywords <> ")\\b" <> not_inside_string }
583583+ |> regex.compile(regex.Options(True, False))
584584+ let assert Ok(number) =
585585+ regex.from_string("(?<!\\$)\\b(\\d+(\\.\\d+)?\\b)" <> not_inside_string)
586586+ let assert Ok(comment) = regex.from_string("(^\\s*--.*)")
587587+ let assert Ok(string) = regex.from_string("(\\'.*\\')")
588588+ let assert Ok(hole) = regex.from_string("(\\$\\d+)" <> not_inside_string)
589589+590590+ content
591591+ |> regex.replace(each: comment, with: "\u{001B}[2m\\1\u{001B}[0m")
592592+ |> regex.replace(each: keyword, with: "\u{001B}[36m\\1\u{001B}[39m")
593593+ |> regex.replace(each: string, with: "\u{001B}[33m\\1\u{001B}[39m")
594594+ |> regex.replace(each: number, with: "\u{001B}[32m\\1\u{001B}[39m")
595595+ |> regex.replace(each: hole, with: "\u{001B}[35m\\1\u{001B}[39m")
596596+}
597597+598598+fn report_bug_doc(additional_info: String) -> Document {
599599+ [
600600+ flexible_string(
601601+ "Please open an issue at "
602602+ <> style_link("https://github.com/giacomocavalieri/squirrel/issues/new")
603603+ <> " with some details about what you where doing, including the following message:",
604604+ ),
605605+ doc.line |> doc.nest(by: indent),
606606+ doc.from_string(additional_info),
607607+ ]
608608+ |> doc.concat
609609+ |> doc.group
610610+}
611611+612612+fn call_to_action_doc(wanted: String) -> Document {
613613+ flexible_string(
614614+ "If you would like for "
615615+ <> wanted
616616+ <> ", please open an issue at "
617617+ <> style_link("https://github.com/giacomocavalieri/squirrel/issues/new"),
618618+ )
619619+}
620620+621621+fn hint_doc(hint: String) -> Document {
622622+ flexible_string("Hint: " <> hint)
623623+}
624624+625625+fn flexible_string(string: String) -> Document {
626626+ string.split(string, on: "\n")
627627+ |> list.flat_map(string.split(_, on: " "))
628628+ |> list.map(doc.from_string)
629629+ |> doc.join(with: doc.flex_space)
630630+ |> doc.group
631631+}
632632+633633+fn option_to_doc(option: Option(a), fun: fn(a) -> Document) -> Document {
634634+ case option {
635635+ Some(a) -> fun(a)
636636+ None -> doc.empty
637637+ }
638638+}
+77
src/squirrel/internal/eval_extra.gleam
···11+//// This package has some additional helpers to work with the `Eval` package.
22+////
33+44+import eval.{type Eval}
55+import gleam/list
66+77+pub fn try_map(
88+ list: List(a),
99+ fun: fn(a) -> Eval(b, _, _),
1010+) -> Eval(List(b), _, _) {
1111+ try_fold(list, [], fn(acc, item) {
1212+ use mapped_item <- eval.try(fun(item))
1313+ eval.return([mapped_item, ..acc])
1414+ })
1515+ |> eval.map(list.reverse)
1616+}
1717+1818+/// Runs a list of `Eval` actions in sequence sharing the same context.
1919+///
2020+pub fn run_all(list: List(Eval(a, b, c)), context: c) -> List(Result(a, b)) {
2121+ let acc = #([], context)
2222+ let #(results, _) = {
2323+ use #(results, context), script <- list.fold(list, acc)
2424+ let #(context, result) = eval.step(script, context)
2525+ #([result, ..results], context)
2626+ }
2727+2828+ results
2929+}
3030+3131+pub fn try_index_map(
3232+ list: List(a),
3333+ fun: fn(a, Int) -> Eval(b, _, _),
3434+) -> Eval(List(b), _, _) {
3535+ try_index_fold(list, [], fn(acc, item, i) {
3636+ use mapped_item <- eval.try(fun(item, i))
3737+ eval.return([mapped_item, ..acc])
3838+ })
3939+ |> eval.map(list.reverse)
4040+}
4141+4242+pub fn try_fold(
4343+ over list: List(a),
4444+ from acc: b,
4545+ with fun: fn(b, a) -> Eval(b, _, _),
4646+) -> Eval(b, _, _) {
4747+ case list {
4848+ [] -> eval.return(acc)
4949+ [first, ..rest] -> {
5050+ use acc <- eval.try(fun(acc, first))
5151+ try_fold(rest, acc, fun)
5252+ }
5353+ }
5454+}
5555+5656+fn try_index_fold(
5757+ over list: List(a),
5858+ from acc: b,
5959+ with fun: fn(b, a, Int) -> Eval(b, _, _),
6060+) -> Eval(b, _, _) {
6161+ do_try_index_fold(0, over: list, from: acc, with: fun)
6262+}
6363+6464+fn do_try_index_fold(
6565+ index: Int,
6666+ over list: List(a),
6767+ from acc: b,
6868+ with fun: fn(b, a, Int) -> Eval(b, _, _),
6969+) -> Eval(b, _, _) {
7070+ case list {
7171+ [] -> eval.return(acc)
7272+ [first, ..rest] -> {
7373+ use acc <- eval.try(fun(acc, first, index))
7474+ do_try_index_fold(index + 1, rest, acc, fun)
7575+ }
7676+ }
7777+}
+224
src/squirrel/internal/gleam.gleam
···11+import gleam/list
22+import gleam/string
33+import justin
44+import squirrel/internal/error.{
55+ type ValueIdentifierError, ContainsInvalidGrapheme, IsEmpty,
66+}
77+88+/// A Gleam type.
99+///
1010+pub type Type {
1111+ List(Type)
1212+ Option(Type)
1313+ Int
1414+ Float
1515+ Bool
1616+ String
1717+}
1818+1919+/// The labelled field of a Gleam record.
2020+///
2121+pub type Field {
2222+ Field(label: ValueIdentifier, type_: Type)
2323+}
2424+2525+/// A Gleam identifier, that is a string that starts with a lowercase letter,
2626+/// is in snake_case and can only contain lowercase letters, numbers and
2727+/// underscores.
2828+///
2929+/// > 💡 This can only be built using the `gleam.identifier` function that
3030+/// > ensures that a string is a valid Gleam identifier.
3131+///
3232+pub opaque type ValueIdentifier {
3333+ ValueIdentifier(String)
3434+}
3535+3636+/// Returns true if the given string is a valid Gleam identifier (that is not
3737+/// a discard identifier, that is starting with an '_').
3838+///
3939+/// > 💡 A valid identifier can be described by the following regex:
4040+/// > `[a-z][a-z0-9_]*`.
4141+pub fn identifier(
4242+ from name: String,
4343+) -> Result(ValueIdentifier, ValueIdentifierError) {
4444+ // A valid identifier needs to start with a lowercase letter.
4545+ // We do not accept _discard identifier as valid.
4646+ case name {
4747+ "a" <> rest
4848+ | "b" <> rest
4949+ | "c" <> rest
5050+ | "d" <> rest
5151+ | "e" <> rest
5252+ | "f" <> rest
5353+ | "g" <> rest
5454+ | "h" <> rest
5555+ | "i" <> rest
5656+ | "j" <> rest
5757+ | "k" <> rest
5858+ | "l" <> rest
5959+ | "m" <> rest
6060+ | "n" <> rest
6161+ | "o" <> rest
6262+ | "p" <> rest
6363+ | "q" <> rest
6464+ | "r" <> rest
6565+ | "s" <> rest
6666+ | "t" <> rest
6767+ | "u" <> rest
6868+ | "v" <> rest
6969+ | "w" <> rest
7070+ | "x" <> rest
7171+ | "y" <> rest
7272+ | "z" <> rest -> to_identifier_rest(name, rest, 1)
7373+ _ ->
7474+ case string.pop_grapheme(name) {
7575+ Ok(#(g, _)) -> Error(ContainsInvalidGrapheme(0, g))
7676+ Error(_) -> Error(IsEmpty)
7777+ }
7878+ }
7979+}
8080+8181+fn to_identifier_rest(
8282+ name: String,
8383+ rest: String,
8484+ position: Int,
8585+) -> Result(ValueIdentifier, ValueIdentifierError) {
8686+ // The rest of an identifier can only contain lowercase letters, _, numbers,
8787+ // or be empty. In all other cases it's not valid.
8888+ case rest {
8989+ "a" <> rest
9090+ | "b" <> rest
9191+ | "c" <> rest
9292+ | "d" <> rest
9393+ | "e" <> rest
9494+ | "f" <> rest
9595+ | "g" <> rest
9696+ | "h" <> rest
9797+ | "i" <> rest
9898+ | "j" <> rest
9999+ | "k" <> rest
100100+ | "l" <> rest
101101+ | "m" <> rest
102102+ | "n" <> rest
103103+ | "o" <> rest
104104+ | "p" <> rest
105105+ | "q" <> rest
106106+ | "r" <> rest
107107+ | "s" <> rest
108108+ | "t" <> rest
109109+ | "u" <> rest
110110+ | "v" <> rest
111111+ | "w" <> rest
112112+ | "x" <> rest
113113+ | "y" <> rest
114114+ | "z" <> rest
115115+ | "_" <> rest
116116+ | "0" <> rest
117117+ | "1" <> rest
118118+ | "2" <> rest
119119+ | "3" <> rest
120120+ | "4" <> rest
121121+ | "5" <> rest
122122+ | "6" <> rest
123123+ | "7" <> rest
124124+ | "8" <> rest
125125+ | "9" <> rest -> to_identifier_rest(name, rest, position + 1)
126126+ "" -> Ok(ValueIdentifier(name))
127127+ _ ->
128128+ case string.pop_grapheme(rest) {
129129+ Ok(#(g, _)) -> Error(ContainsInvalidGrapheme(position, g))
130130+ Error(_) -> panic as "unreachable: empty identifier rest should be ok"
131131+ }
132132+ }
133133+}
134134+135135+/// Turns an identifier back into a String.
136136+///
137137+pub fn identifier_to_string(identifier: ValueIdentifier) -> String {
138138+ let ValueIdentifier(name) = identifier
139139+ name
140140+}
141141+142142+/// Turns a Gleam identifier into a type name. That is it strips it of all its
143143+/// underscores and makes it PascalCase.
144144+///
145145+pub fn identifier_to_type_name(identifier: ValueIdentifier) -> String {
146146+ let ValueIdentifier(name) = identifier
147147+148148+ justin.pascal_case(name)
149149+ |> string.to_graphemes
150150+ // We want to remove any leftover "_" that might still be present after the
151151+ // conversion if the identifier had consecutive "_".
152152+ |> list.filter(keeping: fn(c) { c != "_" })
153153+ |> string.join(with: "")
154154+}
155155+156156+/// Tries to suggest a valid Gleam identifier as similar as possible to a given
157157+/// String.
158158+///
159159+/// If it cannot come up with a suggestion, it returns `Error(Nil)`.
160160+///
161161+pub fn similar_identifier_string(string: String) -> Result(String, Nil) {
162162+ let proposal =
163163+ string.trim(string)
164164+ |> justin.snake_case
165165+ |> string.to_graphemes
166166+ |> list.drop_while(fn(g) { g == "_" || is_digit(g) })
167167+ |> list.filter(keeping: is_identifier_char)
168168+ |> string.join(with: "")
169169+170170+ case proposal {
171171+ "" -> Error(Nil)
172172+ _ -> Ok(proposal)
173173+ }
174174+}
175175+176176+fn is_digit(char: String) -> Bool {
177177+ case char {
178178+ "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True
179179+ _ -> False
180180+ }
181181+}
182182+183183+fn is_identifier_char(char: String) -> Bool {
184184+ case char {
185185+ "a"
186186+ | "b"
187187+ | "c"
188188+ | "d"
189189+ | "e"
190190+ | "f"
191191+ | "g"
192192+ | "h"
193193+ | "i"
194194+ | "j"
195195+ | "k"
196196+ | "l"
197197+ | "m"
198198+ | "n"
199199+ | "o"
200200+ | "p"
201201+ | "q"
202202+ | "r"
203203+ | "s"
204204+ | "t"
205205+ | "u"
206206+ | "v"
207207+ | "w"
208208+ | "x"
209209+ | "y"
210210+ | "z"
211211+ | "_"
212212+ | "0"
213213+ | "1"
214214+ | "2"
215215+ | "3"
216216+ | "4"
217217+ | "5"
218218+ | "6"
219219+ | "7"
220220+ | "8"
221221+ | "9" -> True
222222+ _ -> False
223223+ }
224224+}
+418
src/squirrel/internal/query.gleam
···11+import filepath
22+import glam/doc.{type Document}
33+import gleam/int
44+import gleam/list
55+import gleam/option.{type Option}
66+import gleam/result
77+import gleam/string
88+import simplifile
99+import squirrel/internal/error.{
1010+ type Error, CannotReadFile, QueryFileHasInvalidName,
1111+}
1212+import squirrel/internal/gleam
1313+1414+/// A query that still needs to go through the type checking process.
1515+///
1616+pub type UntypedQuery {
1717+ UntypedQuery(
1818+ /// The file the query comes from.
1919+ ///
2020+ file: String,
2121+ /// The starting line in the source file where the query is defined.
2222+ ///
2323+ starting_line: Int,
2424+ /// The name of the query, it must be a valid Gleam identifier.
2525+ ///
2626+ name: gleam.ValueIdentifier,
2727+ /// Any comment lines that were preceding the query in the file.
2828+ ///
2929+ comment: List(String),
3030+ /// The text of the query itself.
3131+ ///
3232+ content: String,
3333+ )
3434+}
3535+3636+/// This is exactly the same as an untyped query with the difference that it
3737+/// has also been annotated with the type of its parameters and returned values.
3838+///
3939+pub type TypedQuery {
4040+ TypedQuery(
4141+ file: String,
4242+ starting_line: Int,
4343+ name: gleam.ValueIdentifier,
4444+ comment: List(String),
4545+ content: String,
4646+ params: List(gleam.Type),
4747+ returns: List(gleam.Field),
4848+ )
4949+}
5050+5151+/// Turns an untyped query into a typed one.
5252+///
5353+pub fn add_types(
5454+ to query: UntypedQuery,
5555+ params params: List(gleam.Type),
5656+ returns returns: List(gleam.Field),
5757+) -> TypedQuery {
5858+ let UntypedQuery(
5959+ file: file,
6060+ name: name,
6161+ comment: comment,
6262+ content: content,
6363+ starting_line: starting_line,
6464+ ) = query
6565+ TypedQuery(
6666+ file: file,
6767+ name: name,
6868+ comment: comment,
6969+ content: content,
7070+ starting_line: starting_line,
7171+ params: params,
7272+ returns: returns,
7373+ )
7474+}
7575+7676+// --- PARSING -----------------------------------------------------------------
7777+7878+/// Reads a query from a file.
7979+/// This expects the user to follow the convention of having a single query per
8080+/// file.
8181+///
8282+pub fn from_file(file: String) -> Result(UntypedQuery, Error) {
8383+ let read_file =
8484+ simplifile.read(file)
8585+ |> result.map_error(CannotReadFile(file, _))
8686+8787+ use content <- result.try(read_file)
8888+8989+ // A query always starts at the top of the file.
9090+ // If in the future I want to add support for many queries per file this
9191+ // field will be handy to properly show error messages.
9292+ let file_name =
9393+ filepath.base_name(file)
9494+ |> filepath.strip_extension
9595+ let name =
9696+ gleam.identifier(file_name)
9797+ |> result.map_error(QueryFileHasInvalidName(
9898+ file: file,
9999+ reason: _,
100100+ suggested_name: gleam.similar_identifier_string(file_name)
101101+ |> option.from_result,
102102+ ))
103103+104104+ use name <- result.try(name)
105105+ Ok(UntypedQuery(
106106+ file: file,
107107+ starting_line: 1,
108108+ name: name,
109109+ content: content,
110110+ comment: take_comment(content),
111111+ ))
112112+}
113113+114114+fn take_comment(query: String) -> List(String) {
115115+ do_take_comment(query, [])
116116+}
117117+118118+fn do_take_comment(query: String, lines: List(String)) -> List(String) {
119119+ case string.trim_left(query) {
120120+ "--" <> rest ->
121121+ case string.split_once(rest, on: "\n") {
122122+ Ok(#(line, rest)) -> do_take_comment(rest, [string.trim(line), ..lines])
123123+ _ -> do_take_comment("", [string.trim(rest), ..lines])
124124+ }
125125+ _ -> list.reverse(lines)
126126+ }
127127+}
128128+129129+// --- CODE GENERATION ---------------------------------------------------------
130130+131131+pub fn generate_code(version: String, query: TypedQuery) -> String {
132132+ let TypedQuery(
133133+ file: file,
134134+ name: name,
135135+ content: content,
136136+ comment: comment,
137137+ params: params,
138138+ returns: returns,
139139+ starting_line: _,
140140+ ) = query
141141+142142+ let arg_name = fn(i) { "arg_" <> int.to_string(i + 1) }
143143+ let inputs = list.index_map(params, fn(_, i) { arg_name(i) })
144144+ let inputs_encoders =
145145+ list.index_map(params, fn(p, i) {
146146+ gleam_type_to_encoder(p, arg_name(i)) |> doc.from_string
147147+ })
148148+149149+ let function_name = gleam.identifier_to_string(name)
150150+ let constructor_name = gleam.identifier_to_type_name(name) <> "Row"
151151+152152+ let record_doc =
153153+ "/// A row you get from running the `" <> function_name <> "` query
154154+/// defined in `" <> file <> "`.
155155+///
156156+/// > 🐿️ This type definition was generated automatically using " <> version <> " of the
157157+/// > [squirrel package](https://github.com/giacomocavalieri/squirrel).
158158+///"
159159+160160+ let fun_doc = case comment {
161161+ [] -> "/// Runs the `" <> function_name <> "` query
162162+/// defined in `" <> file <> "`."
163163+ [_, ..] ->
164164+ list.map(comment, string.append("/// ", _))
165165+ |> string.join(with: "\n")
166166+ }
167167+ let fun_doc = fun_doc <> "
168168+///
169169+/// > 🐿️ This function was generated automatically using " <> version <> " of
170170+/// > the [squirrel package](https://github.com/giacomocavalieri/squirrel).
171171+///"
172172+173173+ [
174174+ doc.from_string(record_doc),
175175+ doc.line,
176176+ record(constructor_name, returns),
177177+ doc.lines(2),
178178+ doc.from_string(fun_doc),
179179+ doc.line,
180180+ fun(function_name, ["db", ..inputs], [
181181+ var("decoder", decoder(constructor_name, returns)),
182182+ pipe_call("pgo.execute", string(content), [
183183+ doc.from_string("db"),
184184+ list(inputs_encoders),
185185+ doc.from_string("decode.from(decoder, _)"),
186186+ ]),
187187+ ]),
188188+ ]
189189+ |> doc.concat
190190+ |> doc.to_string(80)
191191+}
192192+193193+fn gleam_type_to_decoder(type_: gleam.Type) -> String {
194194+ case type_ {
195195+ gleam.List(type_) -> "decode.list(" <> gleam_type_to_decoder(type_) <> ")"
196196+ gleam.Int -> "decode.int"
197197+ gleam.Float -> "decode.float"
198198+ gleam.Bool -> "decode.bool"
199199+ gleam.String -> "decode.string"
200200+ gleam.Option(type_) ->
201201+ "decode.optional(" <> gleam_type_to_decoder(type_) <> ")"
202202+ }
203203+}
204204+205205+fn gleam_type_to_encoder(type_: gleam.Type, name: String) {
206206+ case type_ {
207207+ gleam.List(type_) ->
208208+ "pgo.array(list.map("
209209+ <> name
210210+ <> ", fn(a) {"
211211+ <> gleam_type_to_encoder(type_, "a")
212212+ <> "}))"
213213+ gleam.Option(type_) ->
214214+ "pgo.nullable(fn(a) {"
215215+ <> gleam_type_to_encoder(type_, "a")
216216+ <> "}, "
217217+ <> name
218218+ <> ")"
219219+ gleam.Int -> "pgo.int(" <> name <> ")"
220220+ gleam.Float -> "pgo.float(" <> name <> ")"
221221+ gleam.Bool -> "pgo.bool(" <> name <> ")"
222222+ gleam.String -> "pgo.text(" <> name <> ")"
223223+ }
224224+}
225225+226226+// --- CODE GENERATION PRETTY PRINTING -----------------------------------------
227227+// These are just a couple of handy helpers to make it easier to generate code
228228+// for a query.
229229+//
230230+// It makes a best effort to also make the generated code look nice.
231231+// Due to some missing features in `glam`, it doesn't reimplement 100% of
232232+// Gleam's own pretty printer so it might have a different look in some places.
233233+//
234234+235235+const indent = 2
236236+237237+pub fn record(name: String, fields: List(gleam.Field)) -> Document {
238238+ let fields =
239239+ list.map(fields, fn(field) {
240240+ let label = gleam.identifier_to_string(field.label)
241241+242242+ [doc.from_string(label <> ": "), pretty_gleam_type(field.type_)]
243243+ |> doc.concat
244244+ |> doc.group
245245+ })
246246+247247+ [
248248+ doc.from_string("pub type " <> name <> " {"),
249249+ [doc.line, call(name, fields)]
250250+ |> doc.concat
251251+ |> doc.nest(by: indent),
252252+ doc.line,
253253+ doc.from_string("}"),
254254+ ]
255255+ |> doc.concat
256256+ |> doc.group
257257+}
258258+259259+fn pretty_gleam_type(type_: gleam.Type) -> Document {
260260+ case type_ {
261261+ gleam.List(type_) -> call("List", [pretty_gleam_type(type_)])
262262+ gleam.Option(type_) -> call("Option", [pretty_gleam_type(type_)])
263263+ gleam.Int -> doc.from_string("Int")
264264+ gleam.Float -> doc.from_string("Float")
265265+ gleam.Bool -> doc.from_string("Bool")
266266+ gleam.String -> doc.from_string("String")
267267+ }
268268+}
269269+270270+/// A pretty printed public function definition.
271271+///
272272+pub fn fun(name: String, args: List(String), body: List(Document)) -> Document {
273273+ let args = list.map(args, doc.from_string)
274274+275275+ [
276276+ doc.from_string("pub fn " <> name),
277277+ comma_list("(", args, ") "),
278278+ block([body |> doc.join(with: doc.lines(2))]),
279279+ doc.line,
280280+ ]
281281+ |> doc.concat
282282+ |> doc.group
283283+}
284284+285285+/// A pretty printed let assignment.
286286+///
287287+pub fn var(name: String, body: Document) -> Document {
288288+ [
289289+ doc.from_string("let " <> name <> " ="),
290290+ [doc.space, body]
291291+ |> doc.concat
292292+ |> doc.group
293293+ |> doc.nest(by: indent),
294294+ ]
295295+ |> doc.concat
296296+}
297297+298298+/// A pretty printed Gleam string.
299299+///
300300+/// > ⚠️ This function escapes all `\` and `"` inside the original string to
301301+/// > avoid generating invalid Gleam code.
302302+///
303303+pub fn string(content: String) -> Document {
304304+ let escaped_string =
305305+ content
306306+ |> string.replace(each: "\\", with: "\\\\")
307307+ |> string.replace(each: "\"", with: "\\\"")
308308+ |> doc.from_string
309309+310310+ [doc.from_string("\""), escaped_string, doc.from_string("\"")]
311311+ |> doc.concat
312312+}
313313+314314+/// A pretty printed Gleam list.
315315+///
316316+pub fn list(elems: List(Document)) -> Document {
317317+ comma_list("[", elems, "]")
318318+}
319319+320320+/// A pretty printed decoder that decodes an n-item dynamic tuple using the
321321+/// `decode` package.
322322+///
323323+pub fn decoder(constructor: String, returns: List(gleam.Field)) -> Document {
324324+ let parameters =
325325+ list.map(returns, fn(field) {
326326+ let label = gleam.identifier_to_string(field.label)
327327+ doc.from_string("use " <> label <> " <- decode.parameter")
328328+ })
329329+330330+ let pipes =
331331+ list.index_map(returns, fn(field, i) {
332332+ let position = int.to_string(i) |> doc.from_string
333333+ let decoder = gleam_type_to_decoder(field.type_) |> doc.from_string
334334+ call("|> decode.field", [position, decoder])
335335+ })
336336+337337+ let labelled_names =
338338+ list.map(returns, fn(field) {
339339+ let label = gleam.identifier_to_string(field.label)
340340+ doc.from_string(label <> ": " <> label)
341341+ })
342342+343343+ [
344344+ call_block("decode.into", [
345345+ doc.join(parameters, with: doc.line),
346346+ doc.line,
347347+ call(constructor, labelled_names),
348348+ ]),
349349+ doc.line,
350350+ doc.join(pipes, with: doc.line),
351351+ ]
352352+ |> doc.concat()
353353+ |> doc.group
354354+}
355355+356356+/// A pretty printed function call where the first argument is piped into
357357+/// the function.
358358+///
359359+pub fn pipe_call(
360360+ function: String,
361361+ first: Document,
362362+ rest: List(Document),
363363+) -> Document {
364364+ [first, doc.line, call("|> " <> function, rest)]
365365+ |> doc.concat
366366+}
367367+368368+/// A pretty printed function call.
369369+///
370370+fn call(function: String, args: List(Document)) -> Document {
371371+ [doc.from_string(function), comma_list("(", args, ")")]
372372+ |> doc.concat
373373+ |> doc.group
374374+}
375375+376376+/// A pretty printed function call where the only argument is a single block.
377377+///
378378+fn call_block(function: String, body: List(Document)) -> Document {
379379+ [doc.from_string(function <> "("), block(body), doc.from_string(")")]
380380+ |> doc.concat
381381+ |> doc.group
382382+}
383383+384384+/// A pretty printed Gleam block.
385385+///
386386+fn block(body: List(Document)) -> Document {
387387+ [
388388+ doc.from_string("{"),
389389+ [doc.line, ..body]
390390+ |> doc.concat
391391+ |> doc.nest(by: indent),
392392+ doc.line,
393393+ doc.from_string("}"),
394394+ ]
395395+ |> doc.concat
396396+ |> doc.force_break
397397+}
398398+399399+/// A comma separated list of items with some given open and closed delimiters.
400400+///
401401+fn comma_list(open: String, content: List(Document), close: String) -> Document {
402402+ [
403403+ doc.from_string(open),
404404+ [
405405+ // We want the first break to be nested
406406+ // in case the group is broken.
407407+ doc.soft_break,
408408+ doc.join(content, doc.break(", ", ",")),
409409+ ]
410410+ |> doc.concat
411411+ |> doc.group
412412+ |> doc.nest(by: indent),
413413+ doc.break("", ","),
414414+ doc.from_string(close),
415415+ ]
416416+ |> doc.concat
417417+ |> doc.group
418418+}
+216
test/squirrel_test.gleam
···11+import birdie
22+import filepath
33+import gleam/dynamic
44+import gleam/list
55+import gleam/pgo
66+import gleam/string
77+import gleeunit
88+import simplifile
99+import squirrel/internal/database/postgres
1010+import squirrel/internal/error.{type Error}
1111+import squirrel/internal/query.{type TypedQuery}
1212+import temporary
1313+1414+pub fn main() {
1515+ setup_database()
1616+ gleeunit.main()
1717+}
1818+1919+// --- TEST SETUP --------------------------------------------------------------
2020+2121+const host = "localhost"
2222+2323+const user = "squirrel_test"
2424+2525+const database = "squirrel_test"
2626+2727+const port = 5432
2828+2929+fn setup_database() {
3030+ let config =
3131+ pgo.Config(
3232+ ..pgo.default_config(),
3333+ port: port,
3434+ user: user,
3535+ host: host,
3636+ database: database,
3737+ )
3838+ let db = pgo.connect(config)
3939+4040+ let assert Ok(_) =
4141+ "
4242+create table if not exists squirrel(
4343+ name text primary key,
4444+ acorns int
4545+)
4646+"
4747+ |> pgo.execute(db, [], dynamic.dynamic)
4848+4949+ pgo.disconnect(db)
5050+}
5151+5252+// --- ASSERTION HELPERS -------------------------------------------------------
5353+5454+fn should_codegen(query: String) -> String {
5555+ // We assert everything went smoothly and we have no errors in the query.
5656+ let assert Ok(#(queries, [])) = codegen_queries([#("query", query)])
5757+ list.map(queries, query.generate_code("v-test", _))
5858+ |> string.join(with: "\n\n")
5959+}
6060+6161+fn codegen_queries(
6262+ queries: List(#(String, String)),
6363+) -> Result(#(List(TypedQuery), List(Error)), Error) {
6464+ // If there's any error with the temporary package we just fail the test,
6565+ // there's no reason to try and keep going.
6666+ let assert Ok(result) = {
6767+ use temp_dir <- temporary.create(temporary.directory())
6868+6969+ // We parse all the queries.
7070+ let queries = {
7171+ use #(file, query) <- list.map(queries)
7272+ let out_file = filepath.join(temp_dir, file <> ".sql")
7373+ let assert Ok(_) = simplifile.write(to: out_file, contents: query)
7474+ let assert Ok(query) = query.from_file(out_file)
7575+ // We manually change the file name here: we do not want to use the full
7676+ // out_file name in tests because that will change across different runs,
7777+ // causing the snapshot tests to fail.
7878+ let query = query.UntypedQuery(..query, file: file <> ".sql")
7979+ query
8080+ }
8181+8282+ // We can then ask squirrel to type check all the queries.
8383+ postgres.main(
8484+ queries,
8585+ postgres.ConnectionOptions(
8686+ host: host,
8787+ port: port,
8888+ user: user,
8989+ database: database,
9090+ password: "",
9191+ timeout: 1000,
9292+ ),
9393+ )
9494+ }
9595+9696+ result
9797+}
9898+9999+// --- ENCODING/DECODING CODEGEN TESTS -----------------------------------------
100100+// This is a group of tests to ensure the generated encoders/decoders are what
101101+// we expect for all the supported data types.
102102+//
103103+104104+pub fn int_decoding_test() {
105105+ "select 11 as res"
106106+ |> should_codegen
107107+ |> birdie.snap(title: "int decoding")
108108+}
109109+110110+pub fn int_encoding_test() {
111111+ "select true as res where $1 = 11"
112112+ |> should_codegen
113113+ |> birdie.snap(title: "int encoding")
114114+}
115115+116116+pub fn float_decoding_test() {
117117+ "select 1.1 as res"
118118+ |> should_codegen
119119+ |> birdie.snap(title: "float decoding")
120120+}
121121+122122+pub fn float_encoding_test() {
123123+ "select true as res where $1 = 1.1"
124124+ |> should_codegen
125125+ |> birdie.snap(title: "float encoding")
126126+}
127127+128128+pub fn string_decoding_test() {
129129+ "select 'wibble' as res"
130130+ |> should_codegen
131131+ |> birdie.snap(title: "string decoding")
132132+}
133133+134134+pub fn string_encoding_test() {
135135+ "select true as res where $1 = 'wibble'"
136136+ |> should_codegen
137137+ |> birdie.snap(title: "string encoding")
138138+}
139139+140140+pub fn bool_decoding_test() {
141141+ "select true as res"
142142+ |> should_codegen
143143+ |> birdie.snap(title: "bool decoding")
144144+}
145145+146146+pub fn bool_encoding_test() {
147147+ "select true as res where $1 = true"
148148+ |> should_codegen
149149+ |> birdie.snap(title: "bool encoding")
150150+}
151151+152152+pub fn array_decoding_test() {
153153+ "select array[1, 2, 3] as res"
154154+ |> should_codegen
155155+ |> birdie.snap(title: "array decoding")
156156+}
157157+158158+pub fn array_encoding_test() {
159159+ "select true as res where $1 = array[1, 2, 3]"
160160+ |> should_codegen
161161+ |> birdie.snap(title: "array encoding")
162162+}
163163+164164+pub fn optional_decoding_test() {
165165+ "select acorns from squirrel"
166166+ |> should_codegen
167167+ |> birdie.snap(title: "optional decoding")
168168+}
169169+170170+// --- CODEGEN STRUCTURE TESTS -------------------------------------------------
171171+// This is a group of tests to ensure the generated code has some specific
172172+// structure (e.g. the names and comments are what we expect...)
173173+//
174174+175175+pub fn query_with_comment_test() {
176176+ "
177177+-- This is a comment
178178+select true as res
179179+"
180180+ |> should_codegen
181181+ |> birdie.snap(title: "query with comment")
182182+}
183183+184184+pub fn query_with_multiline_comment_test() {
185185+ "
186186+-- This is a comment
187187+-- that goes over multiple lines!
188188+select true as res
189189+"
190190+ |> should_codegen
191191+ |> birdie.snap(title: "query with multiline comment")
192192+}
193193+194194+pub fn generated_type_has_the_same_name_as_the_function_but_in_pascal_case_test() {
195195+ "
196196+select true as res
197197+"
198198+ |> should_codegen
199199+ |> birdie.snap(
200200+ title: "generated type has the same name as the function but in pascal case",
201201+ )
202202+}
203203+204204+pub fn generated_type_fields_are_labelled_with_their_name_in_the_select_list_test() {
205205+ "
206206+select
207207+ acorns,
208208+ name as squirrel_name
209209+from
210210+ squirrel
211211+"
212212+ |> should_codegen
213213+ |> birdie.snap(
214214+ title: "generated type fields are labelled with their name in the select list",
215215+ )
216216+}