├── .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 | ![Github Actions](https://github.com/treeform/debby/workflows/Github%20Actions/badge.svg) 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 &"" 44 | x.add "" 45 | x.add "
" & objName & "
" 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 "" 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 &"" 75 | else: 76 | x.add "" 77 | x.add "" 78 | x.add "
" & fieldName & "
" & $value & "" & $value & "
" 79 | 80 | x.add "
" 81 | x.add "
" 82 | x.add "" 83 | 84 | x.add HtmlFooter 85 | 86 | var headers: HttpHeaders 87 | headers["Content-Type"] = "text/html" 88 | request.respond(200, headers, x) 89 | 90 | proc itemHandler[T](request: Request) = 91 | # Generate the HTML for specific object. 92 | var x = HtmlHeader 93 | let id = request.uri.rsplit("/", maxSplit = 1)[^1].parseInt() 94 | let obj = pool.get(T, id) 95 | 96 | x.add &"

{$T}

" 97 | 98 | x.add "

" 99 | x.add "admin » " 100 | x.add "" & $T & " » " 101 | x.add $obj.id 102 | x.add "

" 103 | 104 | x.add "" 105 | x.add "" 106 | for fieldName, value in obj[].fieldPairs: 107 | x.add "" 108 | x.add "" 109 | if fieldName == "id": 110 | x.add "" 111 | elif type(value) isnot string: 112 | x.add "" 113 | else: 114 | x.add "" 115 | x.add "" 116 | x.add "
" & fieldName & "" & $value & "
" 117 | 118 | x.add "
" 119 | x.add " " 120 | 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 "" 166 | for fieldName, value in obj[].fieldPairs: 167 | x.add "" 168 | x.add "" 169 | if fieldName == "id": 170 | x.add "" 171 | elif type(value) isnot string: 172 | x.add "" 173 | else: 174 | x.add "" 175 | x.add "" 176 | x.add "
" & fieldName & "new
" 177 | 178 | x.add "
" 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 "" 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 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 | --------------------------------------------------------------------------------