├── .github
└── workflows
│ ├── build.yml
│ └── docs.yml
├── .gitignore
├── README.md
├── debby.nimble
├── examples
├── admin.css
├── admin.nim
├── admin.nims
├── microblog.nim
└── microblog.nims
├── src
├── debby.nim
└── debby
│ ├── common.nim
│ ├── mysql.nim
│ ├── pools.nim
│ ├── postgres.nim
│ └── sqlite.nim
└── tests
├── common_test.nim
├── config.nims
├── test_db_name.nim
├── test_mysql.nim
├── test_mysql_pools.nim
├── test_mysql_pools.nims
├── test_postgres.nim
├── test_postgres_pools.nim
├── test_postgres_pools.nims
├── test_sqlite.nim
├── test_sqlite_pools.nim
├── test_sqlite_pools.nims
└── varargs.nim
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Github Actions
2 | on: [push, pull_request]
3 | jobs:
4 | build:
5 | strategy:
6 | fail-fast: false
7 | matrix:
8 | os: [ubuntu-latest]
9 |
10 | runs-on: ${{ matrix.os }}
11 |
12 | services:
13 | postgres:
14 | image: postgres:13
15 | env:
16 | POSTGRES_USER: postgres
17 | POSTGRES_PASSWORD: postgres
18 | options: >-
19 | --health-cmd pg_isready
20 | --health-interval 10s
21 | --health-timeout 5s
22 | --health-retries 5
23 | ports:
24 | - 5432:5432
25 |
26 | mysql:
27 | image: mysql:8.0
28 | env:
29 | MYSQL_ROOT_PASSWORD: hunter2
30 | MYSQL_DATABASE: test_db
31 | options: >-
32 | --health-cmd="mysqladmin ping"
33 | --health-interval=10s
34 | --health-timeout=5s
35 | --health-retries=3
36 | ports:
37 | - 3306:3306
38 |
39 | steps:
40 | - uses: actions/checkout@v3
41 |
42 | - name: Install PostgreSQL client
43 | run: sudo apt-get -yqq install libpq-dev
44 |
45 | - name: Set up PostgreSQL database
46 | run: |
47 | pg_isready -h localhost -p 5432 -U postgres
48 | psql -h localhost -U postgres -c 'CREATE USER testuser WITH PASSWORD '\''test'\'';'
49 | psql -h localhost -U postgres -c 'CREATE DATABASE test OWNER testuser;'
50 | env:
51 | PGPASSWORD: postgres
52 |
53 | - name: Install MySQL client
54 | run: sudo apt-get -yqq install default-mysql-client
55 |
56 | - name: Verify MySQL Connection
57 | run: |
58 | mysql --host 127.0.0.1 --port 3306 -uroot -phunter2 -e "SHOW DATABASES;"
59 |
60 | - uses: jiro4989/setup-nim-action@v1
61 | with:
62 | repo-token: ${{ secrets.GITHUB_TOKEN }}
63 |
64 | - name: Run nimble tests
65 | run: |
66 | nimble test -y
67 | nimble test --gc:orc -y
68 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: docs
2 | on:
3 | push:
4 | branches:
5 | - master
6 | env:
7 | nim-version: 'stable'
8 | nim-src: src/${{ github.event.repository.name }}.nim
9 | deploy-dir: .gh-pages
10 | jobs:
11 | docs:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v3
15 | - uses: jiro4989/setup-nim-action@v1
16 | with:
17 | nim-version: ${{ env.nim-version }}
18 | - run: nimble install -Y
19 | - run: nimble doc --index:on --project --git.url:https://github.com/${{ github.repository }} --git.commit:master --out:${{ env.deploy-dir }} ${{ env.nim-src }}
20 | - name: "Copy to index.html"
21 | run: cp ${{ env.deploy-dir }}/${{ github.event.repository.name }}.html ${{ env.deploy-dir }}/index.html
22 | - name: Deploy documents
23 | uses: peaceiris/actions-gh-pages@v3
24 | with:
25 | github_token: ${{ secrets.GITHUB_TOKEN }}
26 | publish_dir: ${{ env.deploy-dir }}
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # ignore files with no extention:
2 | *
3 | !*/
4 | !*.*
5 |
6 | # normal ignores:
7 | *.exe
8 | nimcache
9 | *.pdb
10 | *.ilk
11 | *.out
12 | .*
13 |
14 | *.db
15 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Debby: An Opinionated ORM for Nim
2 |
3 | `nimble install debby`
4 |
5 | 
6 |
7 | [API reference](https://treeform.github.io/debby)
8 |
9 | This library depends on:
10 | * jsony
11 |
12 | > Note: Debby is still in its early stages. We appreciate your feedback and contributions!
13 |
14 | Debby is a powerful, intuitive, and opinionated Object-Relational Mapping (ORM) library designed specifically for Nim. Built with simplicity and efficiency in mind, Debby allows you to interact with your databases.
15 |
16 | With Debby, you can define models as plain Nim objects, perform CRUD operations, handle migrations, and even create complex queries with a type-safe, Nim-like filter syntax.
17 |
18 | - **Powerful ORM**: Create, Read, Update, and Delete operations are simple and intuitive.
19 | - **Nim-like filter syntax**: Write SQL filters as you would write Nim code!
20 | - **Nim-centric Model Definition**: Define your database schema using the familiar syntax and type system of Nim.
21 | - **JSON fields**: Automatically converts complex fields to JSON and back.
22 | - **Custom queries and object mapping**: Supports custom SQL queries and maps them to plain Nim objects.
23 | - **Database Migrations**: Detects schema changes and aids in the generation of migration scripts based on your Nim models.
24 |
25 | Whether you're building a small project or a large-scale application, Debby aims to make your experience with databases in Nim as efficient and enjoyable as possible.
26 |
27 | **Supported Databases**: SQLite, PostgreSQL, MySql.
28 |
29 | ## Quick Start
30 |
31 | ```nim
32 | let db = openDatabase("auto.db")
33 |
34 | type Auto = ref object
35 | id: int
36 | make: string
37 | model: string
38 | year: int
39 |
40 | db.createTable(Auto)
41 |
42 | var auto = Auto(
43 | make: "Chevrolet",
44 | model: "Camaro Z28",
45 | year: 1970
46 | )
47 | db.insert(auto) # Create
48 | auto = db.get(Auto, auto.id) # Read
49 | auto.year = 1971
50 | db.update(auto) # Update
51 | db.delete(auto) # Delete
52 | ```
53 |
54 | # Table Creation and Indexes
55 |
56 | Define your database models as plain Nim objects:
57 | ```nim
58 | type Auto = ref object
59 | id: int ## Special primary-key field, required!!!
60 | make: string
61 | model: string
62 | year: int
63 | ```
64 |
65 | The only required fields is the `.id` field. It must always be `int` and can't be changed. Debby uses the `.id` field for most operations.
66 |
67 | Debby makes it easy to create indices on your tables to speed up queries:
68 |
69 | ```nim
70 | db.createIndex(Auto, "make")
71 | db.createIndex(Auto, "model", "model")
72 | db.createIndex(Auto, "model", "model", "year")
73 | ```
74 |
75 | Remember to add an index when you are going to be querying based on that field or set of fields often.
76 |
77 | # The CRUD part:
78 |
79 | Lets insert autos into the database. You can insert one items at a time:
80 |
81 | ```nim
82 | let auto = Auto(
83 | make: "Chevrolet",
84 | model: "Camaro Z28",
85 | year: 1970
86 | )
87 | db.insert(auto)
88 | ```
89 |
90 | When inserting debby updates the `.id` of the object just inserted.
91 |
92 | ```nim
93 | echo auto.id
94 | ```
95 |
96 | You can also insert whole seq of objects at at time:
97 |
98 | ```nim
99 | db.insert(@[
100 | Auto(make: "Chevrolet", model: "Camaro Z28", year: 1970),
101 | Auto(make: "Porsche", model: "911 Carrera RS", year: 1973),
102 | Auto(make: "Lamborghini", model: "Countach", year: 1974),
103 | ])
104 | ```
105 |
106 | Once you know the `.id` of the object you can read the data back using `get()`:
107 |
108 | ```nim
109 | var car = db.get(Auto, id: 1)
110 | ```
111 |
112 | You can get multiple objects using `filter()` using a type-safe query builder:
113 |
114 | ```nim
115 | let cars = db.filter(Auto, it.year > 1990)
116 | ```
117 |
118 | With `filter()` you can perform complex queries even with logical operators:
119 |
120 | ```nim
121 | let cars = db.filter(Auto, it.make == "Ferrari" or it.make == "Lamborghini")
122 | doAssert cars.len == 3
123 | ```
124 |
125 | To save changes you've made to your objects back to the database, just call `db.update` with the objects:
126 |
127 | ```nim
128 | db.update(car)
129 | ```
130 |
131 | Just make sure that the `.id` fields is set. Debby uses this special field for all operations.
132 |
133 | Just like you can `insert()` multiple objects in a seq, you can `update()` them too:
134 |
135 | ```nim
136 | db.update(@[car1, car2, car])
137 | ```
138 |
139 | Some times you are not sure if you need to update or create an row. For that you can use `upsert()` and it will update or insert:
140 |
141 | ```nim
142 | db.upsert(car)
143 | db.upsert(@[car1, car2, car])
144 | ```
145 |
146 | Again the `.id` field is crucial, if its `0` debby will `insert()`, otherwise it will `update()`.
147 |
148 | ## Transactions
149 |
150 | You can use `withTransaction()` block to make sure to update or insert everything at once:
151 |
152 | ```nim
153 | db.withTransaction:
154 | let p1 = Payer(name: "Bar")
155 | db.insert(p1)
156 | db.insert(Card(payerId: p1.id, number: "1234.1234"))
157 | ```
158 |
159 | If an exception happens during a transaction it will be rolled back.
160 |
161 | ## Custom SQL queries
162 |
163 | Debby also supports custom SQL queries with parameters. Use the `db.query()` function to perform queries:
164 |
165 | ```nim
166 | db.query("select 5")
167 | ```
168 |
169 | Don't splice arguments into the SQL queries as it can cause SQL injection attacks. Rather use the `?` substitution placeholder.
170 |
171 | ```nim
172 | db.query("select ?", 5)
173 | ```
174 |
175 | By default `db.query` returns simple `seq[seq[string]]` which corresponds to rows and columns. The results can be ignored when you don't expect any results.
176 |
177 | ## Mapping SQL queries to objects.
178 |
179 | A cool power of debby comes from mapping custom SQL queries to any `ref object`. Just pass the object type you want to map as first argument to `query()`.
180 |
181 | ```nim
182 | type SteamPlayer = ref object
183 | id: int
184 | steamId: uint64
185 | rank: float32
186 | name: string
187 |
188 | let players = db.query(SteamPlayer, "SELECT * FROM steam_player WHERE name = ?", "foo")
189 | ```
190 |
191 | For big heavy objects, you can select a subset of fields and map them to a different smaller `ref objects`.
192 |
193 | ```nim
194 | type RankName = ref object
195 | rank: float32
196 | name: string
197 | let players = db.query(RankName, "SELECT name, rank FROM steam_player WHERE name = ?", "foo")
198 | ```
199 |
200 | This can also be used for custom rows computed entirely on the fly:
201 |
202 | ```nim
203 | type CountYear = ref object
204 | count: int
205 | year: int
206 | let rows = db.query(CountYear, "SELECT count(*) as count, year FROM auto GROUP BY year")
207 | ```
208 |
209 | ## JSON Fields
210 |
211 | Debby can map almost any plain Nim object to SQL and back. If the object field is a complex type. It will turn into JSON field serialized using [jsony](https://github.com/treeform/jsony).
212 |
213 | ```nim
214 | type Location = ref object
215 | id: int
216 | name: string
217 | revenue: Money
218 | position: Vec2
219 | items: seq[string]
220 | rating: float32
221 | ```
222 |
223 | | id | name | revenue | position | items | rating |
224 | | -- | ---- | ------- | -------- | ----- | ------ |
225 | | 1 | Super Cars | 1234 | {"x":123.0,"y":456.0} | ["wrench","door","bathroom"] | 1.5
226 |
227 | This means you can create and use many Nim objects as is, and save and load them from the database with minimal changes.
228 |
229 | Many DBs have JSON functions to operate on JSON stores in the rows this way.
230 |
231 | ## sqlParseHook / sqlDumpHook
232 |
233 | If JSON encoding with jsony is not enough, you can define custom `sqlParseHook()` and `sqlDumpHook()` for your field types.
234 |
235 | ```nim
236 | type Money = distinct int64
237 |
238 | proc sqlDumpHook(v: Money): string =
239 | result = "$" & $v.int64 & "USD"
240 |
241 | proc sqlParseHook(data: string, v: var Money) =
242 | v = data[1..^4].parseInt().Money
243 | ```
244 |
245 | It will store money as string:
246 |
247 | ```$1234USD```
248 |
249 | ## Check table.
250 |
251 | As you initialize your data base you should run `checkTable()` on all of your tables.
252 |
253 | ```nim
254 | type CheckEntry = ref object
255 | id: int
256 | toField: string
257 | money: Money
258 |
259 | db.checkTable(CheckEntry)
260 | ```
261 |
262 | Check table wil cause an exception if your schema defined with Nim does not match the schema defined in SQL. It will even suggest the SQL command to run to bring your schema up to date.
263 |
264 | ```
265 | Field cars.msrp is missing
266 | Add it with:
267 | ALTER TABLE cars ADD msrp REAL;
268 | Or compile --d:debbyYOLO to do this automatically
269 | ```
270 |
271 | Yes using `--d:debbyYOLO` can do this automatically, but it might **not** be what you want! Always be vigilant.
272 |
273 | ## Pools
274 |
275 | If you are going to use Debby in threaded servers like [Mummy](https://github.com/guzba/mummy), it's best to use pools.
276 |
277 | Using `--mm:arc` or `--mm:orc` as well as `--threads:on` is required with Debby pools (and Mummy). First, import Debby pools and create a pool.
278 |
279 | ```nim
280 | import debby/pools
281 |
282 | let pool = newPool()
283 | # You need to add as many connections as needed.
284 | # Many databases only allow a limited number of connections.
285 | for i in 0 ..< 10:
286 | pool.add openDatabase(
287 | host = "localhost",
288 | user = "testuser",
289 | password = "test",
290 | database = "test"
291 | )
292 | ```
293 |
294 | Then you can use the pool as if it was a database object in one-shot mode:
295 |
296 | ```
297 | pool.get(...)
298 | pool.filter(...)
299 | pool.insert(...)
300 | pool.update(...)
301 | pool.upsert(...)
302 | pool.delete(...)
303 | ```
304 |
305 | However, it is more efficient to borrow a database object from the pool and use the same object to make many queries:
306 |
307 | ```
308 | pool.withDb:
309 | db.get(...)
310 | db.filter(...)
311 | db.insert(...)
312 | db.update(...)
313 | db.upsert(...)
314 | db.delete(...)
315 | ```
316 |
--------------------------------------------------------------------------------
/debby.nimble:
--------------------------------------------------------------------------------
1 | version = "0.6.1"
2 | author = "Andre von Houck"
3 | description = "An Opinionated ORM"
4 | license = "MIT"
5 |
6 | srcDir = "src"
7 |
8 | requires "nim >= 1.6.0"
9 | requires "jsony >= 1.1.5"
10 |
--------------------------------------------------------------------------------
/examples/admin.css:
--------------------------------------------------------------------------------
1 | /************************************************************/
2 | /* Almond.CSS */
3 | /************************************************************/
4 | /** Author: Alvaro Montoro **/
5 | /** Project: https://github.com/alvaromontoro/almond.css **/
6 | /************************************************************/
7 | :root {
8 | /* Colors */
9 | --primaryH: 210;
10 | --primaryS: 50%;
11 | --primaryL: 40%;
12 | --primary-bg: #fff;
13 | --secondaryH: 0;
14 | --secondaryS: 0%;
15 | --secondaryL: 13%;
16 | --secondary-bg: #fff;
17 | /* Font */
18 | --font-family: Helvetica, Arial, sans-serif;
19 | --font-size-root: 16px;
20 | --font-weight-bolder: 700;
21 | --font-weight-bold: 400;
22 | --font-weight-normal: 200;
23 | --font-weight-thin: 100;
24 | --line-height: 1.75rem;
25 | --heading-margin: 1.5rem 0 1rem;
26 | /* Inputs */
27 | --border-radius: 2px;
28 | /* Status */
29 | --error: #d00;
30 | --warning: #ec0;
31 | --info: #369;
32 | --correct: #080;
33 | /* Calculated colors */
34 | --primary: hsl(var(--primaryH), var(--primaryS), var(--primaryL));
35 | --primary-bright: hsl(var(--primaryH), calc(var(--primaryS) * 1.25), 90%);
36 | --primary-transparent: hsla(var(--primaryH), var(--primaryS), var(--primaryL), 0.05);
37 | --primary-dark: hsl(var(--primaryH), var(--primaryS), calc(var(--primaryL) * 0.5));
38 | --primary-darker: hsl(var(--primaryH), var(--primaryS), calc(var(--primaryL) * 0.2));
39 | --primary-light: hsl(var(--primaryH), var(--primaryS), 75%);
40 | --primary-lighter: hsl(var(--primaryH), var(--primaryS), 96%);
41 | --secondary: hsl(var(--secondaryH), var(--secondaryS), var(--secondaryL));
42 | --secondary-transparent: hsl(var(--secondaryH), var(--secondaryS), var(--secondaryL), 0.05);
43 | --secondary-dark: hsl(var(--secondaryH), var(--secondaryS), calc(var(--secondaryL) * 0.5));
44 | --secondary-darker: hsl(var(--secondaryH), var(--secondaryS), calc(var(--secondaryL) * 0.2));
45 | --secondary-light: hsl(var(--secondaryH), var(--secondaryS), 75%);
46 | --secondary-lighter: hsl(var(--secondaryH), var(--secondaryS), 96%); }
47 |
48 | html {
49 | -ms-text-size-adjust: 100%;
50 | -webkit-text-size-adjust: 100%;
51 | background-color: var(--secondary-bg);
52 | color: var(--secondary);
53 | font-family: var(--font-family);
54 | font-size: var(--font-size-root);
55 | font-weight: var(--font-weight-normal);
56 | margin: 0;
57 | padding: 0; }
58 |
59 | body {
60 | font-size: 1rem;
61 | margin: auto auto;
62 | padding: 1rem; }
63 |
64 | @media all and (min-width: 1024px) {
65 | body {
66 | max-width: 920px; } }
67 |
68 | @media all and (min-width: 1200px) {
69 | body {
70 | max-width: 1080px; } }
71 |
72 | :focus {
73 | outline: 1px dashed var(--primary);
74 | outline-offset: 2px; }
75 |
76 | [hidden],
77 | template {
78 | display: none; }
79 |
80 | h1,
81 | h2,
82 | h3,
83 | h4,
84 | h5,
85 | h6 {
86 | color: var(--primary);
87 | font-weight: var(--font-weight-normal);
88 | margin: var(--heading-margin); }
89 |
90 | h1 {
91 | font-size: 2.5rem; }
92 |
93 | h2 {
94 | font-size: 2rem; }
95 |
96 | h3 {
97 | font-size: 1.66rem; }
98 |
99 | h4 {
100 | font-size: 1.45rem; }
101 |
102 | h5 {
103 | font-size: 1.25rem;
104 | font-weight: var(--font-weight-bold); }
105 |
106 | h6 {
107 | font-size: 1.125rem;
108 | font-weight: var(--font-weight-bold); }
109 |
110 | ul,
111 | ol {
112 | margin: 1rem 0;
113 | padding-left: 2rem; }
114 | ul ul,
115 | ul ol,
116 | ol ul,
117 | ol ol {
118 | margin: 0; }
119 |
120 | li {
121 | font-size: 1rem;
122 | line-height: var(--line-height);
123 | max-width: 80ch;
124 | max-width: calc(80ch - 3rem); }
125 |
126 | table {
127 | background-color: var(--secondary-bg);
128 | border: 0;
129 | border-collapse: collapse;
130 | border-spacing: 0;
131 | width: 100%; }
132 |
133 | thead {
134 | border-bottom: 2px solid var(--secondary); }
135 |
136 | tfoot {
137 | border-top: 2px solid var(--secondary); }
138 |
139 | tbody tr:nth-child(even) {
140 | background-color: var(--secondary-lighter); }
141 |
142 | tbody tr:hover {
143 | background-color: var(--primary-lighter); }
144 |
145 | td,
146 | th {
147 | padding: 1rem 0.5rem;
148 | vertical-align: top; }
149 |
150 | th {
151 | font-weight: var(--font-weight-bolder);
152 | text-align: left; }
153 |
154 | input:not([type="file"]),
155 | optgroup,
156 | option,
157 | textarea,
158 | select {
159 | border: 1px solid var(--secondary-light);
160 | border-radius: var(--border-radius);
161 | box-sizing: border-box;
162 | color: inherit;
163 | font: inherit;
164 | font-size: 1rem;
165 | height: 2.5rem;
166 | line-height: normal;
167 | margin: 0;
168 | padding: 0 0.5rem;
169 | max-width: 100%;
170 | min-width: 15rem;
171 | text-transform: none;
172 | vertical-align: middle;
173 | width: 15rem; }
174 | input:not([type="file"]):invalid,
175 | optgroup:invalid,
176 | option:invalid,
177 | textarea:invalid,
178 | select:invalid {
179 | border-color: var(--error); }
180 | input:not([type="file"]):invalid:hover,
181 | optgroup:invalid:hover,
182 | option:invalid:hover,
183 | textarea:invalid:hover,
184 | select:invalid:hover {
185 | border: 1px solid #aa0000; }
186 | input:not([type="file"])[disabled], input:not([type="file"]):disabled,
187 | optgroup[disabled],
188 | optgroup:disabled,
189 | option[disabled],
190 | option:disabled,
191 | textarea[disabled],
192 | textarea:disabled,
193 | select[disabled],
194 | select:disabled {
195 | background: var(--secondary-lighter);
196 | color: var(--secondary-light); }
197 | input:not([type="file"])::-webkit-calendar-picker-indicator,
198 | optgroup::-webkit-calendar-picker-indicator,
199 | option::-webkit-calendar-picker-indicator,
200 | textarea::-webkit-calendar-picker-indicator,
201 | select::-webkit-calendar-picker-indicator {
202 | display: none;
203 | background: none; }
204 |
205 | input[type="button"],
206 | input[type="image"],
207 | input[type="reset"],
208 | input[type="submit"],
209 | input[size] {
210 | min-width: auto;
211 | width: auto; }
212 |
213 | input:not([type="file"]):not([type="image"]):not(:invalid):not(:disabled):not([disabled]):not([readonly]):hover,
214 | textarea:not(:invalid):not(:disabled):not([disabled]):not([readonly]):hover,
215 | select:not(:invalid):not(:disabled):not([disabled]):not([readonly]):hover {
216 | border: 1px solid var(--secondary); }
217 |
218 | input[type="color"] {
219 | padding: 0.125rem; }
220 |
221 | input[type="range"] {
222 | padding: 0; }
223 |
224 | textarea {
225 | height: 5rem;
226 | line-height: 1.5rem;
227 | min-height: 3rem;
228 | overflow: auto; }
229 |
230 | input[type="number"]::-webkit-inner-spin-button,
231 | input[type="number"]::-webkit-outer-spin-button {
232 | height: auto; }
233 |
234 | input[type="search"] {
235 | -webkit-appearance: textfield;
236 | appearance: textfield; }
237 |
238 | input[type="search"]::-webkit-search-cancel-button,
239 | input[type="search"]::-webkit-search-decoration {
240 | -webkit-appearance: none;
241 | appearance: none; }
242 |
243 | input[type="radio"],
244 | input[type="checkbox"],
245 | input[type="image"],
246 | input[type="file"] {
247 | border: 0;
248 | box-sizing: border-box;
249 | height: auto;
250 | max-width: initial;
251 | min-width: auto;
252 | padding: 0; }
253 |
254 | button,
255 | input[type="button"],
256 | input[type="reset"],
257 | input[type="submit"] {
258 | -webkit-appearance: button;
259 | appearance: button;
260 | background-color: var(--primary);
261 | border: 1px solid var(--primary);
262 | border-radius: var(--border-radius);
263 | box-sizing: border-box;
264 | color: var(--primary-bg);
265 | cursor: pointer;
266 | display: inline-block;
267 | font-size: 0.8rem;
268 | font-weight: var(--font-weight-bold);
269 | margin: 0;
270 | max-width: auto;
271 | min-height: 2.5rem;
272 | min-width: auto;
273 | overflow: visible;
274 | padding: 0 1rem;
275 | text-transform: uppercase; }
276 | button[disabled], button:disabled,
277 | input[type="button"][disabled],
278 | input[type="button"]:disabled,
279 | input[type="reset"][disabled],
280 | input[type="reset"]:disabled,
281 | input[type="submit"][disabled],
282 | input[type="submit"]:disabled {
283 | background: var(--secondary-lighter);
284 | border: 1px solid var(--secondary-lighter);
285 | color: var(--secondary-light); }
286 | button:not(:disabled):not([disabled]):hover,
287 | input[type="button"]:not(:disabled):not([disabled]):hover,
288 | input[type="reset"]:not(:disabled):not([disabled]):hover,
289 | input[type="submit"]:not(:disabled):not([disabled]):hover {
290 | background-color: var(--primary-dark);
291 | border: 1px solid transparent; }
292 |
293 | input[type="reset"] {
294 | background-color: var(--primary-bg);
295 | border: 1px solid var(--primary);
296 | color: var(--primary); }
297 | input[type="reset"]:not(:disabled):not([disabled]):hover {
298 | background-color: var(--primary-lighter); }
299 | input[type="reset"]:disabled, input[type="reset"][disabled] {
300 | background: var(--secondary-lighter);
301 | border: 1px solid var(--secondary-lighter);
302 | color: var(--secondary-light); }
303 |
304 | button[disabled],
305 | html input[disabled] {
306 | cursor: default; }
307 |
308 | button::-moz-focus-inner,
309 | input::-moz-focus-inner {
310 | border: 0;
311 | padding: 0; }
312 |
313 | input[type="radio"],
314 | input[type="checkbox"] {
315 | -moz-appearance: none;
316 | -webkit-appearance: none;
317 | appearance: none;
318 | width: 1rem;
319 | height: 1rem;
320 | border-radius: 50%;
321 | border: 1px solid var(--secondary-light);
322 | box-shadow: inset 0 0 0 0.185rem var(--secondary-bg);
323 | background: var(--secondary-bg);
324 | vertical-align: text-top; }
325 | input[type="radio"][type="checkbox"],
326 | input[type="checkbox"][type="checkbox"] {
327 | border-radius: var(--border-radius); }
328 | input[type="radio"]:checked,
329 | input[type="checkbox"]:checked {
330 | background: var(--primary);
331 | -moz-print-color-adjust: exact;
332 | -ms-print-color-adjust: exact;
333 | -webkit-print-color-adjust: exact;
334 | print-color-adjust: exact; }
335 | input[type="radio"]:disabled,
336 | input[type="checkbox"]:disabled {
337 | box-shadow: inset 0 0 0 0.185rem var(--secondary-lighter);
338 | background: var(--secondary-lighter); }
339 |
340 | select:not([multiple]):not([disabled]) {
341 | -webkit-appearance: none;
342 | appearance: none;
343 | background-image: url("data:image/svg+xml;utf8, ");
344 | background-size: 1em 1em;
345 | background-repeat: no-repeat;
346 | background-position: right 0.5em center;
347 | background-color: Field;
348 | padding-right: 2em; }
349 | select:not([multiple]):not([disabled]):hover, select:not([multiple]):not([disabled]):active {
350 | background-image: url("data:image/svg+xml;utf8, "); }
351 |
352 | select[multiple] {
353 | min-height: 10rem;
354 | padding: 0; }
355 | select[multiple] option:checked {
356 | background: var(--secondary-light) linear-gradient(0deg, var(--secondary-light) 0%, var(--secondary-light) 100%); }
357 | select[multiple]:focus option:checked {
358 | background: var(--primary) linear-gradient(0deg, var(--primary) 0%, var(--primary) 100%);
359 | color: var(--primary-bg); }
360 |
361 | optgroup {
362 | border: 0;
363 | border-radius: 0;
364 | font-weight: var(--font-weight-bolder);
365 | padding: 0.5rem; }
366 |
367 | option {
368 | border: 0;
369 | border-radius: 0;
370 | display: flex;
371 | font-weight: var(--font-weight-normal);
372 | align-items: center;
373 | justify-content: flex-start; }
374 | option:hover {
375 | border: 0;
376 | background: var(--primary-lighter); }
377 |
378 | a,
379 | a:link,
380 | a:visited,
381 | a:active,
382 | a:focus {
383 | color: var(--primary);
384 | font-weight: var(--font-weight-bold);
385 | text-decoration: underline; }
386 |
387 | a:hover {
388 | text-decoration: none;
389 | color: var(--primary-dark); }
390 |
391 | abbr[title] {
392 | border-bottom: 0;
393 | text-decoration: underline;
394 | text-decoration-style: dashed;
395 | text-decoration-color: var(--primary); }
396 |
397 | address {
398 | display: block;
399 | font-style: normal;
400 | margin: 1rem 0; }
401 |
402 | audio {
403 | display: inline-block;
404 | vertical-align: baseline;
405 | max-width: 100%; }
406 | audio:not([controls]) {
407 | display: none;
408 | height: 0; }
409 |
410 | b,
411 | strong {
412 | font-weight: var(--font-weight-bolder); }
413 |
414 | blockquote {
415 | background-color: var(--secondary-transparent);
416 | box-sizing: border-box;
417 | display: block;
418 | margin: 1rem 0 1rem 3rem;
419 | max-width: 80ch;
420 | max-width: calc(80ch - 3rem);
421 | overflow: hidden;
422 | padding: 1rem;
423 | page-break-inside: avoid;
424 | position: relative; }
425 | blockquote::before {
426 | content: open-quote;
427 | color: hsla(var(--secondaryH), var(--secondaryS), var(--secondaryL), 0.15);
428 | font-size: 5rem;
429 | font-family: "Times New Roman", "Times", serif;
430 | left: 0.25rem;
431 | line-height: 1;
432 | position: absolute;
433 | top: 0;
434 | z-index: -1; }
435 | blockquote::after {
436 | content: no-close-quote; }
437 | blockquote > :first-child {
438 | margin-top: 0;
439 | text-indent: 1.75rem; }
440 | blockquote > :last-child {
441 | margin-bottom: 0; }
442 |
443 | canvas {
444 | display: inline-block;
445 | vertical-align: baseline;
446 | max-width: 100%; }
447 |
448 | cite {
449 | font-style: italic;
450 | font-weight: var(--font-weight-thin); }
451 |
452 | code {
453 | background: var(--secondary-lighter);
454 | color: var(--secondary);
455 | display: inline-block;
456 | font-family: monospace, monospace;
457 | font-size: 1em;
458 | font-weight: var(--font-weight-bold);
459 | padding: 0 0.25rem; }
460 |
461 | del {
462 | text-decoration: line-through;
463 | text-decoration-color: var(--primary); }
464 |
465 | dialog {
466 | border: 1px solid var(--secondary-light);
467 | border-radius: var(--border-radius);
468 | display: none;
469 | left: 50%;
470 | margin: 0;
471 | max-height: 80vh;
472 | max-width: 80vw;
473 | min-width: 20vw;
474 | padding: 1rem;
475 | position: fixed;
476 | top: 50%;
477 | transform: translate(-50%, -50%);
478 | z-index: 1; }
479 | dialog[open] {
480 | display: flex;
481 | flex-direction: column; }
482 | dialog::before {
483 | content: "";
484 | background: var(--secondary);
485 | height: 100vmax;
486 | left: 50%;
487 | opacity: 0.33;
488 | position: absolute;
489 | top: 50%;
490 | transform: translate(-50%, -50%);
491 | width: 100vmax;
492 | z-index: -1; }
493 | dialog::after {
494 | content: "";
495 | background: var(--primary-bg);
496 | border-radius: var(--border-radius);
497 | height: 100%;
498 | left: 0;
499 | position: absolute;
500 | top: 0;
501 | width: 100%;
502 | z-index: -1; }
503 | dialog > * {
504 | max-height: 80vh;
505 | overflow: auto; }
506 | dialog > h1,
507 | dialog > h2,
508 | dialog > h3,
509 | dialog > h4,
510 | dialog > h5,
511 | dialog > h6 {
512 | border-bottom: 1px solid var(--secondary-light);
513 | border-radius: var(--border-radius) var(--border-radius) 0 0;
514 | color: var(--secondary);
515 | font-size: 1.125rem;
516 | margin: -1rem -1rem 1rem -1rem;
517 | padding: 0.5rem 1rem; }
518 |
519 | details {
520 | border: 1px solid var(--secondary-light);
521 | display: block;
522 | padding: 0 1rem; }
523 | details summary {
524 | margin: 0 -1rem;
525 | padding: 1rem; }
526 | details[open] summary {
527 | border-bottom: 1px solid var(--secondary-light); }
528 | details + details {
529 | border-top: 0;
530 | border-radius: 0; }
531 | details:first-of-type {
532 | border-top-left-radius: var(--border-radius);
533 | border-top-right-radius: var(--border-radius); }
534 | details:last-of-type {
535 | border-bottom-left-radius: var(--border-radius);
536 | border-bottom-right-radius: var(--border-radius); }
537 |
538 | dfn {
539 | font-style: italic;
540 | font-weight: var(--font-weight-bold); }
541 |
542 | dl {
543 | margin: 1rem 0;
544 | font-size: 1rem;
545 | line-height: 1.5rem;
546 | max-width: 80ch; }
547 |
548 | dt {
549 | font-weight: var(--font-weight-bold);
550 | margin-top: 1rem; }
551 |
552 | dd {
553 | margin-left: 1rem;
554 | font-style: italic; }
555 |
556 | fieldset {
557 | border: 1px solid var(--secondary-light);
558 | border-radius: var(--border-radius);
559 | margin: 1rem 0;
560 | padding: 0 1rem 1rem 1rem; }
561 | fieldset > :last-child {
562 | margin-bottom: 0; }
563 |
564 | legend {
565 | background: var(--secondary-lighter);
566 | border: 1px solid var(--secondary-light);
567 | border-radius: var(--border-radius);
568 | padding: 0.25rem 0.5rem; }
569 |
570 | figure {
571 | background: var(--secondary-lighter);
572 | border: 1px solid var(--secondary-light);
573 | border-radius: var(--border-radius);
574 | display: block;
575 | margin: 1rem 0;
576 | padding: 1rem;
577 | text-align: center; }
578 |
579 | figcaption {
580 | font-size: 0.875rem;
581 | font-style: italic; }
582 |
583 | hgroup {
584 | border-left: 5px solid var(--primary);
585 | display: block;
586 | margin: var(--heading-margin);
587 | padding-left: 1rem; }
588 | hgroup h1,
589 | hgroup h2,
590 | hgroup h3,
591 | hgroup h4,
592 | hgroup h5,
593 | hgroup h6 {
594 | margin: 0; }
595 |
596 | hr {
597 | border: 0;
598 | border-top: 1px solid var(--secondary-light);
599 | box-sizing: content-box;
600 | height: 0;
601 | margin: 2rem auto;
602 | max-width: 15rem;
603 | width: 50%; }
604 |
605 | img {
606 | border: 0;
607 | max-width: 100%; }
608 |
609 | ins {
610 | text-decoration: underline;
611 | text-decoration-color: var(--primary); }
612 |
613 | kbd {
614 | background-color: var(--secondary-lighter);
615 | border: 1px solid var(--secondary-light);
616 | border-radius: var(--border-radius);
617 | color: var(--secondary);
618 | font-family: monospace, monospace;
619 | font-size: 0.9rem;
620 | padding: 0.125rem 0.25rem; }
621 |
622 | mark {
623 | background-color: var(--primary-bright);
624 | border-left: 4px solid var(--primary-bright);
625 | border-right: 4px solid var(--primary-bright);
626 | color: var(--secondary-darker); }
627 |
628 | output {
629 | font-weight: var(--font-weight-bold);
630 | unicode-bidi: isolate; }
631 |
632 | p {
633 | font-size: 1rem;
634 | line-height: var(--line-height);
635 | margin: 1rem 0;
636 | max-width: 80ch; }
637 |
638 | pre {
639 | font-family: monospace, monospace;
640 | font-size: 1em;
641 | margin: 1rem 0;
642 | max-width: 100%;
643 | overflow: auto; }
644 | pre > code {
645 | box-sizing: border-box;
646 | display: block;
647 | font-size: 1rem;
648 | line-height: 1.5rem;
649 | min-width: 100%;
650 | padding: 1rem;
651 | width: min-content; }
652 |
653 | progress {
654 | display: inline-block;
655 | max-width: 100%;
656 | min-width: 15rem;
657 | vertical-align: baseline; }
658 |
659 | q {
660 | font-style: italic; }
661 | q::before {
662 | content: open-quote;
663 | font-style: normal; }
664 | q::after {
665 | content: close-quote;
666 | font-style: normal; }
667 |
668 | s,
669 | strike {
670 | text-decoration: line-through;
671 | text-decoration-color: var(--primary); }
672 |
673 | samp {
674 | font-family: monospace, monospace;
675 | font-size: 1em;
676 | font-weight: var(--font-weight-bold); }
677 |
678 | small {
679 | font-size: 0.75em; }
680 |
681 | sub,
682 | sup {
683 | font-size: 0.75em;
684 | line-height: 0;
685 | position: relative;
686 | vertical-align: baseline; }
687 |
688 | sup {
689 | top: -0.5em; }
690 |
691 | sub {
692 | bottom: -0.25em; }
693 |
694 | svg:not(:root) {
695 | border: 0;
696 | max-width: 100%;
697 | overflow: hidden; }
698 |
699 | u {
700 | text-decoration: underline;
701 | text-decoration-style: wavy;
702 | text-decoration-color: var(--error); }
703 |
704 | var {
705 | font-family: monospace, monospace;
706 | font-size: 1em;
707 | font-style: normal;
708 | font-weight: var(--font-weight-bold); }
709 |
710 | video {
711 | display: inline-block;
712 | vertical-align: baseline;
713 | max-width: 100%; }
714 |
715 | a[href^="mailto:"],
716 | a[href^="tel:"],
717 | a[href^="sms:"],
718 | a[href^="file:"],
719 | a[rel~="external"],
720 | a[rel~="bookmark"],
721 | a[download] {
722 | background-repeat: no-repeat;
723 | background-size: 1rem 1rem;
724 | background-position: 0rem 50%;
725 | display: inline-block;
726 | -moz-print-color-adjust: exact;
727 | -ms-print-color-adjust: exact;
728 | -webkit-print-color-adjust: exact;
729 | print-color-adjust: exact;
730 | padding-left: 1.25rem; }
731 |
732 | a[href^="mailto:"] {
733 | background-image: url("data:image/svg+xml;utf8, "); }
734 |
735 | a[href^="tel:"] {
736 | background-image: url("data:image/svg+xml;utf8, "); }
737 |
738 | a[href^="sms:"] {
739 | background-image: url("data:image/svg+xml;utf8, "); }
740 |
741 | a[href^="file:"] {
742 | background-image: url("data:image/svg+xml;utf8, "); }
743 |
744 | a[download] {
745 | background-image: url("data:image/svg+xml;utf8, "); }
746 |
747 | a[rel~="external"] {
748 | background-image: url("data:image/svg+xml;utf8, "); }
749 |
750 | a[rel~="bookmark"] {
751 | background-image: url("data:image/svg+xml;utf8, "); }
752 |
753 | /* input images */
754 | input[type="date"],
755 | input[type="datetime-local"],
756 | input[type="email"],
757 | input[type="month"],
758 | input[type="number"],
759 | input[type="password"],
760 | input[type="search"],
761 | input[type="tel"],
762 | input[type="time"],
763 | input[type="url"],
764 | input[type="week"] {
765 | padding-left: 2.5rem;
766 | background-repeat: no-repeat;
767 | background-size: 1.5rem 1.5rem;
768 | background-position: 0.5rem 50%; }
769 |
770 | input[type="date"],
771 | input[type="datetime-local"],
772 | input[type="month"],
773 | input[type="week"] {
774 | background-image: url("data:image/svg+xml;utf8, "); }
775 |
776 | input[type="email"] {
777 | background-image: url("data:image/svg+xml;utf8, "); }
778 |
779 | input[type="time"] {
780 | background-image: url("data:image/svg+xml;utf8, "); }
781 |
782 | input[type="search"] {
783 | background-image: url("data:image/svg+xml;utf8, "); }
784 |
785 | input[type="password"] {
786 | background-image: url("data:image/svg+xml;utf8, "); }
787 |
788 | input[type="tel"] {
789 | background-image: url("data:image/svg+xml;utf8, "); }
790 |
791 | input[type="url"] {
792 | background-image: url("data:image/svg+xml;utf8, "); }
793 |
794 | input[type="number"] {
795 | background-image: url("data:image/svg+xml;utf8, "); }
796 |
797 | input[type="date"],
798 | input[type="datetime-local"],
799 | input[type="month"],
800 | input[type="week"],
801 | input[type="email"],
802 | input[type="time"],
803 | input[type="search"],
804 | input[type="password"],
805 | input[type="tel"],
806 | input[type="url"],
807 | input[type="number"] {
808 | -moz-print-color-adjust: exact;
809 | -ms-print-color-adjust: exact;
810 | -webkit-print-color-adjust: exact;
811 | print-color-adjust: exact; }
812 |
813 | [role="progressbar"] {
814 | --value: 50;
815 | --thick: 50%;
816 | --medium: 58%;
817 | --thin: 67%;
818 | --thickness: var(--medium);
819 | aspect-ratio: 1;
820 | border-radius: 50%;
821 | display: grid;
822 | font-size: 2em;
823 | overflow: hidden;
824 | place-items: center;
825 | position: relative;
826 | width: 100%; }
827 | [role="progressbar"]::before {
828 | content: "";
829 | background: conic-gradient(var(--primary) calc(var(--value) * 1%), #0000 0);
830 | background-color: var(--primary-lighter);
831 | height: 100%;
832 | left: 0;
833 | -webkit-mask: radial-gradient(#0000 var(--thickness), #000 0);
834 | mask: radial-gradient(#0000 var(--thickness), #000 0);
835 | position: absolute;
836 | -moz-print-color-adjust: exact;
837 | -ms-print-color-adjust: exact;
838 | -webkit-print-color-adjust: exact;
839 | print-color-adjust: exact;
840 | top: 0;
841 | transition: background-color 0.5s;
842 | width: 100%; }
843 | [role="progressbar"]::after {
844 | counter-reset: percentage var(--value);
845 | content: counter(percentage) "%"; }
846 | [role="progressbar"]:hover::before {
847 | background-color: var(--primary-light); }
848 |
--------------------------------------------------------------------------------
/examples/admin.nim:
--------------------------------------------------------------------------------
1 | import debby/sqlite, debby/pools, mummy, mummy/routers, std/strutils,
2 | std/strformat, webby
3 |
4 | let pool = newPool()
5 |
6 | var adminObjectList: seq[string]
7 |
8 | const
9 | HtmlHeader = """
10 |
11 |
12 |
13 |
14 |
15 | Admin
16 |
17 |
18 |
19 | """
20 | HtmlFooter = """
21 |
22 | """
23 |
24 | proc cssHandler(request: Request) =
25 | var headers: HttpHeaders
26 | headers["Content-Type"] = "text/css"
27 | request.respond(200, headers, readFile("examples/admin.css"))
28 |
29 | proc adminHandler(request: Request) =
30 | # Generate the HTML for listing all T.
31 | var x = HtmlHeader
32 | x.add &"Debby Admin "
33 |
34 | x.add ""
35 | x.add "admin "
36 | x.add "
"
37 |
38 | x.add ""
39 |
40 | {.gcsafe.}:
41 | for objName in adminObjectList:
42 | x.add ""
43 | x.add &"" & objName & " "
44 | x.add " "
45 | x.add "
"
46 |
47 | x.add HtmlFooter
48 |
49 | var headers: HttpHeaders
50 | headers["Content-Type"] = "text/html"
51 | request.respond(200, headers, x)
52 |
53 | proc listHandler[T](request: Request) =
54 | # Generate the HTML for listing all T.
55 | var x = HtmlHeader
56 | x.add &"{$T} Listing "
57 |
58 | x.add ""
59 | x.add "admin » "
60 | x.add $T
61 | x.add "
"
62 |
63 | x.add ""
64 | var tmp: T
65 | x.add ""
66 | for fieldName, value in tmp[].fieldPairs:
67 | x.add "" & fieldName & " "
68 | x.add " "
69 |
70 | for row in pool.filter(T):
71 | x.add ""
72 | for fieldName, value in row[].fieldPairs:
73 | if fieldName == "id":
74 | x.add &"" & $value & " "
75 | else:
76 | x.add "" & $value & " "
77 | x.add " "
78 | x.add "
"
79 |
80 | x.add ""
121 |
122 | x.add HtmlFooter
123 |
124 | var headers: HttpHeaders
125 | headers["Content-Type"] = "text/html"
126 | request.respond(200, headers, x)
127 |
128 | proc saveHandler[T](request: Request) =
129 | # Generate the HTML for specific object.
130 | var x = ""
131 | let id = request.uri.rsplit("/", maxSplit = 1)[^1].parseInt()
132 | let obj = pool.get(T, id)
133 | let url = parseUrl(request.body)
134 |
135 | if url.query["action"] == "delete":
136 | pool.delete(obj)
137 | else:
138 | for fieldName, value in obj[].fieldPairs:
139 | if fieldName == "id":
140 | discard
141 | else:
142 | sqlParseHook(url.query[fieldName], value)
143 | pool.update(obj)
144 |
145 | var headers: HttpHeaders
146 | headers["Location"] = "/admin/" & $($T).toLowerAscii()
147 | request.respond(302, headers, x)
148 |
149 | proc newHandler[T](request: Request) =
150 | # Generate the HTML for specific object.
151 | # Generate the HTML for specific object.
152 | var x = HtmlHeader
153 |
154 | let obj = new(T)
155 |
156 | x.add &"{$T} "
157 |
158 | x.add ""
159 | x.add "admin » "
160 | x.add "" & $T & " » "
161 | x.add "new"
162 | x.add "
"
163 |
164 | x.add ""
165 | x.add ""
177 |
178 | x.add "Create "
179 | x.add " "
180 |
181 | x.add HtmlFooter
182 |
183 | var headers: HttpHeaders
184 | headers["Content-Type"] = "text/html"
185 | request.respond(200, headers, x)
186 |
187 | proc createHandler[T](request: Request) =
188 | # Generate the HTML for specific object.
189 | var x = ""
190 | let obj = new(T)
191 | let url = parseUrl(request.body)
192 | for fieldName, value in obj[].fieldPairs:
193 | if fieldName == "id":
194 | discard
195 | else:
196 | sqlParseHook(url.query[fieldName], value)
197 | pool.insert(obj)
198 |
199 | var headers: HttpHeaders
200 | headers["Location"] = "/admin/" & $($T).toLowerAscii()
201 | request.respond(302, headers, x)
202 |
203 | # Set up a mummy router
204 | var router: Router
205 | router.get("/admin.css", cssHandler)
206 | router.get("/admin", adminHandler)
207 |
208 | proc addAdmin(t: typedesc) =
209 |
210 | adminObjectList.add($t)
211 |
212 | router.get("/admin/" & ($t).toLowerAscii(), listHandler[t])
213 |
214 | router.get("/admin/" & ($t).toLowerAscii() & "/new", newHandler[t])
215 | router.post("/admin/" & ($t).toLowerAscii() & "/new", createHandler[t])
216 |
217 | router.get("/admin/" & ($t).toLowerAscii() & "/*", itemHandler[t])
218 | router.post("/admin/" & ($t).toLowerAscii() & "/*", saveHandler[t])
219 |
220 |
221 |
222 |
223 |
224 | for i in 0 ..< 10:
225 | pool.add(openDatabase("examples/admin.db"))
226 |
227 | type Account = ref object
228 | id: int
229 | name: string
230 | bio: string
231 |
232 | type Post = ref object
233 | id: int
234 | accountId: int
235 | authorId: int
236 | title: string
237 | tags: string
238 | postDate: string
239 | body: string
240 | views: int
241 | rating: float32
242 |
243 | type Comment = ref object
244 | id: int
245 | postId: int
246 | authorId: int
247 | postDate: string
248 | body: string
249 | views: int
250 | rating: float32
251 |
252 | # In order to use a pool, call `withDb:`, this will inject a `db` variable so
253 | # that you can query a db. It will return the db back to the pool after.
254 | # This is great if you are going to be making many database operations
255 | pool.withDb:
256 | if not db.tableExists(Account):
257 | # When running this for the first time, it will create the table
258 | # and populate it with dummy data.
259 | db.createTable(Account)
260 | db.insert(Account(
261 | name: "root",
262 | bio: "This is the root account"
263 | ))
264 |
265 | if not db.tableExists(Post):
266 | # When running this for the first time, it will create the table
267 | # and populate it with dummy data.
268 | db.createTable(Post)
269 | db.insert(Post(
270 | title: "First post!",
271 | authorId: 1,
272 | tags: "autogenerated, system",
273 | postDate: "today",
274 | body: "This is how to create a post"
275 | ))
276 | db.insert(Post(
277 | title: "Second post!",
278 | authorId: 1,
279 | tags: "autogenerated, system",
280 | postDate: "yesterday",
281 | body: "This is how to create a second post"
282 | ))
283 |
284 | if not db.tableExists(Comment):
285 | # When running this for the first time, it will create the table
286 | # and populate it with dummy data.
287 | db.createTable(Comment)
288 | db.insert(Comment(
289 | postId: 1,
290 | authorId: 1,
291 | postDate: "today",
292 | body: "This is how to create a comment"
293 | ))
294 |
295 | pool.checkTable(Account)
296 | pool.checkTable(Post)
297 | pool.checkTable(Comment)
298 |
299 | addAdmin(Account)
300 | addAdmin(Post)
301 | addAdmin(Comment)
302 |
303 | # Set up mummy server
304 | let server = newServer(router)
305 | echo "Serving on http://localhost:8080"
306 | server.serve(Port(8080))
307 |
--------------------------------------------------------------------------------
/examples/admin.nims:
--------------------------------------------------------------------------------
1 | --threads:on
2 | --mm:orc
3 |
--------------------------------------------------------------------------------
/examples/microblog.nim:
--------------------------------------------------------------------------------
1 | import debby/sqlite, debby/pools, mummy, mummy/routers, std/strutils,
2 | std/strformat
3 |
4 | # You need to create a data base pool because mummy is multi threaded and
5 | # you can't use a DB connection from multiple threads at once.
6 | let pool = newPool()
7 |
8 | # After creating a pool you should open connections.
9 | # Most DB's only allow about ~10, but this can be configured at DB level.
10 | for i in 0 ..< 10:
11 | pool.add(openDatabase("examples/microblog.db"))
12 |
13 | # Debby users simple Nim objects as table definition.
14 | type Post = ref object
15 | id: int
16 | title: string
17 | author: string
18 | tags: string
19 | postDate: string
20 | body: string
21 |
22 | # In order to use a pool, call `withDb:`, this will inject a `db` variable so
23 | # that you can query a db. It will return the db back to the pool after.
24 | # This is great if you are going to be making many database operations
25 | pool.withDb:
26 | if not db.tableExists(Post):
27 | # When running this for the first time, it will create the table
28 | # and populate it with dummy data.
29 | db.createTable(Post)
30 | db.insert(Post(
31 | title: "First post!",
32 | author: "system",
33 | tags: "autogenerated, system",
34 | postDate: "today",
35 | body: "This is how to create a post"
36 | ))
37 | db.insert(Post(
38 | title: "Second post!",
39 | author: "system",
40 | tags: "autogenerated, system",
41 | postDate: "yesterday",
42 | body: "This is how to create a second post"
43 | ))
44 |
45 | # But you can also use "one-shot" methods on the pool.
46 | # They will borrow a db from the pool and put it back.
47 | pool.checkTable(Post)
48 | # This is not performant if you are making several queries.
49 | # And will not work with transactions.
50 |
51 | proc indexHandler(request: Request) =
52 | # Generate the HTML for index.html page.
53 | var x = ""
54 | x.add "Micro Blog "
55 | x.add ""
56 | for post in pool.filter(Post):
57 | x.add &"{post.title} "
58 | x.add " "
59 |
60 | var headers: HttpHeaders
61 | headers["Content-Type"] = "text/html"
62 | request.respond(200, headers, x)
63 |
64 | proc postHandler(request: Request) =
65 | # Generate the HTML for /posts/123 page.
66 | var x = ""
67 | let id = request.uri.rsplit("/", maxSplit = 1)[^1].parseInt()
68 | let post = pool.get(Post, id)
69 | x.add &"{post.title} "
70 | x.add &"by {post.author} "
71 | x.add post.body
72 |
73 | var headers: HttpHeaders
74 | headers["Content-Type"] = "text/html"
75 | request.respond(200, headers, x)
76 |
77 | # Set up a mummy router
78 | var router: Router
79 | router.get("/", indexHandler)
80 | router.get("/posts/*", postHandler)
81 |
82 | # Set up mummy server
83 | let server = newServer(router)
84 | echo "Serving on http://localhost:8080"
85 | server.serve(Port(8080))
86 |
--------------------------------------------------------------------------------
/examples/microblog.nims:
--------------------------------------------------------------------------------
1 | --threads:on
2 | --mm:orc
3 |
--------------------------------------------------------------------------------
/src/debby.nim:
--------------------------------------------------------------------------------
1 | ##
2 | ## **Debby - Database ORM layer.**
3 | ##
4 | ## Import the database engine you want to use:
5 | ##
6 | ## .. code-block:: nim
7 | ## import debby/sqlite
8 | ## import debby/postgres
9 | ## import debby/mysql
10 | ##
11 | ## If you are going to use debby in mummy you want connection pools:
12 | ##
13 | ## .. code-block:: nim
14 | ## import debby/pools
15 |
16 | when defined(nimdoc):
17 | # Used to generate docs.
18 | import debby/common
19 | import debby/sqlite
20 | import debby/postgres
21 | import debby/mysql
22 | import debby/pools
23 | else:
24 | {.error: "Import debby/sqlite, debby/postgres, or debby/mysql".}
25 |
--------------------------------------------------------------------------------
/src/debby/common.nim:
--------------------------------------------------------------------------------
1 | import jsony, std/typetraits, std/strutils, std/macros, std/sets, std/strformat
2 |
3 | type
4 | Db* = distinct pointer ## Generic database pointer.
5 | DbError* = object of IOError ## Debby error.
6 | Row* = seq[string] ## Debby Row type .. just a seq of strings.
7 | Bytes* = distinct string ## Debby's binary datatype. Use this if your data contains nulls or non-utf8 bytes.
8 |
9 | const ReservedNames* = [
10 | "select", "insert", "update", "delete", "from", "where", "join", "inner", "outer",
11 | "left", "right", "on", "group", "by", "order", "having", "limit", "offset", "union",
12 | "create", "alter", "drop", "set", "null", "not", "distinct", "as", "is", "like",
13 | "and", "or", "in", "exists", "any", "all", "between", "asc", "desc", "case",
14 | "when", "then", "else", "end", "some", "with", "table", "column", "value",
15 | "index", "primary", "foreign", "key", "default", "check", "unique", "constraint",
16 | "references", "varchar", "char", "text", "integer", "int", "smallint", "bigint",
17 | "decimal", "numeric", "float", "double", "real", "boolean", "date", "time", "timestamp",
18 | "user"
19 | ] ## Do not use these strings in your tables or column names.
20 |
21 | const ReservedSet = toHashSet(ReservedNames)
22 |
23 | proc toSnakeCase*(s: string): string =
24 | for c in s:
25 | if c.isUpperAscii():
26 | if len(result) > 0:
27 | result.add('_')
28 | result.add(c.toLowerAscii())
29 | else:
30 | result.add(c)
31 |
32 | proc tableName*[T](t: typedesc[T]): string =
33 | ## Converts object type name to table name.
34 | ($type(T)).toSnakeCase
35 |
36 | proc dbError*(msg: string) {.noreturn.} =
37 | ## Raises a DbError with just a message.
38 | ## Does not query the database for error.
39 | raise newException(DbError, msg)
40 |
41 | proc validateObj*[T: ref object](t: typedesc[T]) =
42 |
43 | let tmp = T()
44 |
45 | if T.tableName in ReservedSet:
46 | dbError(&"The '{T.tableName}' is a reserved word in SQL, please use a different name.")
47 |
48 | var foundId = false
49 |
50 | for name, field in tmp[].fieldPairs:
51 |
52 | if name == "id":
53 | foundId = true
54 | if $type(field) != "int":
55 | dbError("Table's id fields must be typed as int.")
56 |
57 | let fieldName = name.toSnakeCase
58 |
59 | if fieldName in ReservedSet:
60 | dbError(&"The '{fieldName}' is a reserved word in SQL, please use a different name.")
61 |
62 | if not foundId:
63 | dbError("Table's must have primary key id: int field.")
64 |
65 | proc sqlDumpHook*[T: SomeFloat|SomeInteger](v: T): string =
66 | ## SQL dump hook for numbers.
67 | $v
68 |
69 | proc sqlDumpHook*[T: string](v: T): string =
70 | ## SQL dump hook for strings.
71 | v
72 |
73 | proc sqlDumpHook*[T: distinct](v: T): string =
74 | ## SQL dump hook for strings.
75 | sqlDumpHook(v.distinctBase)
76 |
77 | proc sqlDumpHook*[T: enum](v: T): string =
78 | ## SQL dump hook for enums
79 | $v
80 |
81 | proc sqlParseHook*[T: string](data: string, v: var T) =
82 | ## SQL parse hook to convert to a string.
83 | v = data
84 |
85 | proc sqlParseHook*[T: SomeFloat](data: string, v: var T) =
86 | ## SQL parse hook to convert to any float.
87 | try:
88 | v = data.parseFloat()
89 | except:
90 | v = 0
91 |
92 | proc sqlParseHook*[T: SomeUnsignedInt](data: string, v: var T) =
93 | ## SQL parse hook to convert to any integer.
94 | try:
95 | discard data.parseBiggestUInt(v)
96 | except:
97 | v = 0
98 |
99 | proc sqlParseHook*[T: SomeSignedInt](data: string, v: var T) =
100 | ## SQL parse hook to convert to any integer.
101 | try:
102 | v = data.parseInt()
103 | except:
104 | v = 0
105 |
106 | proc sqlParseHook*[T: enum](data: string, v: var T) =
107 | ## SQL parse hook to convert to any enum.
108 | try:
109 | v = parseEnum[T](data)
110 | except:
111 | discard # default enum value
112 |
113 | proc sqlParseHook*[T: distinct](data: string, v: var T) =
114 | ## SQL parse distinct.
115 | sqlParseHook(data, v.distinctBase)
116 |
117 | proc sqlParse*[T](data: string, v: var T) =
118 | ## SQL parse distinct.
119 | when compiles(sqlParseHook(data, v)):
120 | sqlParseHook(data, v)
121 | else:
122 | if data != "":
123 | v = data.fromJson(type(v))
124 |
125 | type Argument* = object
126 | sqlType*: string
127 | value*: string
128 |
129 | proc toArgument*[T](v: T): Argument =
130 | when compiles(sqlDumpHook(v)):
131 | result.value = sqlDumpHook(v)
132 | else:
133 | result.sqlType = "JSON"
134 | result.value = v.toJson()
135 |
136 | proc get*[T, V](
137 | db: Db,
138 | t: typedesc[T],
139 | id: V
140 | ): T =
141 | ## Gets the object by id.
142 | doAssert type(V) is type(t.id), "Types for id don't match"
143 | let res = db.query(t, "SELECT * FROM " & T.tableName & " WHERE id = ?", id)
144 | if res.len == 1:
145 | return res[0]
146 |
147 | proc update*[T: ref object](db: Db, obj: T) =
148 | ## Updates the row that corresponds to the object in the database.
149 | ## Makes sure the obj.id is set.
150 | var
151 | query = ""
152 | values: seq[Argument]
153 | query.add "UPDATE " & T.tableName & " SET\n"
154 | for name, field in obj[].fieldPairs:
155 | if name != "id":
156 | query.add " " & name.toSnakeCase & " = ?,\n"
157 | values.add toArgument(field)
158 | query.removeSuffix(",\n")
159 | query.add "\nWHERE id = ?;"
160 | values.add toArgument(obj[].id)
161 | db.query(query, values)
162 |
163 | proc delete*[T: ref object](db: Db, obj: T) =
164 | ## Deletes the row that corresponds to the object from the data
165 | ## base. Makes sure the obj.id is set.
166 | db.query("DELETE FROM " & T.tableName & " WHERE id = ?;", obj.id)
167 |
168 | proc insertInner*[T: ref object](db: Db, obj: T, extra = ""): seq[Row] =
169 | ## Inserts the object into the database.
170 | if obj.id != 0:
171 | dbError("Trying to insert obj with .id != 0. Has it been already inserted?")
172 |
173 | var
174 | query = ""
175 | qs = ""
176 | values: seq[Argument]
177 |
178 | query.add "INSERT INTO " & T.tableName & " (\n"
179 | for name, field in obj[].fieldPairs:
180 | if name == "id" and type(field) is int:
181 | discard
182 | else:
183 | query.add " " & name.toSnakeCase & ",\n"
184 | values.add toArgument(field)
185 | qs.add "?"
186 | qs.add ", "
187 | query.removeSuffix(",\n")
188 | qs.removeSuffix(", ")
189 | query.add "\n"
190 | query.add ") VALUES ("
191 | query.add qs
192 | query.add ")"
193 | query.add extra
194 |
195 | db.query(query, values)
196 |
197 | template insert*[T: ref object](db: Db, objs: seq[T]) =
198 | ## Inserts a seq of objects into the database.
199 | for obj in objs:
200 | db.insert(obj)
201 |
202 | template delete*[T: ref object](db: Db, objs: seq[T]) =
203 | ## Deletes a seq of objects from the database.
204 | for obj in objs:
205 | db.delete(obj)
206 |
207 | template update*[T: ref object](db: Db, objs: seq[T]) =
208 | ## Updates a seq of objects into the database.
209 | for obj in objs:
210 | db.update(obj)
211 |
212 | template upsert*[T: ref object](db: Db, obj: T) =
213 | ## Either updates or inserts a ref object into the database.
214 | ## Will read the inserted id back.
215 | if obj.id == 0:
216 | db.insert(obj)
217 | else:
218 | db.update(obj)
219 |
220 | template upsert*[T: ref object](db: Db, objs: seq[T]) =
221 | ## Either updates or inserts a seq of object into the database.
222 | ## Will read the inserted id back for each object.
223 | for obj in objs:
224 | db.upsert(obj)
225 |
226 | template createIndex*[T: ref object](
227 | db: Db,
228 | t: typedesc[T],
229 | params: varargs[string]
230 | ) =
231 | ## Creates a index, errors out if it already exists.
232 | var params2: seq[string]
233 | for p in params:
234 | params2.add p.toSnakeCase
235 | db.query(db.createIndexStatement(t, false, params2))
236 |
237 | template createIndexIfNotExists*[T: ref object](
238 | db: Db,
239 | t: typedesc[T],
240 | params: varargs[string]
241 | ) =
242 | ## Creates a index if it does not already exists.
243 | var params2: seq[string]
244 | for p in params:
245 | params2.add p.toSnakeCase
246 | db.query(db.createIndexStatement(t, true, params2))
247 |
248 | # Filter macro is complex:
249 |
250 | const allowed = @["!=", ">=", "<=", ">", "<", "and", "or", "not"]
251 |
252 | proc findByStrVal(node: NimNode, s: string): NimNode =
253 | ## Walks all children nodes, looking for matching string value.
254 | if node.kind == nnkSym and node.strVal == s:
255 | return node
256 | for child in node.children:
257 | let n = child.findByStrVal(s)
258 | if n != nil:
259 | return n
260 |
261 | proc walk(n: NimNode, params: var seq[NimNode]): string =
262 | ## Walks the Nim nodes and converts them from Nim to SQL expression.
263 | ## Values are removed and replaced with ? and then put in the params seq.
264 | ## it.model == model -> model = ?, [model]
265 | ## it.year >= a and it.year < b -> year>=? and year, [a, b]
266 | case n.kind
267 | of nnkSym:
268 | if n.strVal == "==":
269 | return "="
270 | elif n.strVal in allowed:
271 | return n.strVal
272 | else:
273 | params.add n
274 | return "?"
275 | of nnkHiddenDeref:
276 | return walk(n[0], params)
277 | of nnkHiddenStdConv:
278 | return walk(n[1], params)
279 | of nnkDotExpr:
280 | if n[0].repr() == "it":
281 | result.add repr(n[1]).toSnakeCase
282 | else:
283 | params.add n
284 | result.add "?"
285 | of nnkInfix:
286 | result.add "("
287 | result.add walk(n[1], params)
288 | let op = n[0].repr()
289 | if op == "==":
290 | result.add "="
291 | else:
292 | result.add op
293 | result.add walk(n[2], params)
294 | result.add ")"
295 | of nnkPrefix:
296 | result.add "("
297 | result.add n[0].repr()
298 | result.add walk(n[1], params)
299 | result.add ")"
300 | of nnkStmtListExpr:
301 | return walk(n[1], params)
302 | of nnkStrLit:
303 | params.add n
304 | return "?"
305 | of nnkIntLit:
306 | return n.repr()
307 | of nnkCall, nnkCommand:
308 | params.add n
309 | let itNode = n.findByStrVal("it")
310 | if itNode != nil:
311 | error("Cannot pass `it` to any calling functions", itNode)
312 | return "?"
313 | else:
314 | assert false, $n.kind & " not supported: " & n.treeRepr()
315 |
316 | proc innerSelect*[T: ref object](
317 | db: Db,
318 | it: T,
319 | where: string,
320 | args: varargs[Argument, toArgument]
321 | ): seq[T] =
322 | ## Used by innerFilter to make the db.select call.
323 | let statement = "SELECT * FROM " & T.tableName & " WHERE " & where
324 | db.query(
325 | T,
326 | statement,
327 | args
328 | )
329 |
330 | macro innerFilter(db, it, expression: typed): untyped =
331 | ## Typed marco that makes the call to innerSelect
332 | var params: seq[NimNode]
333 | let clause = walk(expression, params)
334 | var call = nnkCall.newTree(
335 | newIdentNode("innerSelect"),
336 | db,
337 | it,
338 | newStrLitNode(clause),
339 | )
340 | for param in params:
341 | call.add(param)
342 | return call
343 |
344 | template filter*[T: ref object](db: Db, t: typedesc[T], expression: untyped): untyped =
345 | ## Filters type's table with a Nim like filter expression.
346 | ## db.filter(Auto, it.year > 1990)
347 | ## db.filter(Auto, it.make == "Ferrari" or it.make == "Lamborghini")
348 | ## db.filter(Auto, it.year >= startYear and it.year < endYear)
349 |
350 | block:
351 | # Inject the `it` into the expression.
352 | var it {.inject.}: T = T()
353 | # Pass the expression to a typed macro to convert it to SQL where clause.
354 | innerFilter(db, it, expression)
355 |
356 | proc filter*[T](
357 | db: Db,
358 | t: typedesc[T],
359 | ): seq[T] =
360 | ## Filter without a filter clause just returns everything.
361 | db.query(t, ("select * from " & T.tableName))
362 |
363 | proc hexNibble*(ch: char): int =
364 | ## Encodes a hex char.
365 | case ch:
366 | of '0'..'9':
367 | return ch.ord - '0'.ord
368 | of 'a'..'f':
369 | return ch.ord - 'a'.ord + 10
370 | of 'A'..'F':
371 | return ch.ord - 'A'.ord + 10
372 | else:
373 | raise newException(DbError, "Invalid hexadecimal digit: " & $ch)
374 |
375 | proc dropTable*[T](db: Db, t: typedesc[T]) =
376 | ## Removes tables, errors out if it does not exist.
377 | db.query("DROP TABLE " & T.tableName)
378 |
379 | proc dropTableIfExists*[T](db: Db, t: typedesc[T]) =
380 | ## Removes tables if it exists.
381 | db.query("DROP TABLE IF EXISTS " & T.tableName)
382 |
383 | proc createTable*[T: ref object](db: Db, t: typedesc[T]) =
384 | ## Creates a table, errors out if it already exists.
385 | db.query(db.createTableStatement(t))
386 |
--------------------------------------------------------------------------------
/src/debby/mysql.nim:
--------------------------------------------------------------------------------
1 | import common, jsony, std/strutils, std/strformat, std/tables, std/macros,
2 | std/sets
3 | export common, jsony
4 |
5 | when defined(windows):
6 | const Lib = "(libmysql.dll|libmariadb.dll)"
7 | elif defined(macosx):
8 | const Lib = "(libmysqlclient|libmariadbclient)(|.21|).dylib"
9 | else:
10 | const Lib = "(libmysqlclient|libmariadbclient).so(|.21|)"
11 |
12 | type
13 | PRES = pointer
14 |
15 | FIELD*{.final.} = object
16 | name*: cstring
17 | PFIELD* = ptr FIELD
18 |
19 | {.push importc, cdecl, dynlib: Lib.}
20 |
21 | proc mysql_init*(MySQL: DB): DB
22 |
23 | proc mysql_error*(MySQL: DB): cstring
24 |
25 | proc mysql_real_connect*(
26 | MySQL: DB,
27 | host: cstring,
28 | user: cstring,
29 | passwd: cstring,
30 | db: cstring,
31 | port: cuint,
32 | unix_socket: cstring,
33 | clientflag: int
34 | ): int
35 |
36 | proc mysql_close*(sock: DB)
37 |
38 | proc mysql_query*(MySQL: DB, q: cstring): cint
39 |
40 | proc mysql_store_result*(MySQL: DB): PRES
41 |
42 | proc mysql_num_rows*(res: PRES): uint64
43 |
44 | proc mysql_num_fields*(res: PRES): cuint
45 |
46 | proc mysql_fetch_row*(result: PRES): cstringArray
47 |
48 | proc mysql_free_result*(result: PRES)
49 |
50 | proc mysql_real_escape_string*(MySQL: DB, fto: cstring, `from`: cstring, len: int): int
51 |
52 | proc mysql_insert_id*(MySQL: DB): uint64
53 |
54 | proc mysql_fetch_field_direct*(res: PRES, fieldnr: cuint): PFIELD
55 |
56 | {.pop.}
57 |
58 | proc dbError*(db: Db) {.noreturn.} =
59 | ## Raises an error from the database.
60 | raise newException(DbError, "MySQL: " & $mysql_error(db))
61 |
62 | proc sqlType(t: typedesc): string =
63 | ## Converts nim type to sql type.
64 | when t is string: "text"
65 | elif t is Bytes: "text"
66 | elif t is int8: "tinyint"
67 | elif t is uint8: "tinyint unsigned"
68 | elif t is int16: "smallint"
69 | elif t is uint16: "smallint unsigned"
70 | elif t is int32: "int"
71 | elif t is uint32: "int unsigned"
72 | elif t is int or t is int64: "bigint"
73 | elif t is uint or t is uint64: "bigint unsigned"
74 | elif t is float or t is float32: "float"
75 | elif t is float64: "double"
76 | elif t is bool: "boolean"
77 | elif t is enum: "text"
78 | else: "json"
79 |
80 | proc prepareQuery(
81 | db: DB,
82 | query: string,
83 | args: varargs[Argument, toArgument]
84 | ): string =
85 | ## Generates the query based on parameters.
86 | when defined(debbyShowSql):
87 | debugEcho(query)
88 |
89 | if query.count('?') != args.len:
90 | dbError("Number of arguments and number of ? in query does not match")
91 |
92 | var argNum = 0
93 | for c in query:
94 | if c == '?':
95 | let arg = args[argNum]
96 | # This is a bit hacky, I am open to suggestions.
97 | # mySQL does not take JSON in the query
98 | # It must be CAST AS JSON.
99 | if arg.sqlType != "":
100 | result.add "CAST("
101 | result.add "'"
102 | var escapedArg = newString(arg.value.len * 2 + 1)
103 | let newLen = mysql_real_escape_string(
104 | db,
105 | escapedArg.cstring,
106 | arg.value.cstring,
107 | arg.value.len.int32
108 | )
109 | escapedArg.setLen(newLen)
110 | result.add escapedArg
111 | result.add "'"
112 | if arg.sqlType != "":
113 | result.add " AS " & arg.sqlType & ")"
114 | inc argNum
115 | else:
116 | result.add c
117 |
118 | proc readRow(res: PRES, r: var seq[string], columnCount: int) =
119 | ## Reads a single row back.
120 | var row = mysql_fetch_row(res)
121 | for column in 0 ..< columnCount:
122 | r[column] = $row[column]
123 |
124 | proc query*(
125 | db: DB,
126 | query: string,
127 | args: varargs[Argument, toArgument]
128 | ): seq[Row] {.discardable.} =
129 | ## Runs a query and returns the results.
130 | var sql = prepareQuery(db, query, args)
131 | if mysql_query(db, sql.cstring) != 0:
132 | dbError(db)
133 | var res = mysql_store_result(db)
134 | if res != nil:
135 | var rowCount = mysql_num_rows(res).int
136 | var columnCount = mysql_num_fields(res).int
137 | try:
138 | for i in 0 ..< rowCount:
139 | var row = newSeq[string](columnCount)
140 | readRow(res, row, columnCount)
141 | result.add(row)
142 | finally:
143 | mysql_free_result(res)
144 |
145 | proc openDatabase*(
146 | database: string,
147 | host = "localhost",
148 | port = 3306,
149 | user = "root",
150 | password = ""
151 | ): DB =
152 | ## Opens a database connection.
153 | var db = mysql_init(cast[Db](nil))
154 | if cast[pointer](db) == nil:
155 | dbError("could not open database connection")
156 |
157 | if mysql_real_connect(
158 | db,
159 | host.cstring,
160 | user.cstring,
161 | password.cstring,
162 | database.cstring,
163 | port.cuint,
164 | nil,
165 | 0
166 | ) == 0:
167 | dbError(db)
168 |
169 | db.query("SET sql_mode='ANSI_QUOTES'")
170 |
171 | return db
172 |
173 | proc close*(db: DB) =
174 | ## Closes the database connection.
175 | mysql_close(db)
176 |
177 | proc tableExists*[T](db: Db, t: typedesc[T]): bool =
178 | ## Checks if table exists.
179 | for row in db.query(&"""SELECT
180 | table_name
181 | FROM
182 | information_schema.tables
183 | WHERE
184 | table_schema = DATABASE()
185 | AND table_name = '{T.tableName}';
186 | """):
187 | result = true
188 | break
189 |
190 | proc createIndexStatement*[T: ref object](
191 | db: Db,
192 | t: typedesc[T],
193 | ifNotExists: bool,
194 | params: varargs[string]
195 | ): string =
196 | ## Returns the SQL code need to create an index.
197 | result.add "CREATE INDEX "
198 | if ifNotExists:
199 | result.add "IF NOT EXISTS "
200 | result.add "idx_"
201 | result.add T.tableName
202 | result.add "_"
203 | result.add params.join("_")
204 | result.add " ON "
205 | result.add T.tableName
206 | result.add " ("
207 | result.add params.join(", ")
208 | result.add ")"
209 |
210 | proc createTableStatement*[T: ref object](db: Db, t: typedesc[T]): string =
211 | ## Given an object creates its table create statement.
212 | validateObj(T)
213 | let tmp = T()
214 | result.add "CREATE TABLE "
215 | result.add T.tableName
216 | result.add " (\n"
217 | for name, field in tmp[].fieldPairs:
218 | result.add " "
219 | result.add name.toSnakeCase
220 | result.add " "
221 | result.add sqlType(type(field))
222 | if name == "id":
223 | result.add " PRIMARY KEY AUTO_INCREMENT"
224 | result.add ",\n"
225 | result.removeSuffix(",\n")
226 | result.add "\n)"
227 |
228 | proc checkTable*[T: ref object](db: Db, t: typedesc[T]) =
229 | ## Checks to see if table matches the object.
230 | ## And recommends to create whole table or alter it.
231 | let tmp = T()
232 | var issues: seq[string]
233 |
234 | if not db.tableExists(T):
235 | when defined(debbyYOLO):
236 | db.createTable(T)
237 | else:
238 | issues.add "Table " & T.tableName & " does not exist."
239 | issues.add "Create it with:"
240 | issues.add db.createTableStatement(t)
241 | else:
242 | var tableSchema: Table[string, string]
243 | for row in db.query(&"""SELECT
244 | COLUMN_NAME,
245 | DATA_TYPE
246 | FROM
247 | INFORMATION_SCHEMA.COLUMNS
248 | WHERE
249 | TABLE_SCHEMA = DATABASE()
250 | AND TABLE_NAME = '{T.tableName}';
251 | """):
252 | let
253 | fieldName = row[0]
254 | fieldType = row[1]
255 | tableSchema[fieldName] = fieldType
256 |
257 | for fieldName, field in tmp[].fieldPairs:
258 | let sqlType = sqlType(type(field))
259 |
260 | if fieldName.toSnakeCase in tableSchema:
261 | if tableSchema[fieldName.toSnakeCase] == sqlType:
262 | discard # good everything matches
263 | else:
264 | issues.add "Field " & T.tableName & "." & fieldName & " expected type " & sqlType & " but got " & tableSchema[fieldName]
265 | # TODO create new table with right data
266 | # copy old data into new table
267 | # delete old table
268 | # rename new table
269 | else:
270 | let addFieldStatement = "ALTER TABLE " & T.tableName & " ADD COLUMN " & fieldName.toSnakeCase & " " & sqlType & ";"
271 | if defined(debbyYOLO):
272 | db.query(addFieldStatement)
273 | else:
274 | issues.add "Field " & T.tableName & "." & fieldName & " is missing"
275 | issues.add "Add it with:"
276 | issues.add addFieldStatement
277 |
278 | if issues.len != 0:
279 | issues.add "Or compile --d:debbyYOLO to do this automatically"
280 | raise newException(DBError, issues.join("\n"))
281 |
282 | proc insert*[T: ref object](db: Db, obj: T) =
283 | ## Inserts the object into the database.
284 | ## Reads the ID of the inserted ref object back.
285 | discard db.insertInner(obj)
286 | obj.id = mysql_insert_id(db).int
287 |
288 | proc query*[T](
289 | db: Db,
290 | t: typedesc[T],
291 | query: string,
292 | args: varargs[Argument, toArgument]
293 | ): seq[T] =
294 | ## Query the table, and returns results as a seq of ref objects.
295 | ## This will match fields to column names.
296 | ## This will also use JSONy for complex fields.
297 | let tmp = T()
298 |
299 | var
300 | sql = prepareQuery(db, query, args)
301 |
302 | if mysql_query(db, sql.cstring) != 0:
303 | dbError(db)
304 |
305 | var res = mysql_store_result(db)
306 | if res != nil:
307 |
308 | var rowCount = mysql_num_rows(res).int
309 | var columnCount = mysql_num_fields(res).int
310 | var headerIndex: seq[int]
311 |
312 | for i in 0 ..< columnCount:
313 | let field = mysql_fetch_field_direct(res, i.cuint)
314 | if field == nil:
315 | dbError("Field is nil")
316 | let columnName = $field[].name
317 | var
318 | j = 0
319 | found = false
320 | for fieldName, field in tmp[].fieldPairs:
321 | if columnName == fieldName.toSnakeCase:
322 | found = true
323 | headerIndex.add(j)
324 | break
325 | inc j
326 | if not found:
327 | raise newException(
328 | DBError,
329 | "Can't map query to object, missing " & $columnName
330 | )
331 |
332 | try:
333 | for j in 0 ..< rowCount:
334 | var row = newSeq[string](columnCount)
335 | readRow(res, row, columnCount)
336 | let tmp = T()
337 | var i = 0
338 | for fieldName, field in tmp[].fieldPairs:
339 | sqlParse(row[headerIndex[i]], field)
340 | inc i
341 | result.add(tmp)
342 | finally:
343 | mysql_free_result(res)
344 |
345 | template withTransaction*(db: Db, body) =
346 | ## Transaction block.
347 |
348 | # Start a transaction
349 | discard db.query("START TRANSACTION;")
350 |
351 | try:
352 | body
353 |
354 | # Commit the transaction
355 | discard db.query("COMMIT;")
356 | except Exception as e:
357 | discard db.query("ROLLBACK;")
358 | raise e
359 |
360 | proc sqlDumpHook*(v: bool): string =
361 | ## SQL dump hook to convert from bool.
362 | if v: "1"
363 | else: "0"
364 |
365 | proc sqlParseHook*(data: string, v: var bool) =
366 | ## SQL parse hook to convert to bool.
367 | v = data == "1"
368 |
369 | proc sqlDumpHook*(data: Bytes): string =
370 | ## MySQL-specific dump hook for binary data.
371 | let hexChars = "0123456789abcdef"
372 | var hexStr = "\\x"
373 | for ch in data.string:
374 | let code = ch.ord
375 | hexStr.add hexChars[code shr 4] # Dividing by 16
376 | hexStr.add hexChars[code and 0x0F] # Modulo operation with 16
377 | return hexStr
378 |
379 | proc sqlParseHook*(data: string, v: var Bytes) =
380 | ## MySQL-specific parse hook for binary data.
381 | if not (data.len >= 2 and data[0] == '\\' and data[1] == 'x'):
382 | raise newException(DbError, "Invalid binary representation" )
383 | var buffer = ""
384 | for i in countup(2, data.len - 1, 2): # Parse the hexadecimal characters two at a time
385 | let highNibble = hexNibble(data[i]) # Extract the high nibble
386 | let lowNibble = hexNibble(data[i + 1]) # Extract the low nibble
387 | let byte = (highNibble shl 4) or lowNibble # Convert the high and low nibbles to a byte
388 | buffer.add chr(byte) # Convert the byte to a character and append it to the result string
389 | v = buffer.Bytes
390 |
--------------------------------------------------------------------------------
/src/debby/pools.nim:
--------------------------------------------------------------------------------
1 |
2 | when not defined(nimdoc):
3 | when not compileOption("threads"):
4 | {.error: "Using --threads:on is required with debby pools.".}
5 | when not defined(gcArc) and not defined(gcOrc) and not defined(gcAtomicArc):
6 | {.error: "Using --mm:arc or --mm:orc is required with debby pools.".}
7 |
8 | import std/locks, std/random, common
9 |
10 | type
11 | Pool* = ptr PoolObj
12 |
13 | PoolObj = object
14 | entries: seq[Db]
15 | lock: Lock
16 | cond: Cond
17 | r: Rand
18 |
19 | proc newPool*(): Pool =
20 | ## Creates a new thread-safe pool.
21 | ## Pool starts empty, don't forget to .add() DB connections.
22 | result = cast[Pool](allocShared0(sizeof(PoolObj)))
23 | initLock(result.lock)
24 | initCond(result.cond)
25 | result.r = initRand(2023)
26 |
27 | proc borrow*(pool: Pool): Db {.raises: [], gcsafe.} =
28 | ## Note: you should use withDb instead.
29 | ## Takes an entry from the pool. This call blocks until it can take
30 | ## an entry. After taking an entry remember to add it back to the pool
31 | ## when you're finished with it.
32 | {.gcsafe.}:
33 | acquire(pool.lock)
34 | while pool.entries.len == 0:
35 | wait(pool.cond, pool.lock)
36 | result = pool.entries.pop()
37 | release(pool.lock)
38 |
39 | proc add*(pool: Pool, t: Db) {.raises: [], gcsafe.} =
40 | ## Add new or returns an entry to the pool.
41 | {.gcsafe.}:
42 | withLock pool.lock:
43 | pool.entries.add(t)
44 | pool.r.shuffle(pool.entries)
45 | signal(pool.cond)
46 |
47 | template close*(pool: Pool) =
48 | ## Closes all entires and Deallocates the pool.
49 | withLock pool.lock:
50 | for db in pool.entries:
51 | try:
52 | db.close()
53 | except:
54 | discard
55 | deinitLock(pool.lock)
56 | deinitCond(pool.cond)
57 | `=destroy`(pool[])
58 | deallocShared(pool)
59 |
60 | template withDb*(pool: Pool, body: untyped) =
61 | block:
62 | let db {.inject.} = pool.borrow()
63 | try:
64 | body
65 | finally:
66 | pool.add(db)
67 |
68 | proc dropTable*[T](pool: Pool, t: typedesc[T]) =
69 | ## Removes tables, errors out if it does not exist.
70 | pool.withDb:
71 | db.dropTable(t)
72 |
73 | proc dropTableIfExists*[T](pool: Pool, t: typedesc[T]) =
74 | ## Removes tables if it exists.
75 | pool.withDb:
76 | db.dropTableIfExists(t)
77 |
78 | proc createTable*[T: ref object](pool: Pool, t: typedesc[T]) =
79 | ## Creates a table, errors out if it already exists.
80 | pool.withDb:
81 | db.createTable(t)
82 |
83 | template checkTable*[T: ref object](pool: Pool, t: typedesc[T]) =
84 | ## Checks to see if table matches the object.
85 | ## And recommends to create whole table or alter it.
86 | pool.withDb:
87 | db.checkTable(t)
88 |
89 | proc get*[T, V](pool: Pool, t: typedesc[T], id: V): T =
90 | ## Gets the object by id.
91 | pool.withDb:
92 | return db.get(t, id)
93 |
94 | proc update*[T: ref object](pool: Pool, obj: T) =
95 | ## Updates the row that corresponds to the object in the database.
96 | ## Makes sure the obj.id is set.
97 | pool.withDb:
98 | db.update(obj)
99 |
100 | template update*[T: ref object](pool: Pool, objs: seq[T]) =
101 | ## Updates a seq of objects into the database.
102 | pool.withDb:
103 | db.update(objs)
104 |
105 | proc delete*[T: ref object](pool: Pool, obj: T) =
106 | ## Deletes the row that corresponds to the object from the data
107 | ## base. Makes sure the obj.id is set.
108 | pool.withDb:
109 | db.delete(obj)
110 |
111 | template delete*[T: ref object](pool: Pool, objs: seq[T]) =
112 | ## Deletes a seq of objects from the database.
113 | pool.withDb:
114 | db.delete(objs)
115 |
116 | template insert*[T: ref object](pool: Pool, obj: T) =
117 | ## Inserts the object into the database.
118 | ## Reads the ID of the inserted ref object back.
119 | pool.withDb:
120 | db.insert(obj)
121 |
122 | template insert*[T: ref object](pool: Pool, objs: seq[T]) =
123 | ## Inserts a seq of objects into the database.
124 | pool.withDb:
125 | db.insert(objs)
126 |
127 | template upsert*[T: ref object](pool: Pool, obj: T) =
128 | ## Either updates or inserts a ref object into the database.
129 | ## Will read the inserted id back.
130 | pool.withDb:
131 | db.upsert(obj)
132 |
133 | template upsert*[T: ref object](pool: Pool, objs: seq[T]) =
134 | ## Either updates or inserts a seq of object into the database.
135 | ## Will read the inserted id back for each object.
136 | pool.withDb:
137 | db.upsert(objs)
138 |
139 | template filter*[T: ref object](pool: Pool, t: typedesc[T], expression: untyped): untyped =
140 | ## Filters type's table with a Nim like filter expression.
141 | ## db.filter(Auto, it.year > 1990)
142 | ## db.filter(Auto, it.make == "Ferrari" or it.make == "Lamborghini")
143 | ## db.filter(Auto, it.year >= startYear and it.year < endYear)
144 | var tmp: seq[T]
145 | pool.withDb:
146 | tmp = db.filter(t, expression)
147 | tmp
148 |
149 | proc filter*[T](pool: Pool, t: typedesc[T]): seq[T] =
150 | ## Filter without a filter clause just returns everything.
151 | pool.withDb:
152 | return db.filter(t)
153 |
154 | template query*(pool: Pool, sql: string, args: varargs[Argument, toArgument]): seq[Row] =
155 | ## Query returning plain results
156 | var data: seq[Row]
157 | pool.withDb:
158 | data = db.query(sql, args)
159 | data
160 |
161 | template query*[T](pool: Pool, t: typedesc[T], sql: string, args: varargs[Argument, toArgument]): seq[T] =
162 | ## Gets the object by id.
163 | var data: seq[T]
164 | pool.withDb:
165 | data = db.query(t, sql, args)
166 | data
167 |
--------------------------------------------------------------------------------
/src/debby/postgres.nim:
--------------------------------------------------------------------------------
1 | import common, jsony, std/tables, std/strformat, std/strutils, std/sets
2 | export common, jsony
3 |
4 | when defined(windows):
5 | const Lib = "libpq.dll"
6 | elif defined(macosx):
7 | const Lib = "libpq.dylib"
8 | else:
9 | const Lib = "libpq.so(.5|)"
10 |
11 | type
12 | Statement* = pointer
13 | Result* = pointer
14 |
15 | ConnStatusType* = enum
16 | CONNECTION_OK = 0, CONNECTION_BAD, CONNECTION_STARTED, CONNECTION_MADE,
17 | CONNECTION_AWAITING_RESPONSE, CONNECTION_AUTH_OK, CONNECTION_SETENV,
18 | CONNECTION_SSL_STARTUP, CONNECTION_NEEDED, CONNECTION_CHECK_WRITABLE,
19 | CONNECTION_CONSUME, CONNECTION_GSS_STARTUP, CONNECTION_CHECK_TARGET
20 |
21 | ExecStatusType* = enum
22 | PGRES_EMPTY_QUERY = 0, PGRES_COMMAND_OK, PGRES_TUPLES_OK, PGRES_COPY_OUT,
23 | PGRES_COPY_IN, PGRES_BAD_RESPONSE, PGRES_NONFATAL_ERROR, PGRES_FATAL_ERROR,
24 | PGRES_COPY_BOTH, PGRES_SINGLE_TUPLE
25 |
26 | {.push importc, cdecl, dynlib: Lib.}
27 |
28 | proc PQsetdbLogin*(
29 | pghost: cstring,
30 | pgport: cstring,
31 | pgoptions: cstring,
32 | pgtty: cstring,
33 | dbName: cstring,
34 | login: cstring,
35 | pwd: cstring
36 | ): DB
37 |
38 | proc PQstatus*(
39 | conn: DB
40 | ): ConnStatusType
41 |
42 | proc PQerrorMessage*(
43 | conn: DB
44 | ): cstring
45 |
46 | proc PQfinish*(
47 | conn: DB
48 | )
49 |
50 | proc PQexec*(
51 | conn: DB,
52 | query: cstring
53 | ): Result
54 |
55 | proc PQexecParams*(
56 | conn: DB,
57 | command: cstring,
58 | nParams: int32,
59 | paramTypes: ptr int32,
60 | paramValues: cstringArray,
61 | paramLengths: ptr int32,
62 | paramFormats: ptr int32,
63 | resultFormat: int32
64 | ): Result
65 |
66 | proc PQresultStatus*(
67 | res: Result
68 | ): ExecStatusType
69 |
70 | proc PQntuples*(
71 | res: Result
72 | ): int32
73 |
74 | proc PQnfields*(
75 | res: Result
76 | ): int32
77 |
78 | proc PQclear*(
79 | res: Result
80 | )
81 |
82 | proc PQgetvalue*(
83 | res: Result,
84 | tup_num: int32,
85 | field_num: int32
86 | ): cstring
87 |
88 | proc PQfname*(
89 | res: Result,
90 | field_num: int32
91 | ): cstring
92 |
93 | {.pop.}
94 |
95 | proc dbError*(db: DB) {.noreturn.} =
96 | ## Raises an error from the database.
97 | raise newException(DbError, "Postgres: " & $PQerrorMessage(db))
98 |
99 | proc sqlType(t: typedesc): string =
100 | ## Converts nim type to SQL type.
101 | when t is string: "text"
102 | elif t is Bytes: "bytea"
103 | elif t is int8: "smallint"
104 | elif t is uint8: "integer"
105 | elif t is int16: "smallint"
106 | elif t is uint16: "integer"
107 | elif t is int32: "integer"
108 | elif t is uint32: "integer"
109 | elif t is int or t is int64: "integer"
110 | elif t is uint or t is uint64: "numeric(20)"
111 | elif t is float or t is float32: "REAL"
112 | elif t is float64: "double precision"
113 | elif t is bool: "boolean"
114 | elif t is enum: "text"
115 | else: "jsonb"
116 |
117 | proc prepareQuery(
118 | db: DB,
119 | query: string,
120 | args: varargs[Argument, toArgument]
121 | ): Result =
122 | ## Generates the query based on parameters.
123 | when defined(debbyShowSql):
124 | debugEcho(query)
125 |
126 | if query.count('?') != args.len:
127 | dbError("Number of arguments and number of ? in query does not match")
128 |
129 | if args.len > 0:
130 | var pgQuery = ""
131 | var argNum = 1
132 | for c in query:
133 | if c == '?':
134 | # Use the $number escape:
135 | pgQuery.add "$"
136 | pgQuery.add $argNum
137 | inc argNum
138 | else:
139 | pgQuery.add c
140 |
141 | var
142 | paramData: seq[string]
143 | paramLengths: seq[int32]
144 | paramFormats: seq[int32]
145 |
146 | for arg in args:
147 | paramData.add(arg.value)
148 | paramLengths.add(arg.value.len.int32)
149 | paramFormats.add(0)
150 |
151 | var paramValues = allocCStringArray(paramData)
152 |
153 | result = PQexecParams(
154 | db,
155 | pgQuery.cstring,
156 | args.len.int32,
157 | nil, # let the backend deduce param type
158 | paramValues,
159 | paramLengths[0].addr,
160 | paramFormats[0].addr,
161 | 0 # ask for binary results
162 | )
163 |
164 | deallocCStringArray(paramValues)
165 |
166 | else:
167 | result = PQexec(db, query)
168 |
169 | proc checkForErrors(db: Db, res: Result) =
170 | ## Checks for errors in the result.
171 | if res == nil:
172 | dbError("Result is nil")
173 | if PQresultStatus(res) notin {
174 | PGRES_TUPLES_OK,
175 | PGRES_COMMAND_OK,
176 | PGRES_EMPTY_QUERY
177 | }:
178 | dbError(db)
179 |
180 | proc readRow(res: Result, r: var Row, line, cols: int32) =
181 | ## Reads a single row back.
182 | for col in 0'i32..cols-1:
183 | setLen(r[col], 0)
184 | let x = PQgetvalue(res, line, col)
185 | if x.isNil:
186 | r[col] = ""
187 | else:
188 | add(r[col], x)
189 |
190 | proc getAllRows(res: Result): seq[Row] =
191 | ## Try to get all rows from the result.
192 | if PQresultStatus(res) == PGRES_TUPLES_OK:
193 | let N = PQntuples(res)
194 | let L = PQnfields(res)
195 | if N > 0 and L > 0:
196 | result = newSeqOfCap[Row](N)
197 | var row = newSeq[string](L)
198 | for i in 0'i32..N-1:
199 | readRow(res, row, i, L)
200 | result.add(row)
201 |
202 | proc query*(
203 | db: DB,
204 | query: string,
205 | args: varargs[Argument, toArgument]
206 | ): seq[Row] {.discardable.} =
207 | ## Runs a query and returns the results.
208 | let res = prepareQuery(db, query, args)
209 | try:
210 | db.checkForErrors(res)
211 | result = getAllRows(res)
212 | finally:
213 | PQclear(res)
214 |
215 | proc openDatabase*(host, user, password, database: string, port = ""): Db =
216 | ## Opens a database connection.
217 | result = PQsetdbLogin(
218 | host.cstring,
219 | port.cstring,
220 | nil,
221 | nil,
222 | database.cstring,
223 | user.cstring,
224 | password.cstring
225 | )
226 | if PQstatus(result) != CONNECTION_OK:
227 | dbError(result)
228 |
229 | # Prevent Notice: junk messages from showing up.
230 | discard result.query("SET client_min_messages TO WARNING")
231 |
232 | proc close*(db: Db) =
233 | ## Closes the database connection.
234 | PQfinish(db)
235 |
236 | proc tableExists*[T](db: Db, t: typedesc[T]): bool =
237 | ## Checks if table exists.
238 | for row in db.query(&"""SELECT
239 | column_name,
240 | data_type
241 | FROM
242 | information_schema.columns
243 | WHERE
244 | table_schema = 'public'
245 | AND table_name = '{T.tableName}';
246 | """):
247 | result = true
248 | break
249 |
250 | proc createIndexStatement*[T: ref object](
251 | db: Db,
252 | t: typedesc[T],
253 | ifNotExists: bool,
254 | params: varargs[string]
255 | ): string =
256 | ## Returns the SQL code need to create an index.
257 | result.add "CREATE INDEX "
258 | if ifNotExists:
259 | result.add "IF NOT EXISTS "
260 | result.add "idx_"
261 | result.add T.tableName
262 | result.add "_"
263 | result.add params.join("_")
264 | result.add " ON "
265 | result.add T.tableName
266 | result.add "("
267 | result.add params.join(", ")
268 | result.add ")"
269 |
270 | proc createTableStatement*[T: ref object](db: Db, t: typedesc[T]): string =
271 | ## Given an object creates its table create statement.
272 | validateObj(t)
273 | let tmp = T()
274 | result.add "CREATE TABLE "
275 | result.add T.tableName
276 | result.add " (\n"
277 | for name, field in tmp[].fieldPairs:
278 | result.add " "
279 | result.add name.toSnakeCase
280 | result.add " "
281 | if name == "id":
282 | result.add " SERIAL PRIMARY KEY"
283 | else:
284 | result.add sqlType(type(field))
285 | result.add ",\n"
286 | result.removeSuffix(",\n")
287 | result.add "\n)"
288 |
289 | proc checkTable*[T: ref object](db: Db, t: typedesc[T]) =
290 | ## Checks to see if table matches the object.
291 | ## And recommends to create whole table or alter it.
292 | let tmp = T()
293 | var issues: seq[string]
294 |
295 | if not db.tableExists(T):
296 | when defined(debbyYOLO):
297 | db.createTable(T)
298 | else:
299 | issues.add "Table " & T.tableName & " does not exist."
300 | issues.add "Create it with:"
301 | issues.add db.createTableStatement(t)
302 | else:
303 | var tableSchema: Table[string, string]
304 | for row in db.query(&"""SELECT
305 | column_name,
306 | data_type
307 | FROM
308 | information_schema.columns
309 | WHERE
310 | table_schema = 'public'
311 | AND table_name = '{T.tableName}';
312 | """):
313 | let
314 | fieldName = row[0]
315 | fieldType = row[1]
316 | tableSchema[fieldName] = fieldType
317 |
318 | for fieldName, field in tmp[].fieldPairs:
319 | let sqlType = sqlType(type(field))
320 | if fieldName.toSnakeCase in tableSchema:
321 | if tableSchema[fieldName.toSnakeCase] == sqlType:
322 | discard # good everything matches
323 | else:
324 | issues.add "Field " & T.tableName & "." & fieldName & " expected type " & sqlType & " but got " & tableSchema[fieldName]
325 | # TODO create new table with right data
326 | # copy old data into new table
327 | # delete old table
328 | # rename new table
329 | else:
330 | let addFieldStatement = "ALTER TABLE " & T.tableName & " ADD COLUMN " & fieldName.toSnakeCase & " " & sqlType
331 | if defined(debbyYOLO):
332 | db.query(addFieldStatement)
333 | else:
334 | issues.add "Field " & T.tableName & "." & fieldName & " is missing"
335 | issues.add "Add it with:"
336 | issues.add addFieldStatement
337 |
338 | if issues.len != 0:
339 | issues.add "Or compile --d:debbyYOLO to do this automatically"
340 | raise newException(DBError, issues.join("\n"))
341 |
342 | proc insert*[T: ref object](db: Db, obj: T) =
343 | ## Inserts the object into the database.
344 | ## Reads the ID of the inserted ref object back.
345 | for row in db.insertInner(obj, " RETURNING id"):
346 | obj.id = row[0].parseInt()
347 |
348 | proc query*[T](
349 | db: Db,
350 | t: typedesc[T],
351 | query: string,
352 | args: varargs[Argument, toArgument]
353 | ): seq[T] =
354 | ## Query the table, and returns results as a seq of ref objects.
355 | ## This will match fields to column names.
356 | ## This will also use JSONy for complex fields.
357 | let tmp = T()
358 |
359 | let res = prepareQuery(db, query, args)
360 | try:
361 | db.checkForErrors(res)
362 | var
363 | columnCount = PQnfields(res)
364 | rowCount = PQntuples(res)
365 | headerIndex: seq[int]
366 |
367 | for i in 0 ..< columnCount:
368 | let columnName = $PQfname(res, i)
369 | var
370 | j = 0
371 | found = false
372 | for fieldName, field in tmp[].fieldPairs:
373 | if columnName == fieldName.toSnakeCase:
374 | found = true
375 | headerIndex.add(j)
376 | break
377 | inc j
378 | # if not found:
379 | # raise newException(
380 | # DBError,
381 | # "Can't map query to object, missing " & $columnName
382 | # )
383 |
384 | for j in 0 ..< rowCount:
385 | var row = newSeq[string](columnCount)
386 | readRow(res, row, j, columnCount)
387 | let tmp = T()
388 | var i = 0
389 | for fieldName, field in tmp[].fieldPairs:
390 | sqlParse(row[headerIndex[i]], field)
391 | inc i
392 | result.add(tmp)
393 | finally:
394 | PQclear(res)
395 |
396 | template withTransaction*(db: Db, body) =
397 | ## Transaction block.
398 |
399 | # Start a transaction
400 | discard db.query("BEGIN;")
401 |
402 | try:
403 | body
404 |
405 | # Commit the transaction
406 | discard db.query("COMMIT;")
407 | except Exception as e:
408 | discard db.query("ROLLBACK;")
409 | raise e
410 |
411 | proc sqlDumpHook*(v: bool): string =
412 | ## SQL dump hook to convert from bool.
413 | if v: "t"
414 | else: "f"
415 |
416 | proc sqlParseHook*(data: string, v: var bool) =
417 | ## SQL parse hook to convert to bool.
418 | v = data == "t"
419 |
420 | proc sqlDumpHook*(data: Bytes): string =
421 | ## Postgres-specific dump hook for binary data.
422 | let hexChars = "0123456789abcdef"
423 | var hexStr = "\\x"
424 | for ch in data.string:
425 | let code = ch.ord
426 | hexStr.add hexChars[code shr 4] # Dividing by 16
427 | hexStr.add hexChars[code and 0x0F] # Modulo operation with 16
428 | return hexStr
429 |
430 | proc sqlParseHook*(data: string, v: var Bytes) =
431 | ## Postgres-specific parse hook for binary data.
432 | if not (data.len >= 2 and data[0] == '\\' and data[1] == 'x'):
433 | raise newException(DbError, "Invalid binary representation" )
434 | var buffer = ""
435 | for i in countup(2, data.len - 1, 2): # Parse the hexadecimal characters two at a time
436 | let highNibble = hexNibble(data[i]) # Extract the high nibble
437 | let lowNibble = hexNibble(data[i + 1]) # Extract the low nibble
438 | let byte = (highNibble shl 4) or lowNibble # Convert the high and low nibbles to a byte
439 | buffer.add chr(byte) # Convert the byte to a character and append it to the result string
440 | v = buffer.Bytes
441 |
--------------------------------------------------------------------------------
/src/debby/sqlite.nim:
--------------------------------------------------------------------------------
1 | ## Public interface to you library.
2 |
3 | import std/strutils, std/tables, std/macros, std/typetraits, jsony, common,
4 | std/sets, std/strformat
5 |
6 | export jsony
7 | export common
8 |
9 | when defined(windows):
10 | when defined(cpu64):
11 | const Lib = "sqlite3_64.dll"
12 | else:
13 | const Lib = "sqlite3_32.dll"
14 | elif defined(macosx):
15 | const Lib = "libsqlite3(|.0).dylib"
16 | else:
17 | const Lib = "libsqlite3.so(|.0)"
18 |
19 | const
20 | SQLITE_OK* = 0
21 | SQLITE_ROW* = 100
22 |
23 | type
24 | Statement* = pointer
25 |
26 | {.push importc, cdecl, dynlib: Lib.}
27 |
28 | proc sqlite3_errmsg*(
29 | db: Db
30 | ): cstring
31 |
32 | proc sqlite3_open*(
33 | filename: cstring,
34 | db: var Db
35 | ): int32
36 |
37 | proc sqlite3_close*(
38 | db: Db
39 | ): int32
40 |
41 | proc sqlite3_prepare_v2*(
42 | db: Db,
43 | zSql: cstring,
44 | nByte: int32,
45 | pStatement: var Statement,
46 | pzTail: ptr cstring
47 | ): int32
48 |
49 | proc sqlite3_bind_text*(
50 | stmt: Statement,
51 | index: int32,
52 | text: cstring,
53 | size: int32,
54 | destructor: pointer
55 | ): int32
56 |
57 | proc sqlite3_column_bytes*(
58 | stmt: Statement,
59 | iCol: int32
60 | ): int32
61 |
62 | proc sqlite3_column_blob*(
63 | stmt: Statement,
64 | iCol: int32
65 | ): pointer
66 |
67 | proc sqlite3_column_count*(
68 | stmt: Statement
69 | ): int32
70 |
71 | proc sqlite3_step*(
72 | stmt: Statement
73 | ): int32
74 |
75 | proc sqlite3_finalize*(
76 | stmt: Statement
77 | ): int32
78 |
79 | proc sqlite3_column_name*(
80 | stmt: Statement,
81 | iCol: int32
82 | ): cstring
83 |
84 | proc sqlite3_last_insert_rowid*(
85 | db: Db
86 | ): int64
87 |
88 | {.pop.}
89 |
90 | proc sqlType(t: typedesc): string =
91 | ## Converts nim type to sql type.
92 | when t is string: "TEXT"
93 | elif t is Bytes: "BLOB"
94 | elif t is int8: "INTEGER"
95 | elif t is uint8: "INTEGER"
96 | elif t is int16: "INTEGER"
97 | elif t is uint16: "INTEGER"
98 | elif t is int32: "INTEGER"
99 | elif t is uint32: "INTEGER"
100 | elif t is int or t is int64: "INTEGER"
101 | elif t is uint or t is uint64: "TEXT"
102 | elif t is float or t is float32: "REAL"
103 | elif t is float64: "REAL"
104 | elif t is bool: "INTEGER"
105 | elif t is enum: "TEXT"
106 | else: "TEXT"
107 |
108 | proc dbError*(db: Db) {.noreturn.} =
109 | ## Raises an error from the database.
110 | raise newException(DbError, "SQLite: " & $sqlite3_errmsg(db))
111 |
112 | proc prepareQuery(
113 | db: Db,
114 | query: string,
115 | args: varargs[Argument, toArgument]
116 | ): Statement =
117 | ## Generates the query based on parameters.
118 |
119 | if query.count('?') != args.len:
120 | dbError("Number of arguments and number of ? in query does not match")
121 |
122 | if sqlite3_prepare_v2(db, query.cstring, query.len.cint, result, nil) != SQLITE_OK:
123 | dbError(db)
124 | for i, arg in args:
125 | if arg.value.len == 0:
126 | continue
127 | if sqlite3_bind_text(
128 | result,
129 | int32(i + 1),
130 | arg.value.cstring,
131 | arg.value.len.int32, nil
132 | ) != SQLITE_OK:
133 | dbError(db)
134 |
135 | proc readRow(statement: Statement, r: var Row, columnCount: int) =
136 | ## Reads a single row back.
137 | for column in 0 ..< columnCount:
138 | let sizeBytes = sqlite3_column_bytes(statement, column.cint)
139 | if sizeBytes > 0:
140 | r[column].setLen(sizeBytes) # set capacity
141 | copyMem(
142 | addr(r[column][0]),
143 | sqlite3_column_blob(statement, column.cint),
144 | sizeBytes
145 | )
146 |
147 | proc query*(
148 | db: Db,
149 | query: string,
150 | args: varargs[Argument, toArgument]
151 | ): seq[Row] {.discardable.} =
152 | ## Runs a query and returns the results.
153 | when defined(debbyShowSql):
154 | debugEcho(query)
155 | var statement = prepareQuery(db, query, args)
156 | var columnCount = sqlite3_column_count(statement)
157 | try:
158 | while sqlite3_step(statement) == SQLITE_ROW:
159 | var row = newSeq[string](columnCount)
160 | readRow(statement, row, columnCount)
161 | result.add(row)
162 | finally:
163 | if sqlite3_finalize(statement) != SQLITE_OK:
164 | dbError(db)
165 |
166 | proc openDatabase*(path: string): Db =
167 | ## Opens the database file.
168 | var db: Db
169 | if sqlite3_open(path, db) == SQLITE_OK:
170 | result = db
171 | else:
172 | dbError(db)
173 |
174 | proc close*(db: Db) =
175 | ## Closes the database file.
176 | if sqlite3_close(db) != SQLITE_OK:
177 | dbError(db)
178 |
179 | proc tableExists*[T](db: Db, t: typedesc[T]): bool =
180 | ## Checks if table exists.
181 | for x in db.query(
182 | "SELECT name FROM sqlite_master WHERE type='table' and name = ?",
183 | T.tableName
184 | ):
185 | result = x[0] == T.tableName
186 |
187 | proc createIndexStatement*[T: ref object](
188 | db: Db,
189 | t: typedesc[T],
190 | ifNotExists: bool,
191 | params: varargs[string]
192 | ): string =
193 | ## Returns the SQL code need to create an index.
194 | result.add "CREATE INDEX "
195 | if ifNotExists:
196 | result.add "IF NOT EXISTS "
197 | result.add "idx_"
198 | result.add T.tableName
199 | result.add "_"
200 | result.add params.join("_")
201 | result.add " ON "
202 | result.add T.tableName
203 | result.add "("
204 | result.add params.join(", ")
205 | result.add ")"
206 |
207 | proc createTableStatement*[T: ref object](db: Db, t: typedesc[T]): string =
208 | ## Given an object creates its table create statement.
209 | validateObj(T)
210 | let tmp = T()
211 | result.add "CREATE TABLE "
212 | result.add T.tableName
213 | result.add " (\n"
214 | for name, field in tmp[].fieldPairs:
215 | result.add " "
216 | result.add name.toSnakeCase
217 | result.add " "
218 | result.add sqlType(type(field))
219 | if name == "id":
220 | result.add " PRIMARY KEY"
221 | if type(field) is int:
222 | result.add " AUTOINCREMENT"
223 | result.add ",\n"
224 | result.removeSuffix(",\n")
225 | result.add "\n)"
226 |
227 | proc checkTable*[T: ref object](db: Db, t: typedesc[T]) =
228 | ## Checks to see if table matches the object.
229 | ## And recommends to create whole table or alter it.
230 | let tmp = T()
231 | var issues: seq[string]
232 |
233 | if not db.tableExists(T):
234 | when defined(debbyYOLO):
235 | db.createTable(T)
236 | else:
237 | issues.add "Table " & T.tableName & " does not exist."
238 | issues.add "Create it with:"
239 | issues.add db.createTableStatement(t)
240 | else:
241 | var tableSchema: Table[string, string]
242 | for x in db.query("PRAGMA table_info(" & T.tableName & ")"):
243 | let
244 | fieldName = x[1]
245 | fieldType = x[2]
246 | notNull {.used.} = x[3] == "1"
247 | defaultValue {.used.} = x[4]
248 | primaryKey {.used.} = x[5] == "1"
249 |
250 | tableSchema[fieldName] = fieldType
251 |
252 | for name, field in tmp[].fieldPairs:
253 | let fieldName = name.toSnakeCase
254 | let sqlType = sqlType(type(field))
255 | if fieldName.toSnakeCase in tableSchema:
256 | if tableSchema[fieldName.toSnakeCase ] == sqlType:
257 | discard # good everything matches
258 | else:
259 | issues.add "Field " & T.tableName & "." & fieldName & " expected type " & sqlType & " but got " & tableSchema[fieldName]
260 | # TODO create new table with right data
261 | # copy old data into new table
262 | # delete old table
263 | # rename new table
264 | else:
265 | let addFieldStatement = "ALTER TABLE " & T.tableName & " ADD COLUMN " & fieldName.toSnakeCase & " " & sqlType
266 | if defined(debbyYOLO):
267 | db.query(addFieldStatement)
268 | else:
269 | issues.add "Field " & T.tableName & "." & fieldName & " is missing"
270 | issues.add "Add it with:"
271 | issues.add addFieldStatement
272 |
273 | if issues.len != 0:
274 | issues.add "Or compile --d:debbyYOLO to do this automatically"
275 | raise newException(DBError, issues.join("\n"))
276 |
277 | proc insert*[T: ref object](db: Db, obj: T) =
278 | ## Inserts the object into the database.
279 | ## Reads the ID of the inserted ref object back.
280 | discard db.insertInner(obj)
281 | obj.id = db.sqlite3_last_insert_rowid().int
282 |
283 | proc query*[T](
284 | db: Db,
285 | t: typedesc[T],
286 | query: string,
287 | args: varargs[Argument, toArgument]
288 | ): seq[T] =
289 | ## Query the table, and returns results as a seq of ref objects.
290 | ## This will match fields to column names.
291 | ## This will also use JSONy for complex fields.
292 | when defined(debbyShowSql):
293 | debugEcho(query)
294 |
295 | let tmp = T()
296 |
297 | var
298 | statement = prepareQuery(db, query, args)
299 | columnCount = sqlite3_column_count(statement)
300 | headerIndex: seq[int]
301 | for i in 0 ..< columnCount:
302 | let columnName = $sqlite3_column_name(statement, i)
303 | var
304 | j = 0
305 | found = false
306 | for fieldName, field in tmp[].fieldPairs:
307 | if columnName == fieldName.toSnakeCase:
308 | found = true
309 | headerIndex.add(j)
310 | break
311 | inc j
312 | if not found:
313 | raise newException(
314 | DBError,
315 | "Can't map query to object, missing " & $columnName
316 | )
317 |
318 | try:
319 | while sqlite3_step(statement) == SQLITE_ROW:
320 | var row = newSeq[string](columnCount)
321 | readRow(statement, row, columnCount)
322 | let tmp = T()
323 | var i = 0
324 | for fieldName, field in tmp[].fieldPairs:
325 | sqlParse(row[headerIndex[i]], field)
326 | inc i
327 | result.add(tmp)
328 | finally:
329 | if sqlite3_finalize(statement) != SQLITE_OK:
330 | dbError(db)
331 |
332 | template withTransaction*(db: Db, body) =
333 | ## Transaction block.
334 |
335 | # Start a transaction
336 | discard db.query("BEGIN TRANSACTION;")
337 |
338 | try:
339 | body
340 |
341 | # Commit the transaction
342 | discard db.query("COMMIT;")
343 | except Exception as e:
344 | discard db.query("ROLLBACK;")
345 | raise e
346 |
347 | proc sqlDumpHook*(v: bool): string =
348 | ## SQL dump hook to convert from bool.
349 | if v: "1"
350 | else: "0"
351 |
352 | proc sqlParseHook*(data: string, v: var bool) =
353 | ## SQL parse hook to convert to bool.
354 | v = data == "1"
355 |
--------------------------------------------------------------------------------
/tests/common_test.nim:
--------------------------------------------------------------------------------
1 | import std/strutils, std/sequtils
2 |
3 | block:
4 | # Test the most basic operation:
5 | doAssert db.query("select 5") == @[@["5"]]
6 |
7 | block:
8 | let rows = db.query("select ?", "hello world")
9 | doAssert rows.len == 1
10 | let row = rows[0]
11 | doAssert row[0] == "hello world"
12 |
13 | block:
14 | let rows = db.query("select ?, ?, ?", "hello", " ", "world")
15 | doAssert rows.len == 1
16 | let row = rows[0]
17 | doAssert row[0] == "hello"
18 | doAssert row[1] == " "
19 | doAssert row[2] == "world"
20 |
21 | block:
22 | # Test fetching current date
23 | let currDate = db.query("select CURRENT_DATE")[0][0]
24 | for row in db.query("select ?", currDate):
25 | doAssert row[0] == currDate
26 |
27 | # Test with basic object
28 | type Auto = ref object
29 | id: int
30 | make: string
31 | model: string
32 | year: int
33 | truck: bool
34 |
35 | var vintageSportsCars = @[
36 | Auto(make: "Chevrolet", model: "Camaro Z28", year: 1970),
37 | Auto(make: "Porsche", model: "911 Carrera RS", year: 1973),
38 | Auto(make: "Lamborghini", model: "Countach", year: 1974),
39 | Auto(make: "Ferrari", model: "308 GTS", year: 1977),
40 | Auto(make: "Aston Martin", model: "V8 Vantage", year: 1977),
41 | Auto(make: "Datsun", model: "280ZX", year: 1980),
42 | Auto(make: "Ferrari", model: "Testarossa", year: 1984),
43 | Auto(make: "BMW", model: "M3", year: 1986),
44 | Auto(make: "Mazda", model: "RX-7", year: 1993),
45 | Auto(make: "Toyota", model: "Supra", year: 1998)
46 | ]
47 |
48 | db.dropTableIfExists(Auto)
49 | db.checkTable(Auto)
50 | db.insert(vintageSportsCars)
51 |
52 | block:
53 | # Test empty filter (all filter).
54 | doAssert db.filter(Auto).len == 10
55 |
56 | block:
57 | # Test simple filters >
58 | let cars = db.filter(Auto, it.year > 1990)
59 | doAssert cars.len == 2
60 |
61 | block:
62 | # Test simple filters with equals
63 | let model = "Countach"
64 | let cars = db.filter(Auto, it.model == model)
65 | doAssert cars.len == 1
66 |
67 | block:
68 | # Test filters with or
69 | let cars = db.filter(Auto, it.make == "Ferrari" or it.make == "Lamborghini")
70 | doAssert cars.len == 3
71 |
72 | block:
73 | # Test filter with <
74 | let cars = db.filter(Auto, it.year < 1980)
75 | doAssert cars.len == 5
76 |
77 | block:
78 | # Test filter with !=
79 | let model = "M3"
80 | let cars = db.filter(Auto, it.model != model)
81 | doAssert cars.len == 9
82 |
83 | block:
84 | # Test filter with multiple conditions
85 | let cars = db.filter(Auto, it.make == "Ferrari" and it.year > 1980)
86 | doAssert cars.len == 1
87 |
88 | block:
89 | # Test filters with complex or conditions
90 | let cars = db.filter(Auto, (it.make == "BMW" or it.make == "Toyota") and it.year < 2000)
91 | doAssert cars.len == 2
92 |
93 | block:
94 | # Test filters with complex and conditions
95 | let cars = db.filter(Auto, it.year < 1980 and (it.make == "Ferrari" or it.make == "Lamborghini"))
96 | doAssert cars.len == 2
97 |
98 | block:
99 | # Test filters with not
100 | let cars = db.filter(Auto, not(it.make == "Ferrari"))
101 | doAssert cars.len == 8
102 |
103 | block:
104 | # Test filter for cars made after 1990s with specific manufacturers
105 | let cars = db.filter(Auto, it.year > 1990 and (it.make == "BMW" or it.make == "Mazda" or it.make == "Toyota"))
106 | doAssert cars.len == 2
107 |
108 | block:
109 | # Test filter for cars with year in a specific range
110 | let cars = db.filter(Auto, it.year >= 1970 and it.year <= 1980)
111 | doAssert cars.len == 6
112 |
113 | block:
114 | # Test filter with combination of not and or
115 | let cars = db.filter(Auto, not(it.make == "BMW" or it.make == "Toyota"))
116 | doAssert cars.len == 8
117 |
118 | block:
119 | # Test filter with combination of not and and
120 | let cars = db.filter(Auto, not(it.make == "Ferrari" and it.year < 1980))
121 | doAssert cars.len == 9
122 |
123 | block:
124 | # Test update
125 | let startYear = 1970
126 | let endYear = 1980
127 | let cars = db.filter(Auto, it.year >= startYear and it.year < endYear)
128 | doAssert cars.len == 5
129 | db.update(cars)
130 |
131 | block:
132 | # Test filter with function call.
133 | proc startYear(): int = 1980
134 | let cars = db.filter(Auto, it.year >= startYear())
135 | doAssert cars.len == 5
136 |
137 | let cars2 = db.filter(Auto, it.year >= parseInt("1980"))
138 | doAssert cars2.len == 5
139 |
140 | let cars3 = db.filter(Auto, it.year >= parseInt("19" & "80"))
141 | doAssert cars3.len == 5
142 |
143 | block:
144 | # Test filter with invalid function call
145 | proc isOfYear(a: Auto): bool = a.year >= 1980
146 |
147 | let res = compiles:
148 | let cars = db.filter(Auto, it.isOfYear())
149 |
150 | doAssert not res, "`it` passed to function compiles when it shouldn't!"
151 |
152 | proc nest1(a: Auto): Auto = a
153 | proc nest2(a: Auto): bool = a.year >= 1980
154 | let res2 = compiles:
155 | let cars = db.filter(Auto, nest2(nest1(it)))
156 |
157 | doAssert not res, "`it` passed to a nested function compiles when it shouldn't!"
158 |
159 | block:
160 | # Test upsert
161 | vintageSportsCars.add Auto(
162 | make: "Jeep",
163 | model: "Wrangler",
164 | year: 1993,
165 | truck: true)
166 | db.upsert(vintageSportsCars)
167 | doAssert db.filter(Auto).len == 11
168 |
169 | let jeeps = db.filter(Auto, it.make == "Jeep" and it.model == "Wrangler")
170 | doAssert jeeps[0].truck == true
171 |
172 | block:
173 | # Test uint64 field
174 | type SteamPlayer = ref object
175 | id: int
176 | steamId: uint64
177 | name: string
178 |
179 | db.dropTableIfExists(SteamPlayer)
180 | db.createTable(SteamPlayer)
181 |
182 | db.insert(SteamPlayer(steamId: uint64.high, name:"Foo"))
183 |
184 | var steamPlayers = db.query(
185 | SteamPlayer,
186 | "select * from steam_player where steam_id = ?",
187 | uint64.high
188 | )
189 | doAssert steamPlayers[0].name == "Foo"
190 | doAssert steamPlayers[0].steamId == uint64.high
191 |
192 | steamPlayers[0].name = "NewName"
193 | db.update(steamPlayers[0])
194 |
195 | steamPlayers = db.query(
196 | SteamPlayer,
197 | "select * from steam_player where steam_id = ?",
198 | uint64.high
199 | )
200 | doAssert steamPlayers[0].name == "NewName"
201 | doAssert steamPlayers[0].steamId == uint64.high
202 |
203 | db.delete(steamPlayers[0])
204 | steamPlayers = db.query(
205 | SteamPlayer,
206 | "select * from steam_player where steam_id = ?",
207 | uint64.high
208 | )
209 | doAssert steamPlayers.len == 0
210 |
211 | block:
212 | # Test string field as main field.
213 | type Push = ref object
214 | id: int
215 | iden: string
216 | bodyText: string
217 |
218 | db.dropTableIfExists(Push)
219 | db.createTable(Push)
220 |
221 | db.insert(Push(iden: "uuid:XXXX", bodyText:"Hi, you there?"))
222 |
223 | var pushes = db.query(
224 | Push,
225 | "select * from push where iden = ?",
226 | "uuid:XXXX"
227 | )
228 | doAssert pushes[0].bodyText == "Hi, you there?"
229 | doAssert pushes[0].iden == "uuid:XXXX"
230 |
231 | pushes[0].bodyText = "new text"
232 | db.update(pushes)
233 |
234 | pushes = db.query(
235 | Push,
236 | "select * from push where iden = ?",
237 | "uuid:XXXX"
238 | )
239 | doAssert pushes[0].bodyText == "new text"
240 | db.delete(pushes)
241 |
242 | block:
243 | # Test read and write Binary data.
244 | type FileEntry = ref object
245 | id: int
246 | fileName: string
247 | data: Bytes
248 |
249 | db.dropTableIfExists(FileEntry)
250 | db.createTable(FileEntry)
251 |
252 | let zeroBin = "" & char(0x00)
253 | db.insert FileEntry(
254 | fileName: "zero.bin",
255 | data: zeroBin.Bytes
256 | )
257 | doAssert db.get(FileEntry, 1).data.string == zeroBin
258 |
259 | let unicodeBadBin = char(0xC3) & char(0x28)
260 | db.insert FileEntry(
261 | fileName: "unicodebad.bin",
262 | data: unicodeBadBin.Bytes
263 | )
264 | doAssert db.get(FileEntry, 2).data.string == unicodeBadBin
265 |
266 | block:
267 | # Test transactions
268 | type Payer = ref object
269 | id: int
270 | name: string
271 | db.dropTableIfExists(Payer)
272 | db.createTable(Payer)
273 |
274 | type Card = ref object
275 | id: int
276 | payerId: int
277 | number: string
278 | db.dropTableIfExists(Card)
279 | db.createTable(Card)
280 |
281 | db.withTransaction:
282 | let
283 | p1 = Payer(name:"Bar")
284 | p2 = Payer(name:"Baz")
285 | db.insert(p1)
286 | db.insert(p2)
287 | db.insert(Card(payerId:p1.id, number:"1234.1234"))
288 |
289 | block:
290 | # Test auto JSON fields.
291 | type
292 | Vec2 = object
293 | x: float32
294 | y: float32
295 | Money = distinct int64 # money in cents
296 |
297 | type Location = ref object
298 | id: int
299 | name: string
300 | revenue: Money
301 | position: Vec2
302 | items: seq[string]
303 | rating: float32
304 |
305 | db.dropTableIfExists(Location)
306 | db.createTable(Location)
307 |
308 | db.insert Location(
309 | name: "Super Cars",
310 | revenue: 1234.Money,
311 | position: Vec2(x:123, y:456),
312 | items: @["wrench", "door", "bathroom"],
313 | rating: 1.5
314 | )
315 |
316 | let loc = db.get(Location, 1)
317 | doAssert loc.name == "Super Cars"
318 | doAssert loc.revenue.int == (1234.Money).int
319 | doAssert loc.position == Vec2(x:123, y:456)
320 | doAssert loc.items == @["wrench", "door", "bathroom"]
321 | doAssert loc.rating == 1.5
322 |
323 | block:
324 | # Test invalid table creates.
325 |
326 | # id must always be there
327 | type BadTable1 = ref object
328 | iden: string
329 |
330 | db.dropTableIfExists(BadTable1)
331 | doAssertRaises(DbError):
332 | db.createTable(BadTable1)
333 |
334 | # id must always be integer
335 | type BadTable2 = ref object
336 | id: string
337 |
338 | db.dropTableIfExists(BadTable2)
339 | doAssertRaises(DbError):
340 | db.createTable(BadTable2)
341 |
342 | # can't use reserved words as table names
343 | type BadTable3 = ref object
344 | id: int
345 | select: int
346 | where: string
347 | group: float32
348 |
349 | db.dropTableIfExists(BadTable3)
350 | doAssertRaises(DbError):
351 | db.createTable(BadTable3)
352 |
353 | type User = ref object
354 | id: int
355 | name: string
356 |
357 | doAssertRaises(DbError):
358 | db.createTable(User)
359 |
360 | doAssertRaises(DbError):
361 | # Count ? of does not match arg count.
362 | db.query("select ?, ?", "hello world")
363 |
364 | doAssertRaises(DbError):
365 | # Count ? of does not match arg count.
366 | db.query("select ?", "hello", "world")
367 |
368 | # Test nested ?
369 | for row in db.query("select ?", "? ?"):
370 | doAssert row == @["? ?"]
371 |
372 | block:
373 | # Test compare with complex type.
374 | type
375 | Position = object
376 | lat, lon: float64
377 | Feature = ref object
378 | id: int
379 | name: string
380 | pos: Position
381 |
382 | db.dropTableIfExists(Feature)
383 | db.createTable(Feature)
384 |
385 | db.insert(Feature(name:"center", pos: Position(lat: 0, lon: 0)))
386 | db.insert(Feature(name:"off-center", pos: Position(lat: -1.2, lon: +3.14)))
387 |
388 | let rows = db.query(
389 | Feature,
390 | "SELECT * FROM feature WHERE pos = ?",
391 | Position(lat: 0, lon: 0)
392 | )
393 | doAssert rows.len == 1
394 | let row = rows[0]
395 | doAssert row.name == "center"
396 | doAssert row.pos.lat == 0
397 | doAssert row.pos.lon == 0
398 |
399 | block:
400 | # Test simple sqlDumpHook/sqlParseHook
401 | type
402 | Money = distinct int64
403 |
404 | type CheckEntry = ref object
405 | id: int
406 | toField: string
407 | money: Money
408 |
409 | proc sqlDumpHook(v: Money): string =
410 | result = "\"$" & $v.int64 & "USD\""
411 |
412 | proc sqlParseHook(data: string, v: var Money) =
413 | v = data[2..^5].parseInt().Money
414 |
415 | db.dropTableIfExists(CheckEntry)
416 | db.createTable(CheckEntry)
417 | db.checkTable(CheckEntry)
418 |
419 | db.insert CheckEntry(
420 | toField: "Super Cars",
421 | money: 1234.Money
422 | )
423 |
424 | let check = db.get(CheckEntry, 1)
425 | doAssert check.id == 1
426 | doAssert check.toField == "Super Cars"
427 | doAssert check.money.int == 1234.Money.int
428 |
429 | db.update(check)
430 | db.upsert(check)
431 | check.id = 0
432 | db.upsert(check)
433 | db.delete(check)
434 |
435 | block:
436 | # Test complex object with custom *binary* representation
437 | type
438 | Account = ref object
439 | id: int
440 | uid: UID
441 |
442 | UID = object
443 | timestamp*: int64
444 | randomness*: uint64
445 |
446 | func toArray[T](oa: openArray[T], size: static Slice[int]): array[size.len, T] =
447 | result[0.. 1990)
36 | doAssert cars.len == 2
37 |
--------------------------------------------------------------------------------
/tests/test_mysql.nim:
--------------------------------------------------------------------------------
1 | import debby/mysql
2 |
3 | let db = openDatabase(
4 | host = "127.0.0.1",
5 | user = "root",
6 | database = "test_db",
7 | password = "hunter2",
8 | port = 3306
9 | )
10 |
11 | include common_test
12 |
13 | db.close()
14 |
--------------------------------------------------------------------------------
/tests/test_mysql_pools.nim:
--------------------------------------------------------------------------------
1 | import debby/pools, debby/mysql
2 |
3 | block:
4 | let pool = newPool()
5 | for i in 0 ..< 5:
6 | pool.add openDatabase(
7 | host = "127.0.0.1",
8 | user = "root",
9 | database = "test_db",
10 | password = "hunter2",
11 | port = 3306
12 | )
13 |
14 | pool.withDb:
15 | doAssert db.query("select ?", 1) == @[@["1"]]
16 |
17 | proc threadFunc() =
18 | for i in 0 ..< 100:
19 | pool.withDb:
20 | doAssert db.query("select ?", i) == @[@[$i]]
21 |
22 | var threads: array[0 .. 9, Thread[void]]
23 | for i in 0..high(threads):
24 | createThread(threads[i], threadFunc)
25 |
26 | joinThreads(threads)
27 |
28 | pool.close()
29 |
30 | block:
31 | let pool = newPool()
32 | pool.add openDatabase(
33 | host = "127.0.0.1",
34 | user = "root",
35 | database = "test_db",
36 | password = "hunter2",
37 | port = 3306
38 | )
39 |
40 | type Counter = ref object
41 | id: int
42 | number: int
43 |
44 | pool.withDb:
45 | db.dropTableIfExists(Counter)
46 | db.createTable(Counter)
47 | var counter = Counter()
48 | db.insert(counter)
49 |
50 | const numTimes = 20
51 |
52 | proc threadFunc() =
53 | for i in 0 ..< numTimes:
54 | pool.withDb:
55 | let counter = db.get(Counter, 1)
56 | counter.number += 1
57 | db.update(counter)
58 |
59 | var threads: array[0 .. 2, Thread[void]]
60 | for i in 0..high(threads):
61 | createThread(threads[i], threadFunc)
62 |
63 | joinThreads(threads)
64 |
65 | pool.withDb:
66 | let counter = db.get(Counter, 1)
67 | doAssert counter.number == threads.len * numTimes
68 |
69 | pool.close()
70 |
71 | block:
72 | let pool = newPool()
73 | pool.add openDatabase(
74 | host = "127.0.0.1",
75 | user = "root",
76 | database = "test_db",
77 | password = "hunter2",
78 | port = 3306
79 | )
80 |
81 | # Test with basic object
82 | type Auto = ref object
83 | id: int
84 | make: string
85 | model: string
86 | year: int
87 |
88 | var vintageSportsCars = @[
89 | Auto(make: "Chevrolet", model: "Camaro Z28", year: 1970),
90 | Auto(make: "Porsche", model: "911 Carrera RS", year: 1973),
91 | Auto(make: "Lamborghini", model: "Countach", year: 1974),
92 | Auto(make: "Ferrari", model: "308 GTS", year: 1977),
93 | Auto(make: "Aston Martin", model: "V8 Vantage", year: 1977),
94 | Auto(make: "Datsun", model: "280ZX", year: 1980),
95 | Auto(make: "Ferrari", model: "Testarossa", year: 1984),
96 | Auto(make: "BMW", model: "M3", year: 1986),
97 | Auto(make: "Mazda", model: "RX-7", year: 1993),
98 | Auto(make: "Toyota", model: "Supra", year: 1998)
99 | ]
100 |
101 | pool.dropTableIfExists(Auto)
102 | pool.checkTable(Auto)
103 | pool.insert(vintageSportsCars)
104 | pool.update(vintageSportsCars)
105 | pool.upsert(vintageSportsCars)
106 | pool.delete(vintageSportsCars)
107 |
108 | let cars = pool.filter(Auto)
109 | doAssert cars.len == 0
110 |
111 | var sportsCar = Auto(make: "Jeep", model: "Wangler Sahara", year: 1993)
112 | pool.insert(sportsCar)
113 | pool.update(sportsCar)
114 | pool.upsert(sportsCar)
115 | pool.delete(sportsCar)
116 |
117 | let cars2 = pool.filter(Auto, it.year > 1990)
118 | doAssert cars2.len == 0
119 |
120 | let cars3 = pool.query(Auto, "SELECT * FROM auto")
121 | doAssert cars3.len == 0
122 |
123 | # Test raw insert and update
124 | discard pool.query("INSERT INTO auto (make, model, year) VALUES (?, ?, ?)", "Jeep", "Wangler Sahara", 1993)
125 | discard pool.query("UPDATE auto SET year = ? WHERE make = ?", 1994, "Jeep")
126 |
127 | pool.close()
128 |
--------------------------------------------------------------------------------
/tests/test_mysql_pools.nims:
--------------------------------------------------------------------------------
1 | --threads:on
2 | --mm:orc
3 |
--------------------------------------------------------------------------------
/tests/test_postgres.nim:
--------------------------------------------------------------------------------
1 | import debby/postgres
2 |
3 | let db = openDatabase(
4 | host = "localhost",
5 | user = "testuser",
6 | password = "test",
7 | database = "test"
8 | )
9 |
10 | include common_test
11 |
12 | block:
13 | # Test PG unique constraint
14 |
15 | type
16 | UniqueName = ref object
17 | id: int
18 | name: string
19 |
20 | db.dropTableIfExists(UniqueName)
21 | db.createTable(UniqueName)
22 |
23 | db.query("CREATE UNIQUE INDEX unique_name_idx ON unique_name (name)")
24 |
25 | db.insert(UniqueName(name: "hello"))
26 | doAssertRaises(DbError):
27 | db.insert(UniqueName(name: "hello"))
28 |
29 | block:
30 | # Test PG wrong type
31 |
32 | type
33 | WrongType = ref object
34 | id: int
35 | num: int
36 | name: string
37 |
38 | db.dropTableIfExists(WrongType)
39 | db.createTable(WrongType)
40 |
41 | doAssertRaises(DbError):
42 | db.query(
43 | "INSERT INTO wrong_type (num, name) VALUES (?,?)",
44 | "hello",
45 | 123
46 | )
47 |
48 | db.close()
49 |
--------------------------------------------------------------------------------
/tests/test_postgres_pools.nim:
--------------------------------------------------------------------------------
1 | import debby/pools, debby/postgres
2 |
3 | block:
4 | let pool = newPool()
5 | for i in 0 ..< 10:
6 | pool.add openDatabase(
7 | host = "localhost",
8 | user = "testuser",
9 | password = "test",
10 | database = "test"
11 | )
12 |
13 | pool.withDb:
14 | doAssert db.query("select ?", 1) == @[@["1"]]
15 |
16 | proc threadFunc() =
17 | for i in 0 ..< 100:
18 | pool.withDb:
19 | doAssert db.query("select ?", i) == @[@[$i]]
20 |
21 | var threads: array[0 .. 9, Thread[void]]
22 | for i in 0..high(threads):
23 | createThread(threads[i], threadFunc)
24 |
25 | joinThreads(threads)
26 |
27 | pool.close()
28 |
29 | block:
30 | let pool = newPool()
31 | pool.add openDatabase(
32 | host = "localhost",
33 | user = "testuser",
34 | password = "test",
35 | database = "test"
36 | )
37 |
38 | type Counter = ref object
39 | id: int
40 | number: int
41 |
42 | pool.withDb:
43 | db.dropTableIfExists(Counter)
44 | db.createTable(Counter)
45 | var counter = Counter()
46 | db.insert(counter)
47 |
48 | const numTimes = 20
49 |
50 | proc threadFunc() =
51 | for i in 0 ..< numTimes:
52 | pool.withDb:
53 | let counter = db.get(Counter, 1)
54 | counter.number += 1
55 | db.update(counter)
56 |
57 | var threads: array[0 .. 2, Thread[void]]
58 | for i in 0..high(threads):
59 | createThread(threads[i], threadFunc)
60 |
61 | joinThreads(threads)
62 |
63 | pool.withDb:
64 | let counter = db.get(Counter, 1)
65 | doAssert counter.number == threads.len * numTimes
66 |
67 | pool.close()
68 |
69 | block:
70 | let pool = newPool()
71 | pool.add openDatabase(
72 | host = "localhost",
73 | user = "testuser",
74 | password = "test",
75 | database = "test"
76 | )
77 |
78 | # Test with basic object
79 | type Auto = ref object
80 | id: int
81 | make: string
82 | model: string
83 | year: int
84 |
85 | var vintageSportsCars = @[
86 | Auto(make: "Chevrolet", model: "Camaro Z28", year: 1970),
87 | Auto(make: "Porsche", model: "911 Carrera RS", year: 1973),
88 | Auto(make: "Lamborghini", model: "Countach", year: 1974),
89 | Auto(make: "Ferrari", model: "308 GTS", year: 1977),
90 | Auto(make: "Aston Martin", model: "V8 Vantage", year: 1977),
91 | Auto(make: "Datsun", model: "280ZX", year: 1980),
92 | Auto(make: "Ferrari", model: "Testarossa", year: 1984),
93 | Auto(make: "BMW", model: "M3", year: 1986),
94 | Auto(make: "Mazda", model: "RX-7", year: 1993),
95 | Auto(make: "Toyota", model: "Supra", year: 1998)
96 | ]
97 |
98 | pool.dropTableIfExists(Auto)
99 | pool.checkTable(Auto)
100 | pool.insert(vintageSportsCars)
101 | pool.update(vintageSportsCars)
102 | pool.upsert(vintageSportsCars)
103 | pool.delete(vintageSportsCars)
104 |
105 | let cars = pool.filter(Auto)
106 | doAssert cars.len == 0
107 |
108 | var sportsCar = Auto(make: "Jeep", model: "Wangler Sahara", year: 1993)
109 | pool.insert(sportsCar)
110 | pool.update(sportsCar)
111 | pool.upsert(sportsCar)
112 | pool.delete(sportsCar)
113 |
114 | let cars2 = pool.filter(Auto, it.year > 1990)
115 | doAssert cars2.len == 0
116 |
117 | let cars3 = pool.query(Auto, "SELECT * FROM auto")
118 | doAssert cars3.len == 0
119 |
120 | # Test raw insert and update
121 | discard pool.query("INSERT INTO auto (make, model, year) VALUES (?, ?, ?)", "Jeep", "Wangler Sahara", 1993)
122 | discard pool.query("UPDATE auto SET year = ? WHERE make = ?", 1994, "Jeep")
123 |
124 | pool.close()
125 |
--------------------------------------------------------------------------------
/tests/test_postgres_pools.nims:
--------------------------------------------------------------------------------
1 | --threads:on
2 | --mm:orc
3 |
--------------------------------------------------------------------------------
/tests/test_sqlite.nim:
--------------------------------------------------------------------------------
1 | import debby/sqlite
2 |
3 | let db = openDatabase("tests/test.db")
4 |
5 | include common_test
6 |
7 | db.close()
8 |
--------------------------------------------------------------------------------
/tests/test_sqlite_pools.nim:
--------------------------------------------------------------------------------
1 | import debby/pools, debby/sqlite
2 |
3 | block:
4 | let pool = newPool()
5 | for i in 0 ..< 10:
6 | pool.add sqlite.openDatabase(
7 | path = "tests/test.db"
8 | )
9 |
10 | pool.withDb:
11 | doAssert db.query("select ?", 1) == @[@["1"]]
12 |
13 | proc threadFunc() =
14 | for i in 0 ..< 100:
15 | pool.withDb:
16 | doAssert db.query("select ?", i) == @[@[$i]]
17 |
18 | var threads: array[0 .. 9, Thread[void]]
19 | for i in 0..high(threads):
20 | createThread(threads[i], threadFunc)
21 |
22 | joinThreads(threads)
23 |
24 | pool.close()
25 |
26 | block:
27 | let pool = newPool()
28 | pool.add sqlite.openDatabase(
29 | path = "tests/test.db"
30 | )
31 |
32 | type Counter = ref object
33 | id: int
34 | number: int
35 |
36 | pool.withDb:
37 | db.dropTableIfExists(Counter)
38 | db.createTable(Counter)
39 | var counter = Counter()
40 | db.insert(counter)
41 |
42 | const numTimes = 20
43 |
44 | proc threadFunc() =
45 | for i in 0 ..< numTimes:
46 | pool.withDb:
47 | let counter = db.get(Counter, 1)
48 | counter.number += 1
49 | db.update(counter)
50 |
51 | var threads: array[0 .. 2, Thread[void]]
52 | for i in 0..high(threads):
53 | createThread(threads[i], threadFunc)
54 |
55 | joinThreads(threads)
56 |
57 | pool.withDb:
58 | let counter = db.get(Counter, 1)
59 | doAssert counter.number == threads.len * numTimes
60 |
61 | pool.close()
62 |
63 | block:
64 | let pool = newPool()
65 | pool.add sqlite.openDatabase(
66 | path = "tests/test.db"
67 | )
68 |
69 | # Test with basic object
70 | type Auto = ref object
71 | id: int
72 | make: string
73 | model: string
74 | year: int
75 |
76 | var vintageSportsCars = @[
77 | Auto(make: "Chevrolet", model: "Camaro Z28", year: 1970),
78 | Auto(make: "Porsche", model: "911 Carrera RS", year: 1973),
79 | Auto(make: "Lamborghini", model: "Countach", year: 1974),
80 | Auto(make: "Ferrari", model: "308 GTS", year: 1977),
81 | Auto(make: "Aston Martin", model: "V8 Vantage", year: 1977),
82 | Auto(make: "Datsun", model: "280ZX", year: 1980),
83 | Auto(make: "Ferrari", model: "Testarossa", year: 1984),
84 | Auto(make: "BMW", model: "M3", year: 1986),
85 | Auto(make: "Mazda", model: "RX-7", year: 1993),
86 | Auto(make: "Toyota", model: "Supra", year: 1998)
87 | ]
88 |
89 | pool.dropTableIfExists(Auto)
90 | pool.checkTable(Auto)
91 | pool.insert(vintageSportsCars)
92 | pool.update(vintageSportsCars)
93 | pool.upsert(vintageSportsCars)
94 | pool.delete(vintageSportsCars)
95 |
96 | let cars = pool.filter(Auto)
97 | doAssert cars.len == 0
98 |
99 | var sportsCar = Auto(make: "Jeep", model: "Wangler Sahara", year: 1993)
100 | pool.insert(sportsCar)
101 | pool.update(sportsCar)
102 | pool.upsert(sportsCar)
103 | pool.delete(sportsCar)
104 |
105 | let cars2 = pool.filter(Auto, it.year > 1990)
106 | doAssert cars2.len == 0
107 |
108 | let cars3 = pool.query(Auto, "SELECT * FROM auto")
109 | doAssert cars3.len == 0
110 |
111 | # Test raw insert and update
112 | discard pool.query("INSERT INTO auto (make, model, year) VALUES (?, ?, ?)", "Jeep", "Wangler Sahara", 1993)
113 | discard pool.query("UPDATE auto SET year = ? WHERE make = ?", 1994, "Jeep")
114 |
115 | pool.close()
116 |
--------------------------------------------------------------------------------
/tests/test_sqlite_pools.nims:
--------------------------------------------------------------------------------
1 | --threads:on
2 | --mm:orc
3 |
--------------------------------------------------------------------------------
/tests/varargs.nim:
--------------------------------------------------------------------------------
1 | import std/strutils, jsony
2 |
3 | type Argument = object
4 | kind*: string
5 | value*: string
6 |
7 | proc toArgument[T](v: T): Argument =
8 | result.kind = $T
9 | result.value = v.toJson()
10 |
11 | proc takesVarargs(args: varargs[Argument, toArgument]) =
12 | for arg in args:
13 | echo arg.kind, ":", arg.value
14 |
15 | takesVarArgs("hi", "how are you?", 1)
16 |
--------------------------------------------------------------------------------