├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── igor.go ├── igor_private.go ├── igor_test.go ├── json.go ├── notifications.go └── types.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [galeone] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Setup PostgreSQL and test igor 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | test: 12 | services: 13 | postgres: 14 | # Docker Hub image 15 | image: postgres:14.5 16 | # Provide the password for postgres 17 | env: 18 | POSTGRES_PASSWORD: pass 19 | # Set health checks to wait until postgres has started 20 | options: >- 21 | --health-cmd pg_isready 22 | --health-interval 10s 23 | --health-timeout 5s 24 | --health-retries 5 25 | ports: 26 | # Maps tcp port 5432 on service container to the host 27 | - 5432:5432 28 | 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v3 32 | - name: Setup Go environment 33 | uses: actions/setup-go@v3 34 | with: 35 | go-version-file: 'go.mod' 36 | - name: build 37 | run: go build -v ./... 38 | - name: test 39 | env: 40 | POSTGRES_HOST: localhost 41 | run: | 42 | export PGPASSWORD=pass 43 | psql -h localhost -p 5432 -c "CREATE ROLE igor WITH LOGIN PASSWORD 'igor';" -U postgres 44 | psql -h localhost -p 5432 -c 'CREATE DATABASE igor OWNER igor;' -U postgres 45 | go test -parallel 10 -v ./... 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | cover.* 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License, Version 2.0 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ 2 | 3 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 4 | 5 | 1. Definitions. 6 | 7 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 8 | 9 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 10 | 11 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 12 | 13 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 14 | 15 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 16 | 17 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 18 | 19 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 20 | 21 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 22 | 23 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 24 | 25 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 26 | 27 | 2. Grant of Copyright License. 28 | 29 | Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 30 | 31 | 3. Grant of Patent License. 32 | 33 | Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 34 | 35 | 4. Redistribution. 36 | 37 | You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 38 | 39 | You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 40 | 41 | 5. Submission of Contributions. 42 | 43 | Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 44 | 45 | 6. Trademarks. 46 | 47 | This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 48 | 49 | 7. Disclaimer of Warranty. 50 | 51 | Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 52 | 53 | 8. Limitation of Liability. 54 | 55 | In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 56 | 57 | 9. Accepting Warranty or Additional Liability. 58 | 59 | While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 60 | 61 | END OF TERMS AND CONDITIONS 62 | 63 | APPENDIX: How to apply the Apache License to your work 64 | 65 | To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. 66 | 67 | Copyright [yyyy] [name of copyright owner] 68 | 69 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at 70 | 71 | http://www.apache.org/licenses/LICENSE-2.0 72 | 73 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # igor 2 | igor is an abstraction layer for PostgreSQL, written in Go. Igor syntax is (almost) compatible with [GORM](https://github.com/jinzhu/gorm "The fantastic ORM library for Golang, aims to be developer friendly"). 3 | 4 | [![GoDoc](https://godoc.org/github.com/galeone/igor?status.svg)](https://godoc.org/github.com/galeone/igor) 5 | [![Build Status](https://travis-ci.org/galeone/igor.svg?branch=master)](https://travis-ci.org/galeone/igor) 6 | 7 | ## When to use igor 8 | You should use igor when your DBMS is PostgreSQL and you want to place an abstraction layer on top of it and do CRUD operations in a smart, easy, secure and fast way. 9 | 10 | Thus with igor you __do not__ create a new schema. In general igor does not support DDL (you can do it with the `Raw` and `Exec`, but there are not method created ad-hoc for this purpose). 11 | 12 | ## What igor does 13 | - Always uses prepared statements: no sql injection and good performance. 14 | - Supports transactions 15 | - Supports PostgreSQL JSON and JSONB types with `igor.JSON` 16 | - Supports PostgreSQL [LISTEN/NOTIFY](http://www.postgresql.org/docs/current/static/sql-notify.html) 17 | - Uses a GORM like syntax 18 | - Uses the same logic in insertion and update: handle default values in a coherent manner 19 | - Uses GORM models and conventions (partially, see [Differences](#differences)) 20 | - Exploits PostgreSQL `RETURNING` statement to update models fields with the updated values (even when changed on db side; e.g. when having a default value) 21 | - Automatically handle reserved keywords when used as a table name or fields. Does not quote every field (that's not recommended) but only the ones conflicting with a reserved keyword. 22 | 23 | 24 | ## What igor is not 25 | - An ORM (and thus a complete GORM replacement): 26 | - Does not support associations 27 | - Does not support callbacks 28 | - Does not have any specific method for data migration and DDL operations 29 | - Does not support soft delete 30 | 31 | ## Install 32 | ```go 33 | go get -u github.com/galeone/igor 34 | ``` 35 | 36 | ## GORM compatible 37 | igor uses the same syntax of GORM. Thus in a great number of cases you can replace GORM with igor by only changing the import path. 38 | 39 | __Warning__: igor is not a complete GORM replacement. See the [Differences](#differences). 40 | 41 | ## Model definition 42 | Models are the [same used in GORM.](http://jinzhu.me/gorm/models.html#model-definition) 43 | 44 | The main differences are: 45 | 46 | - Igor does not handle associations. Thus, if you have a field that refers to another table, disable it with the annotation `sql:"-"` (see the code below). 47 | - Every model __must__ implement the `igor.DBTable` interface. Therefore every model must have the method `TableName() string`, that returns the table name associated with the model. 48 | - Every model __must__ explicit the primary key field (using the tag `igor:"primary_key"`). 49 | - Since igor does not deal with DDL, `sql:"type:"` is ignored. 50 | 51 | Like: 52 | 53 | ```go 54 | type User struct { 55 | Counter uint64 `igor:"primary_key"` 56 | Username string 57 | Password string 58 | Name string 59 | Surname string 60 | Profile Profile `sql:"-"` 61 | } 62 | 63 | type (User) TableName() string { 64 | return "users" 65 | } 66 | ``` 67 | 68 | ### Array support 69 | 70 | igor supports PostgreSQL fields natively, without the need to use the `pg.Array` type - you can use just plain structs. 71 | 72 | ```go 73 | type NestMe struct { 74 | ID int64 `igor:"primary_key"` 75 | SliceOfString []string 76 | SliceOfInt64 []int64 77 | } 78 | ``` 79 | 80 | This structure maps this table definition: 81 | 82 | ```sql 83 | CREATE TABLE nest_table( 84 | id bigserial not null PRIMARY KEY, 85 | slice_of_string text[] not null, 86 | slice_of_int64 bigint[] not null 87 | ) 88 | ``` 89 | 90 | ### Nested types support 91 | 92 | igor allows you to embed types, and overwrite fields of the inner type. In particular, you can add the `sql` decorator (or change type, potentially). 93 | 94 | 95 | ```go 96 | type NestMe struct { 97 | ID int64 `igor:"primary_key"` 98 | OverwriteMe int64 99 | SliceOfString []string 100 | SliceOfInt64 []int64 101 | } 102 | 103 | type NestTable struct { 104 | NestMe 105 | OverwriteMe int64 `sql:"-"` 106 | } 107 | func (NestTable) TableName() string { 108 | return "nest_table" 109 | } 110 | ``` 111 | 112 | The `NestTable` type disables the SQL generation for the field `OverwriteMe` that's present in the embedded type `NestMe`. 113 | 114 | ## Methods 115 | - [igor](#igor) 116 | - [When to use igor](#when-to-use-igor) 117 | - [What igor does](#what-igor-does) 118 | - [What igor is not](#what-igor-is-not) 119 | - [Install](#install) 120 | - [GORM compatible](#gorm-compatible) 121 | - [Model definition](#model-definition) 122 | - [Array support](#array-support) 123 | - [Nested types support](#nested-types-support) 124 | - [Methods](#methods) 125 | - [Connect](#connect) 126 | - [Log](#log) 127 | - [Model](#model) 128 | - [Joins](#joins) 129 | - [Table](#table) 130 | - [CTE](#cte) 131 | - [Select](#select) 132 | - [Where](#where) 133 | - [Create](#create) 134 | - [Delete](#delete) 135 | - [Updates](#updates) 136 | - [Pluck](#pluck) 137 | - [Count](#count) 138 | - [First](#first) 139 | - [Scan](#scan) 140 | - [Raw](#raw) 141 | - [Exec](#exec) 142 | - [Where](#where-1) 143 | - [Limit](#limit) 144 | - [Offset](#offset) 145 | - [Order](#order) 146 | - [DB](#db) 147 | - [Begin](#begin) 148 | - [Commit](#commit) 149 | - [Rollback](#rollback) 150 | - [Listen](#listen) 151 | - [Unlisten](#unlisten) 152 | - [UnlistenAll](#unlistenall) 153 | - [Notify](#notify) 154 | - [Differences](#differences) 155 | - [Select and Where call order](#select-and-where-call-order) 156 | - [Models](#models) 157 | - [Open method](#open-method) 158 | - [Logger](#logger) 159 | - [Methods return value](#methods-return-value) 160 | - [Scan and Find methods](#scan-and-find-methods) 161 | - [Scan](#scan-1) 162 | - [Delete](#delete-1) 163 | - [First](#first-1) 164 | - [Other](#other) 165 | - [JSON and JSONB support](#json-and-jsonb-support) 166 | - [LISTEN / NOTIFY support](#listen--notify-support) 167 | - [Contributing](#contributing) 168 | - [Testing](#testing) 169 | - [License](#license) 170 | - [About the author](#about-the-author) 171 | 172 | ### Connect 173 | ```go 174 | import "github.com/galeone/igor" 175 | 176 | func main() { 177 | db, err := igor.Connect("user=galeone dbname=igor sslmode=disable") 178 | } 179 | ``` 180 | 181 | ### Log 182 | See: [Logger](#logger). 183 | 184 | ### Model 185 | `Model(DBModel)` sets the table name for the current query 186 | 187 | ```go 188 | var logged bool 189 | var counter uint64 190 | 191 | db.Model(User{}).Select("login(?, ?) AS logged, counter", username, password).Where("LOWER(username) = ?", username).Scan(&logged, &counter); 192 | ``` 193 | 194 | it generates: 195 | ```sql 196 | SELECT login($1, $2) AS logged, counter FROM users WHERE LOWER(username) = $3 ; 197 | ``` 198 | 199 | ### Joins 200 | Joins append the join string to the current model 201 | 202 | ```go 203 | type Post struct { 204 | Hpid uint64 `igor:"primary_key"` 205 | From uint64 206 | To uint64 207 | Pid uint64 `sql:"default:0"` 208 | Message string 209 | Time time.Time `sql:"default:(now() at time zone 'utc')"` 210 | Lang string 211 | News bool 212 | Closed bool 213 | } 214 | 215 | type UserPost struct { 216 | Post 217 | } 218 | 219 | func (UserPost) TableName() string { 220 | return "posts" 221 | } 222 | 223 | users := new(User).TableName() 224 | posts := new(UserPost).TableName() 225 | 226 | var userPosts []UserPost 227 | db.Model(UserPost{}).Order("hpid DESC"). 228 | Joins("JOIN "+users+" ON "+users+".counter = "+posts+".to"). 229 | Where(`"to" = ?`, user.Counter).Scan(&userPost) 230 | ``` 231 | 232 | it generates: 233 | ```sql 234 | SELECT posts.hpid,posts."from",posts."to",posts.pid,posts.message,posts."time",posts.lang,posts.news,posts.closed 235 | FROM posts 236 | JOIN users ON users.counter = posts.to 237 | WHERE "to" = $1 238 | ``` 239 | 240 | ### Table 241 | Table appends the table string to FROM. It has the same behavior of Model, but passing the table name directly as a string 242 | 243 | See example in [Joins](#joins) 244 | 245 | ### CTE 246 | CTE allows to define a Common Table Expression that precedes the query. 247 | 248 | __Warning__: use it with the [Table](#table) method. 249 | 250 | ```go 251 | var usernames []string 252 | var ids []uint64 // fill them - not the usage of = any since this is converted to a pq.Array 253 | 254 | db.CTE(`WITH full_users_id AS ( 255 | SELECT counter FROM users WHERE name = ? AND counter = any(?))`, "Paolo", ids). 256 | Table("full_users_id as fui"). 257 | Select("username"). 258 | Joins("JOIN users ON fui.counter = users.counter").Scan(&usernames) 259 | ``` 260 | 261 | it generates: 262 | ```sql 263 | WITH full_users_id AS ( 264 | SELECT counter FROM users WHERE name = $1 AND counter = any($2) 265 | ) 266 | SELECT username FROM full_users_id as fui JOIN users ON fui.counter = users.counter; 267 | ``` 268 | 269 | ### Select 270 | Select sets the fields to retrieve. Appends fields to SELECT (See example in [Model](#model)). 271 | 272 | When select is not specified, every field is selected in the Model order (See example in [Joins](#joins)). 273 | 274 | __Warning__: calling `Select` using parameters without type is allowed only if the stored procedure on the DBMS define the type. 275 | 276 | Eg: if we have a function on PostgreSQL that accepts two parameters like 277 | ```pgsql 278 | login(_username text, _pass text, OUT ret boolean) RETURNS boolean 279 | ``` 280 | we can call this function in that way 281 | 282 | ```go 283 | db.Select('login(?,?)', username, password) 284 | ``` 285 | 286 | But, if the DBMS can't infer the parameters (in every other case except the one previous mentioned), we __must__ make parameters type explicit. 287 | 288 | This is due to the use of prepared statements. 289 | 290 | ```go 291 | db.Select("?::int, ?::int, ?::int", 1, 2, 3) 292 | ``` 293 | 294 | ### Where 295 | Where works with `DBModel`s or strings. 296 | 297 | When using a `DBModel`, if the primary key fields is not blank, the query will generate a where clause in the form: 298 | 299 | Thus: 300 | 301 | ```go 302 | db.Model(UserPost{}).Where(&UserPost{Hpid: 1, From:1, To:1}) 303 | ``` 304 | 305 | it generates: 306 | 307 | ```sql 308 | SELECT posts.hpid,posts."from",posts."to",posts.pid,posts.message,posts."time",posts.lang,posts.news,posts.closed 309 | FROM posts 310 | WHERE posts.hpid = $1 311 | ``` 312 | 313 | Ignoring values that are not primary keys. 314 | 315 | If the primary key field is blank, generates the where clause `AND`ing the conditions: 316 | 317 | ```go 318 | db.Model(UserPost{}).Where(&UserPost{From:1, To:1}) 319 | ``` 320 | 321 | The conditions will be: 322 | 323 | ```sql 324 | WHERE posts.from = $1 AND posts.to = $2 325 | ``` 326 | 327 | When using a string, you can use the `?` as placeholder for parameters substitution. Thus 328 | 329 | ```go 330 | db.Model(UserPost{}).Where(`"to" = ?`, user.Counter) 331 | ``` 332 | 333 | it generates: 334 | 335 | ```sql 336 | SELECT posts.hpid,posts."from",posts."to",posts.pid,posts.message,posts."time",posts.lang,posts.news,posts.closed 337 | FROM posts 338 | WHERE "to" = $1 339 | ``` 340 | 341 | Where supports slices as well: 342 | 343 | ```go 344 | db.Model(UserPost{}).Where(`"to" IN (?) OR "from" = ?`, []uint64{1,2,3,4,6}, 88) 345 | ``` 346 | 347 | it generates: 348 | 349 | ```sql 350 | SELECT posts.hpid,posts."from",posts."to",posts.pid,posts.message,posts."time",posts.lang,posts.news,posts.closed 351 | FROM posts 352 | WHERE "to" IN ($1,$2,$3,$4,$5) OR "from" = $6 353 | ``` 354 | 355 | ### Create 356 | Create `INSERT` a new row into the table specified by the DBModel. 357 | 358 | `Create` handles default values using the following rules: 359 | 360 | If a field is blank and has a default value and this default value is the Go Zero value for that field, igor does not generate the query part associated with the insertion of that fields (let the DBMS handle the default value generation). 361 | 362 | If a field is blank and has a default value that's different from the Go Zero value for that filed, insert the specified default value. 363 | 364 | Create exploits the `RETURNING` clause of PostgreSQL to fetch the new row and update the DBModel passed as argument. 365 | 366 | In that way igor always have the up-to-date fields of DBModel. 367 | 368 | ```go 369 | post := &UserPost{ 370 | From: 1, 371 | To: 1, 372 | Pid: 10, 373 | Message: "hi", 374 | Lang: "en", 375 | } 376 | db.Create(post) 377 | ``` 378 | 379 | it generates: 380 | 381 | ```sql 382 | INSERT INTO posts("from","to",pid,message,lang) VALUES ($1,$2,$3,$4,$5) RETURNING posts.hpid,posts."from",posts."to",posts.pid,posts.message,posts."time",posts.lang,posts.news,posts.closed; 383 | ``` 384 | 385 | The resulting row (the result of `RETURNING`) is used as a source for the `Scan` method, having the DBModel as argument. 386 | 387 | Thus, in the example, the variable post.Time has the `(now() at time zone 'utc')` evaluation result value. 388 | 389 | ### Delete 390 | 391 | See [Delete](#delete-1) 392 | 393 | ### Updates 394 | 395 | Updates uses the same logic of [Create](#create) (thus the default value handling is the same). 396 | 397 | The only difference is that Updates `UPDATE` rows. 398 | 399 | `Update` tries to infer the table name from the DBModel passed as argument __if__ a `Where` clause has not been specified. Otherwise uses the `Where` clause to generate the `WHERE` part and the Model to generate the `field = $n` part. 400 | 401 | ```go 402 | var user User 403 | db.First(&user, 1) // handle errors 404 | user.Username = "username changed" 405 | 406 | db.Updates(&user) 407 | ``` 408 | 409 | it generates: 410 | 411 | ```sql 412 | UPDATE users SET users.username = "username changed" WHERE users.counter = 1 RETURNING users.counter,users.last,users.notify_story,users.private,users.lang,users.username,users.email,users.name,users.surname,users.gender,users.birth_date,users.board_lang,users.timezone,users.viewonline,users.registration_time 413 | ``` 414 | 415 | The `RETURNING` clause is handled in the same manner of [Create](#create). 416 | 417 | ### Pluck 418 | Pluck fills the slice with the query result. 419 | It calls `Scan` internally, thus the slice can be a slice of structures or a slice of simple types. 420 | 421 | It panics if slice is not a slice or the query is not well formulated. 422 | 423 | ```go 424 | type Blacklist struct { 425 | From uint64 426 | To uint64 427 | Motivation string 428 | Time time.Time `sql:"default:(now() at time zone 'utc')"` 429 | Counter uint64 `igor:"primary_key"` 430 | } 431 | 432 | func (Blacklist) TableName() string { 433 | return "blacklist" 434 | } 435 | 436 | var blacklist []uint64 437 | db.Model(Blacklist{}).Where(&Blacklist{From: user.Counter}).Pluck(`"to"`, &blacklist) 438 | ``` 439 | 440 | it generates 441 | 442 | ```sql 443 | SELECT "to" FROM blacklist WHERE blacklist."from" = $1 444 | ``` 445 | 446 | ### Count 447 | Count sets the query result to be count(*) and scan the result into value. 448 | 449 | ```go 450 | var count int 451 | db.Model(Blacklist{}).Where(&Blacklist{From: user.Counter}).Count(&count 452 | ``` 453 | 454 | it generates: 455 | 456 | ```sql 457 | SELECT COUNT(*) FROM blacklist WHERE blacklist."from" = $1 458 | ``` 459 | 460 | ### First 461 | 462 | See [First](#first-1) 463 | 464 | ### Scan 465 | 466 | See [Scan and Find methods](#scan-and-find-methods) 467 | 468 | ### Raw 469 | 470 | Prepares and executes a raw query, the results is available for the Scan method. 471 | 472 | See [Scan and Find methods](#scan-and-find-methods) 473 | 474 | ### Exec 475 | 476 | Prepares and executes a raw query, the results is discarded. Useful when you don't need the query result or the operation have no result. 477 | 478 | ```go 479 | tx := db.Begin() 480 | tx.Exec("DROP TABLE IF EXISTS users") 481 | tx.Exec(`CREATE TABLE users ( 482 | counter bigint NOT NULL, 483 | last timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, 484 | notify_story jsonb, 485 | private boolean DEFAULT false NOT NULL, 486 | lang character varying(2) DEFAULT 'en'::character varying NOT NULL, 487 | username character varying(90) NOT NULL, 488 | password character varying(60) NOT NULL, 489 | name character varying(60) NOT NULL, 490 | surname character varying(60) NOT NULL, 491 | email character varying(350) NOT NULL, 492 | gender boolean NOT NULL, 493 | birth_date date NOT NULL, 494 | board_lang character varying(2) DEFAULT 'en'::character varying NOT NULL, 495 | timezone character varying(35) DEFAULT 'UTC'::character varying NOT NULL, 496 | viewonline boolean DEFAULT true NOT NULL, 497 | remote_addr inet DEFAULT '127.0.0.1'::inet NOT NULL, 498 | http_user_agent text DEFAULT ''::text NOT NULL, 499 | registration_time timestamp(0) with time zone DEFAULT now() NOT NULL 500 | )`) 501 | tx.Commit() 502 | ``` 503 | 504 | `Exec` does not use prepared statements if there are no parameters to replace in the query. This make it possible to use a single call to `Exec` to execute multiple statements `;`-terminated. e.g. 505 | 506 | ```go 507 | tx.Exec(`DROP TABLE IF EXISTS users; 508 | CREATE TABLE users ( 509 | counter bigint NOT NULL, 510 | ... 511 | )`) 512 | tx.Commit() 513 | ``` 514 | 515 | ### Where 516 | Where builds the WHERE clause. 517 | 518 | If a primary key is present in the struct passed as argument only that field is used. 519 | 520 | ```go 521 | user.Counter = 2 522 | user.Name = "paolo" 523 | db.Select("username").Where(&user) 524 | ``` 525 | 526 | it generates: 527 | 528 | ```sql 529 | SELECT username FROM users WHERE users.counter = $1 530 | ``` 531 | 532 | because `Counter` is the primary key. 533 | 534 | If the primary key is blank every non empty field is and-end. 535 | 536 | ```go 537 | user.Counter = 0 // 0 is a blank primary key 538 | ``` 539 | 540 | it generates 541 | 542 | ```sql 543 | SELECT username FROM users WHERE users.name = $1 544 | ``` 545 | 546 | You can use a string to build the where clause and pass parameters if needed. 547 | 548 | ```go 549 | db.Model(User{}).Select("username").Where("counter IN (?) AND name ILIKE ?",[]uint64{1,2,4,5}, "nino") 550 | 551 | ``` 552 | 553 | it generates: 554 | 555 | ```sql 556 | SELECT username FROM users WHERE counter in ($1,$2,$3,$4) AND name ILIKE $5 557 | ``` 558 | 559 | If a where condition can't be generated, Where panics 560 | 561 | ### Limit 562 | Limit sets the LIMIT value to the query 563 | 564 | ### Offset 565 | Offset sets the OFFSET value to the query 566 | 567 | ### Order 568 | Order sets the ORDER BY value to the query 569 | 570 | ### DB 571 | DB returns the current `*sql.DB`. It panics if called during a transaction 572 | 573 | ### Begin 574 | Begin initialize a transaction. It panics if begin has been already called. 575 | 576 | Il returns a `*igor.Database`, thus you can use every other `*Database` method on the returned value. 577 | 578 | ```go 579 | tx := db.Begin() 580 | ``` 581 | 582 | ### Commit 583 | Commit commits the transaction. It panics if the transaction is not started (you have to call Begin before) 584 | 585 | ```go 586 | tx.Create(&user) 587 | tx.Commit() 588 | // Now you can use the db variable again 589 | ``` 590 | 591 | ### Rollback 592 | Rollback rollbacks the transaction. It panics if the transaction is not started (you have to call Begin before 593 | 594 | ```go 595 | if e := tx.Create(&user); e != nil { 596 | tx.Rollback() 597 | } else { 598 | tx.Commit() 599 | } 600 | // Now you can use the db variable again 601 | 602 | ``` 603 | 604 | ### Listen 605 | Listen executes `LISTEN channel`. Uses f to handle received notifications on channel. 606 | 607 | ```go 608 | if e := db.Listen("notification_channel", func(payload ...string) { 609 | if len(payload) > 0 { 610 | pl := strings.Join(payload, ",") 611 | fmt.Println("Received notification on channel notification_channel, having payload: " + pl) 612 | } else { 613 | fmt.Println("Received notification on channel notification_channel without payload") 614 | } 615 | }); e != nil { 616 | // handle error 617 | } 618 | ``` 619 | 620 | ### Unlisten 621 | Unlisten executes`UNLISTEN channel`. Unregister function f, that was registered with Listen(channel ,f). 622 | 623 | ```go 624 | e := db.Unlisten("notification_channel") 625 | // handle error 626 | ``` 627 | 628 | You can unlisten from every channel calling `db.Unlisten("*")` or using the method `UnlistenAll` 629 | 630 | ### UnlistenAll 631 | UnlistenAll executes `UNLISTEN *`. Thus do not receive any notification from any channel. 632 | 633 | ### Notify 634 | With Notify you can send a notification with or without payload on a channel. 635 | 636 | ```go 637 | e = db.Notify("notification_channel") // empty payload 638 | e = db.Notify("notification_channel", "payload 1", "payload 2", "test") 639 | ``` 640 | 641 | When sending a payload, the strings are joined together. Therefore the payload sent with previous call to `Notify` is: `payload 1, payload 2, test` 642 | 643 | ## Differences 644 | 645 | ### Select and Where call order 646 | In GORM, you can execute 647 | ```go 648 | db.Model(User{}).Select("username") 649 | ``` 650 | 651 | ```go 652 | db.Select("username").Model(User{}) 653 | ``` 654 | 655 | and achieve the same result. 656 | 657 | In igor this is not possible. You __must__ call `Model` before `Select`. 658 | 659 | Thus always use: 660 | 661 | ```go 662 | db.Model(User{}).Select("username") 663 | ``` 664 | 665 | The reason is that igor generates queries in the form `SELECT table.field1, table.filed2 FROM table [WHERE] RETURNING table.field1, table.filed2`. 666 | 667 | In order to avoid ambiguities when using `Joins`, the `RETURNING` part of the query must be in the form `table.field1, table.filed2, ...`, and table is the `TableName()` result of the `DBModel` passed as `Model` argument. 668 | 669 | ### Models 670 | Igor models are __the same__ as GORM models (except that you have to use the `igor` tag field to define the primary key). The `sql` tag field is used to define default value and column value. Eg: 671 | 672 | ```go 673 | type Test struct { 674 | ID uint64 `igor:"primary_key column:id_column"` 675 | Time time.Time `sql:"default:(now() at time zone 'utc')"` 676 | } 677 | ``` 678 | 679 | The other main difference is that igor models require the implementation of the `DBModel` interface. 680 | 681 | In GORM, you can optionally define the `TableName` method on your Model. With igor this is mandatory. 682 | 683 | This constraint gives to igor the ability to generate conditions (like the `WHERE` or `INSERT` or `UPDATE` part of the query) that have a counter part on DB size for sure. 684 | 685 | If a type does not implement the `DBModel` interface your program will not compile (and thus you can easily find the error and fix it). Otherwise igor could generate a wrong query and we're trying to avoid that. 686 | 687 | ### Open method 688 | Since igor is PostgreSQL only, the `gorm.Open` method has been replaced with 689 | 690 | ```go 691 | Connect(connectionString string) (*Database, error) 692 | ``` 693 | 694 | ### Logger 695 | There's no `db.LogMode(bool)` method in igor. If you want to log the prepared statements, you have to manually set a logger for igor. 696 | 697 | ```go 698 | logger := log.New(os.Stdout, "query-logger: ", log.LUTC) 699 | db.Log(logger) 700 | ``` 701 | 702 | If you want to disable the logger, set it to nil 703 | 704 | ```go 705 | db.Log(nil) 706 | ``` 707 | 708 | Privacy: you'll __never__ see the values of the variables, but only the prepared statement and the PostgreSQL placeholders. Respect your user privacy, do not log user input (like credentials). 709 | 710 | ### Methods return value 711 | In GORM, every method (even the ones that execute queries) returns a `*DB`. 712 | 713 | In igor: 714 | 715 | - methods that execute queries returns `error` 716 | - methods that build the query returns `*Database`, thus you can chain the methods (GORM-like) and build the query. 717 | 718 | ### Scan and Find methods 719 | In GORM, `Scan` method is used to scan query results into a struct. The `Find` method is almost the same. 720 | 721 | In igor: 722 | - `Scan` method executes the `SELECT` query. Thus return an error if `Scan` fails (see the previous section). 723 | 724 | `Scan` handle every type. You can scan query results in: 725 | - slice of struct `.Scan(&sliceOfStruct)` 726 | - single struct `.Scan(&singleStruct)` 727 | - single value `.Scan(&integerType)` 728 | - a comma separated list of values (because `Scan` is a variadic arguments function) `.Scan(&firstInteger, &firstString, &secondInteger, &floatDestination)` 729 | 730 | - `Find` method does not exists, is completely replaced by `Scan`. 731 | 732 | ### Scan 733 | In addiction to the previous section, there's another difference between GORM ad igor. 734 | 735 | `Scan` method __do not__ scans the selected fields into results using the selected fields name, but uses the order (to increase the performance). 736 | 737 | Thus, having: 738 | ```go 739 | type Conversation struct { 740 | From string `json:"from"` 741 | Time time.Time `json:"time"` 742 | ToRead bool `json:"toRead"` 743 | } 744 | 745 | var convList []Conversation 746 | err := Db().Raw(`SELECT DISTINCT otherid, MAX(times) as "time", to_read FROM ( 747 | (SELECT MAX("time") AS times, "from" as otherid, to_read FROM pms WHERE "to" = ? GROUP BY "from", to_read) 748 | UNION 749 | (SELECT MAX("time") AS times, "to" as otherid, FALSE AS to_read FROM pms WHERE "from" = ? GROUP BY "to", to_read) 750 | ) AS tmp GROUP BY otherid, to_read ORDER BY to_read DESC, "time" DESC`, user.Counter, user.Counter).Scan(&convList) 751 | ``` 752 | 753 | Do not cause any problem, but if we change the SELECT clause, inverting the order, like 754 | 755 | ```go 756 | query := "SELECT DISTINCT otherid, to_read, MAX(times) as time " + 757 | ... 758 | 759 | ``` 760 | 761 | Scan will fail because it will try to Scan the boolean value in second position `to_read`, into the `time.Time` field of the Conversation structure. 762 | 763 | 764 | ### Delete 765 | In GORM, if you do not specify a primary key or a where clause (or if the value of the primary key is blank) the generated query will be 766 | ``` 767 | DELETE FROM 768 | ``` 769 | 770 | That will delete everything from your table. 771 | 772 | In igor this is not possible. 773 | 774 | You __must__ specify a `Where` clause or pass to `Delete` a non empty model that will be used to build the where clause. 775 | 776 | ```go 777 | db.Delete(&UserPost{}) // this panics 778 | 779 | post := UserPost{ 780 | Hpid: 10, 781 | From: 1, 782 | } 783 | 784 | db.Delete(&post) 785 | //generates DELETE FROM posts WHERE hpid = $1, because hpid is a primary key 786 | 787 | db.Where(&post).Delete(&UserPost{}) // ^ generates the same query 788 | 789 | db.Delete(&UserPost{From:1,To:1}) 790 | // generates: DELETE FROM posts WHERE "from" = $1 AND "to" = $2 791 | ``` 792 | 793 | ### First 794 | In GORM `First` is used to get the first record, with or without a second parameter that is the primary key value. 795 | 796 | In igor this is not possible. `First` works only with 2 parameter. 797 | 798 | - `DBModel`: that's the model you want to fill 799 | - `key interface{}` that's the primary key value, that __must__ be of the same type of the `DBModel` primary key. 800 | 801 | ```go 802 | var user User 803 | db.First(&user, uint64(1)) 804 | 805 | db.First(&user, "1") // panics, because "1" is not of the same type of user.Counter (uint64) 806 | ``` 807 | 808 | it generates: 809 | 810 | ```sql 811 | SELECT users.counter,users.last,users.notify_story,users.private,users.lang,users.username,users.email,users.name,users.surname,users.gender,users.birth_date,users.board_lang,users.timezone,users.viewonline,users.registration_time 812 | FROM users 813 | WHERE users.counter = $1 814 | ``` 815 | 816 | ## Other 817 | Every other GORM method is not implemented. 818 | 819 | ### JSON and JSONB support 820 | Igor supports PostgreSQL JSON and JSONB types natively. 821 | 822 | Just define the field in the DBModel with the type `igor.JSON`. 823 | After that, you can work with JSON in the following way: 824 | 825 | ```go 826 | user := createUser() 827 | 828 | var ns igor.JSON = make(igor.JSON) // use it like a map[string]interface{} 829 | 830 | ns["0"] = struct { 831 | From uint64 `json:from` 832 | To uint64 `json:to` 833 | Message string `json:message` 834 | }{ 835 | From: 1, 836 | To: 1, 837 | Message: "hi bob", 838 | } 839 | ns["numbers"] = 1 840 | ns["test"] = 2 841 | 842 | user.NotifyStory = ns 843 | 844 | if e = db.Updates(&user); e != nil { 845 | t.Errorf("updates should work but got: %s\n", e.Error()) 846 | } 847 | 848 | // To use JSON with json, use: 849 | // printableJSON, _ := json.Marshal(user.NotifyStory) 850 | // fmt.Printf("%s\n", printableJSON) 851 | 852 | var nsNew igor.JSON 853 | if e = db.Model(User{}).Select("notify_story").Where(&user).Scan(&nsNew); e != nil { 854 | t.Errorf("Problem scanning into igor.JSON: %s\n", e.Error()) 855 | } 856 | ``` 857 | 858 | ### LISTEN / NOTIFY support 859 | PostgreSQL give us a beautiful method to avoid polling the DBMS, using a simple publish/subscribe model over database connections (read more on the [docs](http://www.postgresql.org/docs/current/static/sql-notify.html)). 860 | 861 | Igor gives you the ability to generate notification and subscribe to notifications sent over a channel, using the methods `Listen` and `Notify`. 862 | 863 | Below there's a working example: 864 | 865 | ```go 866 | count := 0 867 | if e = db.Listen("notification_without_payload", func(payload ...string) { 868 | count++ 869 | t.Log("Received notification on channel: notification_without_payload\n") 870 | }); e != nil { 871 | t.Fatalf("Unable to listen on channel: %s\n", e.Error()) 872 | } 873 | 874 | for i := 0; i < 4; i++ { 875 | if e = db.Notify("notification_without_payload"); e != nil { 876 | t.Fatalf("Unable to send notification: %s\n", e.Error()) 877 | } 878 | } 879 | 880 | // wait some time to handle all notifications, because are asynchronous 881 | time.Sleep(100 * time.Millisecond) 882 | if count != 4 { 883 | t.Errorf("Expected to receive 4 notifications, but counted only: %d\n", count) 884 | } 885 | 886 | // listen on an opened channel should fail 887 | if e = db.Listen("notification_without_payload", func(payload ...string) {}); e == nil { 888 | t.Errorf("Listen on an opened channel should fail, but succeeded\n") 889 | } 890 | 891 | // Handle payload 892 | 893 | // listen on more channels, with payload 894 | count = 0 895 | if e = db.Listen("np", func(payload ...string) { 896 | count++ 897 | t.Logf("channel np: received payload: %s\n", payload) 898 | }); e != nil { 899 | t.Fatalf("Unable to listen on channel: %s\n", e.Error()) 900 | } 901 | 902 | // test sending payload with notify 903 | for i := 0; i < 4; i++ { 904 | if e = db.Notify("np", strconv.Itoa(i)+" payload"); e != nil { 905 | t.Fatalf("Unable to send notification with payload: %s\n", e.Error()) 906 | } 907 | } 908 | 909 | // wait some time to handle all notifications 910 | time.Sleep(100 * time.Millisecond) 911 | if count != 4 { 912 | t.Errorf("Expected to receive 4 notifications, but counted only: %d\n", count) 913 | } 914 | 915 | // test unlisten 916 | if e = db.Unlisten("notification_without_payload"); e != nil { 917 | t.Errorf("Unable to unlisten from notification_without_payload, got: %s\n", e.Error()) 918 | } 919 | 920 | // test UnlistenAll 921 | if e = db.UnlistenAll(); e != nil { 922 | t.Errorf("Unable to unlistenAll, got: %s\n", e.Error()) 923 | } 924 | 925 | ``` 926 | 927 | ### Contributing 928 | Do you want to add some new method to improve GORM compatibility or add some new method to improve igor? 929 | 930 | Feel free to contribute via Pull Request. 931 | 932 | ### Testing 933 | To test igor, you must create a igor user on PostgreSQL and make it own the igor database. 934 | On Archlinux, with `postgres` as the PostgreSQL superuser this can be achieved by: 935 | 936 | ```sh 937 | createuser -U postgres igor 938 | createdb -U postgres igor igor 939 | psql -U postgres -d igor -c "GRANT USAGE, CREATE ON SCHEMA public TO igor;" 940 | ``` 941 | 942 | You can run tests with the usual command: 943 | 944 | ```sh 945 | go test 946 | ``` 947 | 948 | ### License 949 | Copyright 2016-2023 Paolo Galeone. All right reserved. 950 | 951 | Licensed under the Apache License, Version 2.0 (the "License"); 952 | you may not use this file except in compliance with the License. 953 | You may obtain a copy of the License at 954 | 955 | http://www.apache.org/licenses/LICENSE-2.0 956 | 957 | Unless required by applicable law or agreed to in writing, software 958 | distributed under the License is distributed on an "AS IS" BASIS, 959 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 960 | See the License for the specific language governing permissions and 961 | limitations under the License. 962 | 963 | ### About the author 964 | 965 | Feel free to contact me (you can find my email address and other ways to contact me in my GitHub profile page). 966 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/galeone/igor 2 | 3 | go 1.22 4 | 5 | require github.com/lib/pq v1.10.9 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 2 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 3 | -------------------------------------------------------------------------------- /igor.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2016-2023 Paolo Galeone. All right reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package igor is an abstraction layer for PostgreSQL with a gorm like syntax. 18 | // 19 | // You should use igor when your DBMS is PostgreSQL and you want to place an abstraction layer on top of it and do CRUD operations in a smart, easy, secure and fast way. 20 | // 21 | // What igor does: 22 | // - Always uses prepared statements: no sql injection and good performance. 23 | // - Supports transactions 24 | // - Uses a GORM like syntax 25 | // - Uses the same logic in insertion and update: handle default values in a coherent manner 26 | // - Uses GORM models and conventions (partially, see [Differences](#differences)) 27 | // - Exploits PostgreSQL `RETURNING` statement to update models fields with the updated values (even when changed on db side; e.g. when having a default value) 28 | // - Automatically handle reserved keywords when used as a table name or fields. Does not quote every field (that's not recommended) but only the ones conflicting with a reserved keyword. 29 | // 30 | // What igor is not: 31 | // 32 | // - An ORM (and thus a complete GORM replacement): 33 | // - Does not support associations 34 | // - Does not support callbacks 35 | // - Does not have any specific method for data migration and DDL operations 36 | // - Does not support soft delete 37 | package igor 38 | 39 | import ( 40 | "bytes" 41 | "database/sql" 42 | "errors" 43 | "fmt" 44 | "log" 45 | "reflect" 46 | "slices" 47 | "sort" 48 | "strings" 49 | 50 | "github.com/lib/pq" 51 | ) 52 | 53 | // Connect opens the connection to PostgreSQL using connectionString 54 | func Connect(connectionString string) (*Database, error) { 55 | var e error 56 | db := new(Database) 57 | if db.db, e = sql.Open("postgres", connectionString); e != nil { 58 | return nil, e 59 | } 60 | 61 | // Ping the database to see if the connection is real 62 | if e = db.DB().Ping(); e != nil { 63 | return nil, errors.New("Connection failed. Unable to ping the DB: " + e.Error()) 64 | } 65 | 66 | db.connectionString = connectionString 67 | db.clear() 68 | return db, nil 69 | } 70 | 71 | // Wrap wraps a sql.DB connection to an igor.Database 72 | func Wrap(connection *sql.DB) (*Database, error) { 73 | if connection == nil { 74 | return nil, errors.New("Wrap: database connection is nil") 75 | } 76 | var e error 77 | db := new(Database) 78 | 79 | // Ping the database to see if the connection is real 80 | if e = connection.Ping(); e != nil { 81 | return nil, errors.New("Connection failed. Unable to ping the DB: " + e.Error()) 82 | } 83 | 84 | db.db = connection 85 | 86 | db.clear() 87 | return db, nil 88 | } 89 | 90 | // Log sets the query logger 91 | func (db *Database) Log(logger *log.Logger) *Database { 92 | db.logger = logger 93 | return db 94 | } 95 | 96 | // Model sets the table name for the current query 97 | func (db *Database) Model(model DBModel) *Database { 98 | db = db.clone() 99 | tableSQL := handleIdentifier(model.TableName()) 100 | if !slices.Contains(db.tables, tableSQL) { 101 | db.tables = append(db.tables, tableSQL) 102 | } 103 | if !slices.Contains(db.models, model) { 104 | db.models = append(db.models, model) 105 | } 106 | 107 | return db 108 | } 109 | 110 | // Joins append the join string to the current model 111 | func (db *Database) Joins(joins string) *Database { 112 | db = db.clone() 113 | db.joinTables = append(db.joinTables, joins) 114 | // we can't infer model from the join string (can contain everything) 115 | return db 116 | } 117 | 118 | // Table appends the table string to FROM. It has the same behavior of Model, but 119 | // passing the table name directly as a string 120 | func (db *Database) Table(table string) *Database { 121 | db = db.clone() 122 | tableSQL := handleIdentifier(table) 123 | if !slices.Contains(db.tables, tableSQL) { 124 | db.tables = append(db.tables, tableSQL) 125 | } 126 | return db 127 | } 128 | 129 | // Select sets the fields to retrieve. Appends fields to SELECT 130 | func (db *Database) Select(fields string, args ...interface{}) *Database { 131 | db = db.clone() 132 | db.selectFields += db.replaceMarks(fields) 133 | db.cteSelectValues = append(db.cteSelectValues, args...) 134 | return db 135 | } 136 | 137 | // CTE defines a Common Table Expression. Parameters are allowed 138 | func (db *Database) CTE(cte string, args ...interface{}) *Database { 139 | db.clear() // clear everything, since CTE is the first statement 140 | db = db.clone() 141 | db.cte += db.replaceMarks(cte) 142 | 143 | for _, value := range args { 144 | var pqVal interface{} 145 | if reflect.ValueOf(value).Kind() == reflect.Slice { 146 | pqVal = pq.Array(value) 147 | } else { 148 | pqVal = value 149 | } 150 | db.cteSelectValues = append(db.cteSelectValues, pqVal) 151 | } 152 | return db 153 | } 154 | 155 | // Delete executes DELETE FROM value.TableName where .Where() 156 | // Calling .Where is mandatory. You can pass a nil pointer to value if you just set 157 | // the table name with Model. 158 | func (db *Database) Delete(value DBModel) error { 159 | defer db.clear() 160 | // if Model has been called, skip table name inference procedure 161 | if len(db.tables) == 0 { 162 | db.tables = append(db.tables, handleIdentifier(value.TableName())) 163 | db.models = append(db.models, value) 164 | } 165 | 166 | // If where is empty, try to infer a primary key by value 167 | // otherwise buildDelete panics (where is mandatory) 168 | db = db.Where(value) 169 | var deleteQuery *string 170 | var err error 171 | if deleteQuery, err = db.buildDelete(); err != nil { 172 | return err 173 | } 174 | 175 | // Compile query 176 | var stmt *sql.Stmt 177 | if stmt, err = db.db.Prepare(*deleteQuery); err != nil { 178 | return err 179 | } 180 | defer stmt.Close() 181 | 182 | // Pass query parameters and executes the query 183 | var r sql.Result 184 | if r, err = stmt.Exec(db.whereValues...); err != nil { 185 | return err 186 | } 187 | 188 | var count int64 189 | if count, err = r.RowsAffected(); err != nil { 190 | return nil 191 | } 192 | if count == 0 { 193 | return errors.New("no rows have been deleted. Check that the passed value exists") 194 | } 195 | 196 | // Clear fields of value after delete, because the object no more exists 197 | if reflect.TypeOf(value).Kind() == reflect.Pointer { 198 | val := reflect.ValueOf(value).Elem() 199 | val.Set(reflect.Zero(val.Type())) 200 | } 201 | 202 | return nil 203 | } 204 | 205 | // Updates looks for non blank fields in value, extract its value and generate the 206 | // UPDATE value.TableName() SET = query part. 207 | // It handles default values when the field is empty. 208 | func (db *Database) Updates(value DBModel) error { 209 | defer func() { 210 | if db.rawRows != nil { 211 | db.rawRows.Close() 212 | } 213 | }() 214 | // Build where condition for update 215 | clone := db.Where(value) 216 | return clone.commonCreateUpdate(value, clone.buildUpdate) 217 | } 218 | 219 | // Create creates a new row into the Database, of type value and with its fields 220 | func (db *Database) Create(value DBModel) error { 221 | defer func() { 222 | if db.rawRows != nil { 223 | db.rawRows.Close() 224 | } 225 | }() 226 | db = db.clone() 227 | return db.commonCreateUpdate(value, db.buildCreate) 228 | } 229 | 230 | // Pluck fills the slice with the query result. 231 | // *Executes the query* (calls Scan internally). 232 | // Panics if slice is not a slice or the query is not well formulated 233 | func (db *Database) Pluck(column string, slice interface{}) error { 234 | dest := reflect.Indirect(reflect.ValueOf(slice)) 235 | if dest.Kind() != reflect.Slice { 236 | db.panicLog(fmt.Sprintf("slice should be a slice, not %s\n", dest.Kind())) 237 | } 238 | 239 | db = db.Select(column) 240 | return db.Scan(slice) 241 | } 242 | 243 | // Count sets the query result to be count() and scan the result into value. 244 | // *It executes the query* (calls Scan internally). 245 | // It panics if the query is not well formulated. 246 | func (db *Database) Count(value *uint8) error { 247 | key, _ := primaryKey(db.models[0]) 248 | if key != "" { 249 | db = db.Select("count(" + handleIdentifier(key) + ")") 250 | } else { 251 | db = db.Select("count(*)") 252 | } 253 | return db.Scan(value) 254 | } 255 | 256 | // First Scans the result of the selection query of type model using the specified id 257 | // Panics if key is not compatible with the primary key filed type or if the query formulation fails 258 | func (db *Database) First(dest DBModel, key interface{}) error { 259 | modelKey, _ := primaryKey(dest) 260 | // Create a copy of dest, in order to do not set any field (the primary key) 261 | // in case of empty results from Scan 262 | destIndirect := reflect.Indirect(reflect.ValueOf(dest)) 263 | 264 | destCopy := reflect.New(destIndirect.Type()).Elem() 265 | destCopy.Set(destIndirect) 266 | 267 | destCopy.FieldByName(modelKey).Set(reflect.Indirect(reflect.ValueOf(key))) 268 | 269 | if err := db.Model(dest).Where(destCopy.Interface()).Scan(dest); err != nil { 270 | return err 271 | } 272 | return nil 273 | } 274 | 275 | // Scan build the SELECT query and scans the query result query into dest. 276 | // Panics if scan fails or the query fail 277 | func (db *Database) Scan(dest ...interface{}) error { 278 | defer db.clear() 279 | db = db.clone() 280 | ld := len(dest) 281 | if ld == 0 { 282 | return errors.New("required at least one parameter to Scan method") 283 | } 284 | 285 | var err error 286 | var rows *sql.Rows 287 | destIndirect := reflect.Indirect(reflect.ValueOf(dest[0])) 288 | if db.rawRows == nil { 289 | // Compile query 290 | var stmt *sql.Stmt 291 | // If the destination is a struct (or a slice of struct) 292 | // select should select only exported sql fields in the order declared in the struct 293 | // This thing should go only if the user does not selected with `.Select` the fields to export 294 | // Thus only if db.selectFields == "" 295 | if db.selectFields == "" { 296 | if ld == 1 { // if is a struct or a slice of struct 297 | switch destIndirect.Kind() { 298 | case reflect.Struct: 299 | db.selectFields = strings.Join(getSQLFields(destIndirect.Interface().(DBModel)), ",") 300 | case reflect.Slice: 301 | // handle slice of structs and slice of pointers to struct 302 | sliceType := destIndirect.Type().Elem() 303 | if sliceType.Kind() == reflect.Ptr { 304 | return errors.New("do not use a slice of pointers. Use a slice of real values. E.g. use []int instead of []*int") 305 | } 306 | if sliceType.Kind() == reflect.Struct { 307 | db.selectFields = strings.Join(getSQLFields(reflect.Indirect(reflect.New(sliceType)).Interface().(DBModel)), ",") 308 | } else { 309 | panic(sliceType) 310 | } 311 | case reflect.Invalid: 312 | fallthrough 313 | default: 314 | panic("Remember to initialize the scan arguments with make() when using pointers") 315 | } 316 | } 317 | } 318 | 319 | if stmt, err = db.db.Prepare(db.buildSelect()); err != nil { 320 | return err 321 | } 322 | defer stmt.Close() 323 | 324 | // Pass query parameters and execute it 325 | if rows, err = stmt.Query(append(db.cteSelectValues, db.whereValues...)...); err != nil { 326 | return err 327 | } 328 | 329 | } else { 330 | // Raw has already executed the Query 331 | rows = db.rawRows 332 | } 333 | 334 | defer rows.Close() 335 | 336 | if ld == 1 { 337 | // if is a slice, find first element to decide how to use scan 338 | // otherwise use destIndirect 339 | var defaultElem reflect.Value 340 | var slicePtr reflect.Value 341 | switch destIndirect.Kind() { 342 | // slice 343 | case reflect.Slice: 344 | // create a new element, because slice usually is empty. Thus we have to dynamically create it 345 | defaultElem = reflect.Indirect(reflect.New(destIndirect.Type().Elem())) 346 | // Create a pointer to a slice value and set it to the slice 347 | realSlice := reflect.ValueOf(dest[0]) 348 | slicePtr = reflect.New(realSlice.Type()) 349 | default: 350 | defaultElem = destIndirect 351 | } 352 | 353 | // if defaultElem is a struct, extract its fields, pass it to scan (extracts the address) 354 | var interfaces []interface{} 355 | if defaultElem.Kind() == reflect.Struct { 356 | fields := []reflect.StructField{} 357 | getFields(defaultElem.Interface(), &fields) 358 | // If there are no fields, and the destination of scan is just a variable 359 | // it means that it is a struct, but this struct doesn't contain igor decorations 360 | // The most common example is the .Scan(&time) (with time of type time.Time). 361 | // In this case, we have to pass the address of the variable to scan, like in the else of the parent if statement. 362 | if len(fields) == 0 { 363 | interfaces = append(interfaces, defaultElem.Addr().Interface()) 364 | } 365 | for _, field := range fields { 366 | var fieldIndirect reflect.Value 367 | if destIndirect.Kind() == reflect.Slice { 368 | fieldIndirect = reflect.Indirect(reflect.Indirect(defaultElem.FieldByName(field.Name))) 369 | } else { 370 | fieldIndirect = reflect.Indirect(reflect.Indirect(destIndirect.Addr()).FieldByName(field.Name)) 371 | } 372 | 373 | switch fieldIndirect.Kind() { 374 | // A field can be a slice of values (e.g. SQL `[]text`) 375 | case reflect.Slice: 376 | valueOfPtrType := fieldIndirect.Interface() 377 | typeOfPtr := reflect.TypeOf(valueOfPtrType) 378 | newPointerVariable := reflect.New(typeOfPtr) 379 | newPointedVariable := newPointerVariable.Elem() 380 | realSlice := reflect.New(newPointedVariable.Type()).Elem().Interface() 381 | slicePtr := &realSlice 382 | interfaces = append(interfaces, slicePtr) 383 | default: 384 | interfaces = append(interfaces, reflect.Indirect(defaultElem.FieldByName(field.Name)).Addr().Interface()) 385 | } 386 | } 387 | } else { 388 | // else convert defaultElem into interfaces, use the address 389 | interfaces = append(interfaces, defaultElem.Addr().Interface()) 390 | } 391 | 392 | fetchedRows := false 393 | for rows.Next() { 394 | fetchedRows = true 395 | // defaultElem fields are filled by Scan (scan result into fields as variadic arguments) 396 | if err = rows.Scan(interfaces...); err != nil { 397 | return err 398 | } 399 | // append result to dest (if the destination is a slice) 400 | if slicePtr.IsValid() { 401 | destIndirect.Set(reflect.Append(destIndirect, reflect.Indirect(defaultElem))) 402 | } 403 | } 404 | if !fetchedRows { 405 | return sql.ErrNoRows 406 | } 407 | } else { 408 | // Scan(field1, field2, ...) 409 | fetchedRows := false 410 | for rows.Next() { 411 | fetchedRows = true 412 | if err = rows.Scan(dest...); err != nil { 413 | return err 414 | } 415 | } 416 | if !fetchedRows { 417 | return sql.ErrNoRows 418 | } 419 | } 420 | return nil 421 | } 422 | 423 | // Exec prepares and execute a raw query and replace placeholders (?) with the one supported by PostgreSQL 424 | // Exec panics if can't build the query 425 | // Use Exec instead of Raw when you don't need the results (or there's no result) 426 | func (db *Database) Exec(query string, args ...interface{}) error { 427 | defer db.clear() 428 | if len(args) > 0 { 429 | stmt := db.commonRawQuery(query, args...) 430 | defer stmt.Close() 431 | _, e := stmt.Exec(db.whereValues...) 432 | return e 433 | } 434 | _, e := db.db.Exec(query) 435 | return e 436 | } 437 | 438 | // Raw prepares and executes a raw query and replace placeholders (?) with the one supported by PostgreSQL 439 | // Raw panics if can't build the query 440 | // To fetch results call Scan 441 | func (db *Database) Raw(query string, args ...interface{}) *Database { 442 | db = db.clone() 443 | var err error 444 | stmt := db.commonRawQuery(query, args...) 445 | defer stmt.Close() 446 | // Pass query parameters and executes the query 447 | if db.rawRows, err = stmt.Query(db.whereValues...); err != nil { 448 | db.panicLog(err.Error()) 449 | } 450 | return db 451 | } 452 | 453 | // Where builds the WHERE clause. If a primary key is present in the struct 454 | // only that field is used. Otherwise, every non empty field is ANDed 455 | // s can be a struct, in that case args are ignored 456 | // or it can be a string, in that case args are the query parameters. Use ? placeholder 457 | // If a where condition can't be generated it panics 458 | func (db *Database) Where(s interface{}, args ...interface{}) *Database { 459 | db = db.clone() 460 | if reflect.TypeOf(s).Kind() == reflect.String { 461 | whereClause := reflect.ValueOf(s).String() 462 | // replace question marks with $n 463 | // handle cases like .Where("a = ? and b in (?)", 1, []int{1,2,4,6}) 464 | // this must become: a = $1 and b in ($2, $3, $4, $5) 465 | var slicePos []int 466 | 467 | // since I'm looping through args, I'll build the whereFields with expanded slices if present 468 | var whereArgsExtended []interface{} 469 | for i := 0; i < len(args); i++ { 470 | if reflect.TypeOf(args[i]).Kind() == reflect.Slice { 471 | slicePos = append(slicePos, i) 472 | slice := reflect.Indirect(reflect.ValueOf(args[i])) 473 | for k := 0; k < slice.Len(); k++ { 474 | whereArgsExtended = append(whereArgsExtended, reflect.Indirect(slice.Index(k)).Interface()) 475 | } 476 | } else { 477 | whereArgsExtended = append(whereArgsExtended, args[i]) 478 | } 479 | } 480 | 481 | if len(slicePos) > 0 { 482 | var buffer bytes.Buffer 483 | // build new where clause, using old where clause until we don't reach the ? associated with the 484 | // slice. Then replace that ? with len(slice) question marks. 485 | markCount := 0 486 | slicePosLen := len(slicePos) 487 | for _, c := range whereClause { 488 | if c == '?' { 489 | s := sort.SearchInts(slicePos, markCount) 490 | // if found a ? associated with a slice 491 | if s < slicePosLen && slicePos[s] == markCount { 492 | sliceLen := reflect.Indirect(reflect.ValueOf(args[markCount])).Len() 493 | for i := 0; i < sliceLen; i++ { 494 | buffer.WriteRune('?') 495 | if i != sliceLen-1 { 496 | buffer.WriteRune(',') 497 | } 498 | } 499 | } else { 500 | // if the ? is not associated with a ?, write it as is 501 | buffer.WriteRune(c) 502 | } 503 | markCount++ 504 | } else { 505 | buffer.WriteRune(c) 506 | } 507 | } 508 | // build the new where clause and pass it to replaceMarks 509 | db.whereFields = append(db.whereFields, db.replaceMarks(buffer.String())) 510 | db.whereValues = append(db.whereValues, whereArgsExtended...) 511 | } else { 512 | db.whereFields = append(db.whereFields, db.replaceMarks(whereClause)) 513 | db.whereValues = append(db.whereValues, args...) 514 | } 515 | } else { 516 | // must be a struct 517 | in := getStruct(s) 518 | key, value := primaryKey(s) 519 | 520 | // if a model has not been set, set the model as s.TableName() 521 | if len(db.tables) == 0 { 522 | db = db.Model(s.(DBModel)) 523 | } 524 | 525 | escapedTableName := handleIdentifier(s.(DBModel).TableName()) 526 | 527 | if key != "" && !isBlank(reflect.ValueOf(value)) { 528 | db.whereFields = append(db.whereFields, escapedTableName+"."+handleIdentifier(key)) 529 | db.whereValues = append(db.whereValues, value) 530 | } else { 531 | // handle embedded anonymous struct 532 | fields := []reflect.StructField{} 533 | getFields(s, &fields) 534 | for _, fieldType := range fields { 535 | fieldValue := in.FieldByName(fieldType.Name) 536 | if !isBlank(fieldValue) { 537 | db.whereFields = append(db.whereFields, escapedTableName+"."+getColumnName(fieldType)) 538 | db.whereValues = append(db.whereValues, fieldValue.Interface()) 539 | } 540 | } 541 | } 542 | } 543 | return db 544 | } 545 | 546 | // Limit sets the LIMIT value to the query 547 | func (db *Database) Limit(limit int) *Database { 548 | db = db.clone() 549 | db.limit = limit 550 | return db 551 | } 552 | 553 | // Offset sets the OFFSET value to the query 554 | func (db *Database) Offset(offset int) *Database { 555 | db = db.clone() 556 | db.offset = offset 557 | return db 558 | } 559 | 560 | // Order sets the ORDER BY value to the query 561 | func (db *Database) Order(value string) *Database { 562 | db = db.clone() 563 | db.order = handleIdentifier(value) 564 | return db 565 | } 566 | 567 | // DB returns the current `*sql.DB` 568 | // panics if called during a transaction 569 | func (db *Database) DB() *sql.DB { 570 | return db.db.(*sql.DB) 571 | } 572 | 573 | // Transactions 574 | 575 | // Begin initialize a transaction 576 | // panics if begin has been already called 577 | // Returns nil on error (if logger is enabled write error on log) 578 | func (db *Database) Begin() *Database { 579 | db = db.clone() 580 | // Initialize transaction 581 | var tx *sql.Tx 582 | var err error 583 | if tx, err = db.db.(*sql.DB).Begin(); err != nil { 584 | db.printLog(err.Error()) 585 | return nil 586 | } 587 | // backup db.db into db.connection 588 | db.connection = db.db.(*sql.DB) 589 | // replace db.db with the transaction 590 | db.db = tx 591 | return db 592 | } 593 | 594 | // Commit commits the transaction. 595 | // Panics if the transaction is not started (you have to call Begin before) 596 | func (db *Database) Commit() error { 597 | err := db.db.(*sql.Tx).Commit() 598 | // restore connection 599 | db.db = db.connection 600 | db.clear() 601 | return err 602 | } 603 | 604 | // Rollback rollbacks the transaction 605 | // Panics if the transaction is not started (you have to call Begin before) 606 | func (db *Database) Rollback() error { 607 | err := db.db.(*sql.Tx).Rollback() 608 | // restore connection 609 | db.db = db.connection 610 | db.clear() 611 | return err 612 | } 613 | -------------------------------------------------------------------------------- /igor_private.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2016-2023 Paolo Galeone. All right reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package igor 18 | 19 | import ( 20 | "bytes" 21 | "database/sql" 22 | "errors" 23 | "fmt" 24 | "reflect" 25 | "sort" 26 | "strconv" 27 | "strings" 28 | "unicode" 29 | 30 | "github.com/lib/pq" 31 | ) 32 | 33 | // Reserved keywords that shouldn't be used as column name or other identifiers 34 | // result of: select '"' || word::text || '",' from pg_get_keywords() where catcode = 'R' OR catdesc like '%cannot%'; 35 | // We will check if the current identifier is in that list. If it is, it will be placed between quotes 36 | var reservedKeywords = []string{ 37 | "all", 38 | "analyse", 39 | "analyze", 40 | "and", 41 | "any", 42 | "array", 43 | "as", 44 | "asc", 45 | "asymmetric", 46 | "between", 47 | "bigint", 48 | "bit", 49 | "boolean", 50 | "both", 51 | "case", 52 | "cast", 53 | "char", 54 | "character", 55 | "check", 56 | "coalesce", 57 | "collate", 58 | "column", 59 | "constraint", 60 | "create", 61 | "current_catalog", 62 | "current_date", 63 | "current_role", 64 | "current_time", 65 | "current_timestamp", 66 | "current_user", 67 | "dec", 68 | "decimal", 69 | "default", 70 | "deferrable", 71 | "desc", 72 | "distinct", 73 | "do", 74 | "else", 75 | "end", 76 | "except", 77 | "exists", 78 | "extract", 79 | "false", 80 | "fetch", 81 | "float", 82 | "for", 83 | "foreign", 84 | "from", 85 | "grant", 86 | "greatest", 87 | "group", 88 | "grouping", 89 | "having", 90 | "in", 91 | "initially", 92 | "inout", 93 | "int", 94 | "integer", 95 | "intersect", 96 | "interval", 97 | "into", 98 | "lateral", 99 | "leading", 100 | "least", 101 | "limit", 102 | "localtime", 103 | "localtimestamp", 104 | "national", 105 | "nchar", 106 | "none", 107 | "normalize", 108 | "not", 109 | "null", 110 | "nullif", 111 | "numeric", 112 | "offset", 113 | "on", 114 | "only", 115 | "or", 116 | "order", 117 | "out", 118 | "overlay", 119 | "placing", 120 | "position", 121 | "precision", 122 | "primary", 123 | "real", 124 | "references", 125 | "returning", 126 | "row", 127 | "select", 128 | "session_user", 129 | "setof", 130 | "smallint", 131 | "some", 132 | "substring", 133 | "symmetric", 134 | "table", 135 | "then", 136 | "time", 137 | "timestamp", 138 | "to", 139 | "trailing", 140 | "treat", 141 | "trim", 142 | "true", 143 | "union", 144 | "unique", 145 | "user", 146 | "using", 147 | "values", 148 | "varchar", 149 | "variadic", 150 | "when", 151 | "where", 152 | "window", 153 | "with", 154 | "xmlattributes", 155 | "xmlconcat", 156 | "xmlelement", 157 | "xmlexists", 158 | "xmlforest", 159 | "xmlnamespaces", 160 | "xmlparse", 161 | "xmlpi", 162 | "xmlroot", 163 | "xmlserialize", 164 | "xmltable", 165 | } 166 | 167 | // clear is called at the end of every query, to clean up the db structure 168 | // preserving the connection and the logger 169 | func (db *Database) clear() { 170 | db.rawRows = nil 171 | db.tables = nil 172 | db.joinTables = nil 173 | db.models = nil 174 | db.cteSelectValues = nil 175 | db.cte = "" 176 | db.selectFields = "" 177 | db.updateCreateValues = nil 178 | db.updateCreateFields = nil 179 | db.whereValues = nil 180 | db.whereFields = nil 181 | db.order = "" 182 | db.limit = 0 183 | db.offset = 0 184 | db.varCount = 1 185 | } 186 | 187 | // printLog uses db.log to update log 188 | func (db *Database) printLog(v interface{}) { 189 | if db.logger != nil { 190 | db.logger.Print(v) 191 | } 192 | } 193 | 194 | // panicLog uses db.log to update log and than it panics 195 | // if db.log is nil, printLog panic using the panic method 196 | func (db *Database) panicLog(v interface{}) { 197 | if db.logger != nil { 198 | db.logger.Panic(v) 199 | } else { 200 | panic(v) 201 | } 202 | } 203 | 204 | // handleIdentifier handle db identifiers that are reserved 205 | // It puts reserved keywords used as column/table name between double quotes and 206 | // rename clause into a valid database identifier, following the conventions 207 | func handleIdentifier(clause string) string { 208 | lowerClause := strings.ToLower(clause) 209 | i := sort.SearchStrings(reservedKeywords, lowerClause) 210 | if i < len(reservedKeywords) && reservedKeywords[i] == lowerClause { 211 | return `"` + lowerClause + `"` 212 | } 213 | return namingConvention(clause) 214 | } 215 | 216 | // commonRawQuery executes common operations when using raw queries 217 | // returns the prepared statement 218 | func (db *Database) commonRawQuery(query string, args ...interface{}) *sql.Stmt { 219 | // Replace ? with $n 220 | query = db.replaceMarks(query) 221 | // Append args content to current values 222 | db.whereValues = append(db.whereValues, args...) 223 | 224 | db.printLog(query) 225 | // Compile query 226 | var stmt *sql.Stmt 227 | var err error 228 | if stmt, err = db.db.Prepare(query + ";"); err != nil { 229 | db.panicLog(err.Error()) 230 | } 231 | return stmt 232 | } 233 | 234 | // commonCreateUpdate executes common operation in preparation of create / update statements 235 | // because the logic is the same. 236 | // builder is the function that build the UPDATE or CREATE query 237 | func (db *Database) commonCreateUpdate(value DBModel, builder func() string) error { 238 | defer db.clear() 239 | // if Model has been called, skip table name inference procedure 240 | if len(db.tables) == 0 { 241 | db.tables = append(db.tables, handleIdentifier(value.TableName())) 242 | } 243 | if len(db.models) == 0 { 244 | db.models = append(db.models, value) 245 | } 246 | 247 | in := getStruct(value) 248 | // getFields handle anonymous nested fields 249 | fields := []reflect.StructField{} 250 | getFields(value, &fields) 251 | 252 | for _, structField := range fields { 253 | field := in.FieldByName(structField.Name) 254 | if value := fieldValue(field, structField); value != nil { 255 | db.updateCreateFields = append(db.updateCreateFields, getColumnName(structField)) 256 | var pqVal interface{} 257 | if reflect.ValueOf(value).Kind() == reflect.Slice { 258 | pqVal = pq.Array(value) 259 | } else { 260 | pqVal = value 261 | } 262 | db.updateCreateValues = append(db.updateCreateValues, pqVal) 263 | } 264 | } 265 | 266 | // Compile query 267 | var stmt *sql.Stmt 268 | var err error 269 | if stmt, err = db.db.Prepare(builder()); err != nil { 270 | return err 271 | } 272 | defer stmt.Close() 273 | 274 | // Pass query parameters and executes the query 275 | // set db.rawRows to query results (of returning) in order to make it possible to scan rows into result 276 | if db.rawRows, err = stmt.Query(append(db.updateCreateValues, db.whereValues...)...); err != nil { 277 | return err 278 | } 279 | // Use the new struct with the fields lib/pq compatible for slices 280 | // e.g. pq.Array/pg.StringArray instead of []string and similar 281 | return db.Scan(value) 282 | } 283 | 284 | // replaceMarks replace question marks (?) with the PostgreSQL variable identifier 285 | // using the right (incremental) value 286 | func (db *Database) replaceMarks(in string) string { 287 | var buffer bytes.Buffer 288 | for _, runeValue := range in { 289 | if runeValue == '?' { 290 | buffer.WriteString("$") 291 | buffer.WriteString(strconv.Itoa(db.varCount)) 292 | db.varCount++ 293 | } else { 294 | buffer.WriteRune(runeValue) 295 | } 296 | } 297 | return buffer.String() 298 | } 299 | 300 | // primaryKey returns the pair (key, value) representing the defined primary key field 301 | // (key) and its value (value), when the `sql` struct tag field is defined and is value is not blank 302 | // returns empty key if a key is not present and thus en empty value 303 | // otherwise returns the key and the value (if present, i.e. is not blank) 304 | // returned Key is the Name of the field. Not following the sql conventions but the go convention. 305 | // If you need to change this value (and you usually do), parse key with handleIdentifier 306 | func primaryKey(s interface{}) (key string, value interface{}) { 307 | val := reflect.Indirect(reflect.ValueOf(s)) 308 | for i := 0; i < val.NumField(); i++ { 309 | fieldValue := val.Field(i) 310 | fieldType := val.Type().Field(i) 311 | // Handle embedded anonymous struct only 312 | switch fieldValue.Kind() { 313 | case reflect.Struct: 314 | if fieldType.Anonymous { 315 | key, value = primaryKey(fieldValue.Interface()) 316 | } 317 | default: 318 | tag := strings.ToLower(fieldType.Tag.Get("igor")) 319 | tagValue := strings.Split(tag, ",") 320 | sort.Strings(tagValue) 321 | idx := sort.SearchStrings(tagValue, "primary_key") 322 | if idx < len(tagValue) && tagValue[idx] == "primary_key" { 323 | key = fieldType.Name 324 | value = fieldValue.Interface() 325 | return 326 | } 327 | } 328 | } 329 | return 330 | } 331 | 332 | // namingConvention returns the conversion of input name to a 333 | // valid db entity that follows the convention 334 | func namingConvention(name string) string { 335 | // first char is always upper case 336 | var ucActual bool 337 | var buffer bytes.Buffer 338 | buffer.WriteRune(rune(name[0])) 339 | for i := 1; i < len(name); i++ { 340 | prevChar := rune(name[i-1]) 341 | actualChar := rune(name[i]) 342 | ucActual = unicode.IsUpper(actualChar) 343 | if unicode.IsLower(prevChar) && ucActual { 344 | buffer.WriteByte('_') 345 | } 346 | buffer.WriteRune(actualChar) 347 | } 348 | 349 | return strings.ToLower(buffer.String()) 350 | } 351 | 352 | // getColumnName returns the column name of the specified field of the struct 353 | // it's the name of the field if the field has not a `igor:column` value specified 354 | // the field is a valid sql value (thus in case, the name is escaped using handleIdentifier) 355 | func getColumnName(field reflect.StructField) (fieldName string) { 356 | ts := parseTagSetting(field.Tag.Get("igor")) 357 | if ts["column"] != "" { 358 | fieldName = ts["column"] 359 | } else { 360 | fieldName = handleIdentifier(field.Name) 361 | } 362 | return 363 | } 364 | 365 | // getSQLFields returns sql-compatible fields that the select query should return 366 | // skips sql:"-". 367 | func getSQLFields(s DBModel) (ret []string) { 368 | fields := []reflect.StructField{} 369 | getFields(s, &fields) 370 | table := handleIdentifier(s.TableName()) 371 | for _, field := range fields { 372 | ret = append(ret, table+"."+getColumnName(field)) 373 | } 374 | return 375 | } 376 | 377 | // getFields returns a slice of reflect.StructField that represents the exported struct Fields in s 378 | // that are not excluded in sql generation. 379 | // toKeep is a pointer to a slice, because we call this function recursively on nested structs 380 | // and we must collect the values in the very same slice. 381 | func getFields(s interface{}, toKeep *[]reflect.StructField) { 382 | // Use a map to count how many times we find the same field 383 | // Usually it's only 1, but it may happen that a struct is embedding 384 | // another struct with the same field name (and type) and different tag 385 | // In this case, we need to take care of considering the tags. 386 | // e.g. 387 | // struct A { x int64 } 388 | // struct B { A, x int64 `sql:"-"`} 389 | // In this case, we want `x` to be excluded from the SQL generation. 390 | 391 | isInSlice := func(value reflect.StructField, slice []reflect.StructField) int { 392 | for idx, v := range slice { 393 | if v.Name == value.Name { 394 | return idx 395 | } 396 | } 397 | return -1 398 | } 399 | 400 | val := reflect.Indirect(reflect.ValueOf(s)) 401 | // addIf adds filedType to toKeep if is not marked as `sql:"-"` 402 | addIf := func(fieldType reflect.StructField) { 403 | tag := strings.ToLower(fieldType.Tag.Get("sql")) 404 | tagValue := strings.Split(tag, ",") 405 | sort.Strings(tagValue) 406 | if key := isInSlice(fieldType, *toKeep); key != -1 { 407 | // If already seen and not ignored, check if the _current tag_ 408 | // is an ignore tag. In that case, let's remove from toKeep 409 | idx := sort.SearchStrings(tagValue, "-") 410 | if idx != len(tagValue) && tagValue[idx] == "-" { 411 | copy((*toKeep)[key:], (*toKeep)[key+1:]) 412 | *toKeep = (*toKeep)[:len(*toKeep)-1] 413 | } 414 | } else { 415 | // Never seen and not ignored: to keep 416 | idx := sort.SearchStrings(tagValue, "-") 417 | if idx == len(tagValue) || tagValue[idx] != "-" { 418 | *toKeep = append(*toKeep, fieldType) 419 | } 420 | } 421 | } 422 | 423 | for i := 0; i < val.NumField(); i++ { 424 | fieldValue := val.Field(i) 425 | fieldType := val.Type().Field(i) 426 | // if it's exported 427 | if fieldType.PkgPath == "" { 428 | // Handle embedded anonymous struct 429 | switch fieldValue.Kind() { 430 | case reflect.Struct: 431 | // if it's anonymous, embed its fields in the query 432 | if fieldType.Anonymous { 433 | getFields(fieldValue.Interface(), toKeep) 434 | } else { // use its name only (to work with structs like time.Time) 435 | addIf(fieldType) 436 | } 437 | default: 438 | addIf(fieldType) 439 | } 440 | } 441 | } 442 | } 443 | 444 | // isBlank returns true if value is empty 445 | func isBlank(value reflect.Value) bool { 446 | return reflect.DeepEqual(value.Interface(), reflect.Zero(value.Type()).Interface()) 447 | } 448 | 449 | // getStruct panics if s is not a struct. Returns the reflect.Indirect(reflect.ValueOf(s)) 450 | func getStruct(s interface{}) reflect.Value { 451 | in := reflect.Indirect(reflect.ValueOf(s)) 452 | if in.Kind() != reflect.Struct { 453 | panic(fmt.Sprintf("s must be a struct, not %s\n", in.Kind())) 454 | } 455 | return in 456 | } 457 | 458 | // buildSelect returns the generated SQL. Panics if it can't generate a query 459 | func (db *Database) buildSelect() string { 460 | if len(db.tables) == 0 { 461 | db.panicLog("Please set a table with Model [ + Joins ]") 462 | } 463 | 464 | var query bytes.Buffer 465 | query.WriteString(db.buildCTE()) 466 | 467 | // Select 468 | var fields string 469 | query.WriteString("SELECT ") 470 | if len(db.selectFields) > 0 { 471 | fields = db.selectFields 472 | } 473 | 474 | query.WriteString(fields) 475 | query.WriteString(" FROM ") 476 | // Model && Join 477 | 478 | query.WriteString(strings.Join(db.tables, ",")) 479 | query.WriteString(" ") 480 | 481 | // Join (optional) 482 | if len(db.joinTables) > 0 { 483 | query.WriteString(strings.Join(db.joinTables, " ")) 484 | query.WriteString(" ") 485 | } 486 | 487 | // Where (optional) 488 | if len(db.whereFields) > 0 { 489 | query.WriteString(db.buildWhere()) 490 | } 491 | 492 | // Order (optional) 493 | if db.order != "" { 494 | query.WriteString(" ORDER BY ") 495 | query.WriteString(db.order) 496 | } 497 | 498 | // Limit (optional) 499 | if db.limit != 0 { 500 | query.WriteString(" LIMIT ") 501 | query.WriteString(strconv.Itoa(db.limit)) 502 | } 503 | 504 | // Offset (optional) 505 | if db.offset != 0 { 506 | query.WriteString(" OFFSET ") 507 | query.WriteString(strconv.Itoa(db.offset)) 508 | } 509 | 510 | query.WriteString(";") 511 | qs := query.String() 512 | db.printLog(qs) 513 | return qs 514 | } 515 | 516 | // buildUpdate returns the generated SQL for the UPDATE statement. Panics if it can't generate a query 517 | func (db *Database) buildUpdate() string { 518 | var query bytes.Buffer 519 | query.WriteString(db.buildCTE()) 520 | query.WriteString("UPDATE ") 521 | 522 | // Model only 523 | if len(db.tables) != 1 { 524 | db.panicLog("Please set a table with Model to Update") 525 | } 526 | query.WriteString(db.tables[0]) 527 | query.WriteString(" SET ") 528 | 529 | updateSize := len(db.updateCreateFields) 530 | if updateSize == 0 { 531 | db.panicLog("Unable to detect fields to update") 532 | } 533 | 534 | for j, field := range db.updateCreateFields { 535 | query.WriteString(handleIdentifier(field)) 536 | query.WriteString(" = $") 537 | query.WriteString(strconv.Itoa(db.varCount)) 538 | db.varCount++ 539 | if j != updateSize-1 { 540 | query.WriteString(", ") 541 | } 542 | } 543 | 544 | // Where (optional) 545 | if len(db.whereFields) > 0 { 546 | query.WriteString(db.buildWhere()) 547 | } 548 | 549 | query.WriteString(db.buildReturning()) 550 | 551 | qs := query.String() 552 | db.printLog(qs) 553 | return qs 554 | } 555 | 556 | // buildCreate returns the generated SQL for the CREATE statement. Panics if it can't generate a query 557 | func (db *Database) buildCreate() string { 558 | var query bytes.Buffer 559 | query.WriteString(db.buildCTE()) 560 | query.WriteString("INSERT INTO ") 561 | 562 | // Model only 563 | if len(db.tables) != 1 { 564 | db.panicLog(fmt.Sprintf("Unable to infer table name for Create. Number of tables: %d", len(db.tables))) 565 | } 566 | // Table ( 567 | query.WriteString(db.tables[0]) 568 | query.WriteString("(") 569 | 570 | // field1,file2,... 571 | createSize := len(db.updateCreateFields) 572 | if createSize == 0 { 573 | db.panicLog("Unable to detect fields for Create. Ensure that you're passing values for all the fields that have no default value. e.g. sql:'default:something'") 574 | } 575 | 576 | query.WriteString(strings.Join(db.updateCreateFields, ",")) 577 | // values($1,..$n) place holders 578 | query.WriteString(") VALUES (") 579 | for i := 0; i < createSize; i++ { 580 | query.WriteString("$") 581 | query.WriteString(strconv.Itoa(db.varCount)) 582 | db.varCount++ 583 | if i != createSize-1 { 584 | query.WriteString(",") 585 | } 586 | } 587 | 588 | query.WriteString(") ") 589 | query.WriteString(db.buildReturning()) 590 | query.WriteString(";") 591 | 592 | qs := query.String() 593 | db.printLog(qs) 594 | return qs 595 | } 596 | 597 | // buildReturning returns the RETURNING part of the query 598 | // it explicits every fields in the current model. 599 | // In that way we're able to easily Scan the results 600 | func (db *Database) buildReturning() string { 601 | var query bytes.Buffer 602 | query.WriteString(" RETURNING ") 603 | query.WriteString(strings.Join(getSQLFields(db.models[0]), ",")) 604 | return query.String() 605 | } 606 | 607 | // buildCTE returns the CTE if defined 608 | func (db *Database) buildCTE() string { 609 | // replaceMarks has been called previously, in order to prevent wrong parameter order 610 | return db.cte + " " 611 | } 612 | 613 | // buildDelete returns the generated SQL for the DELETE statement. 614 | // It returns error if it's impossible to create the delete query for some reason. 615 | func (db *Database) buildDelete() (*string, error) { 616 | var query bytes.Buffer 617 | query.WriteString(db.buildCTE()) 618 | query.WriteString("DELETE FROM ") 619 | 620 | // Model only 621 | if len(db.tables) != 1 { 622 | return nil, errors.New("unable to infer table name for Delete. Use Delete(model) or Model(model)") 623 | } 624 | query.WriteString(db.tables[0]) 625 | 626 | // Where (mandatory) 627 | if len(db.whereFields) == 0 { 628 | return nil, errors.New("Where statement is mandatory in Delete") 629 | } 630 | 631 | query.WriteString(db.buildWhere()) 632 | query.WriteString(";") 633 | 634 | qs := query.String() 635 | db.printLog(qs) 636 | return &qs, nil 637 | } 638 | 639 | // buildWhere returns the generated SQL for the WHERE clause. Panics if the Where method hasn't been called 640 | // The generated query uses the PostgreSQL placeholders for query parameters in compiled queries 641 | func (db *Database) buildWhere() string { 642 | var query bytes.Buffer 643 | whereSize := len(db.whereFields) 644 | if whereSize == 0 { 645 | db.panicLog("Please add a Where condition with .Where") 646 | } 647 | 648 | query.WriteString(" WHERE ") 649 | 650 | for j, clause := range db.whereFields { 651 | if strings.Contains(clause, "$") { 652 | query.WriteString(clause) 653 | } else { 654 | query.WriteString(handleIdentifier(clause)) 655 | query.WriteString(" = $") 656 | query.WriteString(strconv.Itoa(db.varCount)) 657 | db.varCount++ 658 | } 659 | if j != whereSize-1 { 660 | query.WriteString(" AND ") 661 | } 662 | } 663 | query.WriteString(" ") 664 | return query.String() 665 | } 666 | 667 | // fieldValue returns an interface{} that's the value of fieldVal if fieldVal is not blank 668 | // if fieldVal is blank and the field has a default value, return the default value 669 | // otherwise returns nil 670 | func fieldValue(fieldVal reflect.Value, structField reflect.StructField) (ret interface{}) { 671 | if isBlank(fieldVal) { 672 | defaultValue := strings.TrimSpace(parseTagSetting(structField.Tag.Get("sql"))["default"]) 673 | if defaultValue != "" { 674 | switch fieldVal.Kind() { 675 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: 676 | if numericValue, err := strconv.ParseInt(defaultValue, 10, 64); err == nil { 677 | if numericValue != fieldVal.Int() { 678 | return fieldVal.Int() 679 | } 680 | return numericValue 681 | } 682 | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: 683 | if numericValue, err := strconv.ParseUint(defaultValue, 10, 64); err == nil { 684 | if numericValue != fieldVal.Uint() { 685 | return fieldVal.Int() 686 | } 687 | return numericValue 688 | } 689 | case reflect.Float32, reflect.Float64: 690 | if floatValue, err := strconv.ParseFloat(defaultValue, 64); err == nil { 691 | if floatValue != fieldVal.Float() { 692 | return fieldVal.Float() 693 | } 694 | return floatValue 695 | } 696 | case reflect.Bool: 697 | if boolValue, err := strconv.ParseBool(defaultValue); err == nil { 698 | if boolValue != fieldVal.Bool() { 699 | return fieldVal.Bool() 700 | } 701 | return boolValue 702 | } 703 | case reflect.String: 704 | return defaultValue 705 | default: 706 | return nil 707 | } 708 | } 709 | return nil 710 | } 711 | return fieldVal.Interface() 712 | } 713 | 714 | // parseTagSetting, imported from jinzhu/gorm 715 | func parseTagSetting(str string) map[string]string { 716 | tags := strings.Split(str, ";") 717 | setting := map[string]string{} 718 | for _, value := range tags { 719 | v := strings.Split(value, ":") 720 | k := strings.TrimSpace(strings.ToLower(v[0])) 721 | if len(v) >= 2 { 722 | setting[k] = strings.Join(v[1:], ":") 723 | } else { 724 | setting[k] = k 725 | } 726 | } 727 | return setting 728 | } 729 | 730 | // clone clones the current Database in order to be thread safe 731 | func (db *Database) clone() *Database { 732 | clone := &Database{ 733 | connection: db.connection, 734 | db: db.db, 735 | rawRows: db.rawRows, 736 | logger: db.logger, 737 | cte: db.cte, 738 | selectFields: db.selectFields, 739 | order: db.order, 740 | limit: db.limit, 741 | offset: db.offset, 742 | varCount: db.varCount, 743 | connectionString: db.connectionString, 744 | listener: db.listener, 745 | } 746 | 747 | clone.tables = make([]string, len(db.tables)) 748 | copy(clone.tables, db.tables) 749 | 750 | clone.joinTables = make([]string, len(db.joinTables)) 751 | copy(clone.joinTables, db.joinTables) 752 | 753 | clone.models = make([]DBModel, len(db.models)) 754 | copy(clone.models, db.models) 755 | 756 | clone.cteSelectValues = make([]interface{}, len(db.cteSelectValues)) 757 | copy(clone.cteSelectValues, db.cteSelectValues) 758 | 759 | clone.updateCreateValues = make([]interface{}, len(db.updateCreateValues)) 760 | copy(clone.updateCreateValues, db.updateCreateValues) 761 | 762 | clone.updateCreateFields = make([]string, len(db.updateCreateFields)) 763 | copy(clone.updateCreateFields, db.updateCreateFields) 764 | 765 | clone.whereValues = make([]interface{}, len(db.whereValues)) 766 | copy(clone.whereValues, db.whereValues) 767 | 768 | clone.whereFields = make([]string, len(db.whereFields)) 769 | copy(clone.whereFields, db.whereFields) 770 | 771 | return clone 772 | } 773 | -------------------------------------------------------------------------------- /igor_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2016-2023 Paolo Galeone. All right reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package igor_test 17 | 18 | import ( 19 | "database/sql" 20 | "errors" 21 | "fmt" 22 | "log" 23 | "os" 24 | "reflect" 25 | "strconv" 26 | "testing" 27 | "time" 28 | 29 | "github.com/galeone/igor" 30 | ) 31 | 32 | var db *igor.Database 33 | var e error 34 | 35 | // Create a user igor and a db igor writeable by igor before to run tests 36 | 37 | // Define models 38 | type Profile struct { 39 | Counter uint64 `igor:"primary_key"` 40 | Website string 41 | Quotes string 42 | Biography string 43 | Github string 44 | Skype string 45 | Jabber string 46 | Yahoo string 47 | Userscript string 48 | Template uint8 49 | MobileTemplate uint8 50 | Dateformat string 51 | Facebook string 52 | Twitter string 53 | Steam string 54 | Push bool 55 | Pushregtime time.Time `sql:"default:(now() at time zone 'utc')"` 56 | Closed bool 57 | } 58 | 59 | // TableName returns the table name associated with the structure 60 | func (Profile) TableName() string { 61 | return "profiles" 62 | } 63 | 64 | // The User type do not have every field with a counter part on the db side 65 | // as you can see in init(). The non present fields, have a default value associated and handled by the DBMS 66 | type User struct { 67 | Counter uint64 `igor:"primary_key"` 68 | Last time.Time `sql:"default:(now() at time zone 'utc')"` 69 | NotifyStory igor.JSON `sql:"default:'{}'::jsonb"` 70 | Private bool 71 | Lang string `sql:"default:en"` 72 | Username string 73 | Password string 74 | Email string 75 | Name string 76 | Surname string 77 | Gender bool 78 | BirthDate time.Time `sql:"default:(now() at time zone 'utc')"` 79 | BoardLang string `sql:"default:en"` 80 | Timezone string 81 | Viewonline bool 82 | RegistrationTime time.Time `sql:"default:(now() at time zone 'utc')"` 83 | // Relation. Manually fill the field when required 84 | Profile Profile `sql:"-"` 85 | // Nullable foreign key relationship 86 | OtherTableID sql.NullInt64 87 | } 88 | 89 | // TableName returns the table name associated with the structure 90 | func (User) TableName() string { 91 | return "users" 92 | } 93 | 94 | type NestMe struct { 95 | ID int64 `igor:"primary_key"` 96 | OverwriteMe int64 97 | SliceOfString []string 98 | SliceOfInt64 []int64 99 | } 100 | 101 | type NestTable struct { 102 | NestMe 103 | OverwriteMe int64 `sql:"-"` 104 | } 105 | 106 | // TableName returns the table name associated with the structure 107 | func (NestTable) TableName() string { 108 | return "nest_table" 109 | } 110 | 111 | func init() { 112 | 113 | if db, e = igor.Connect("user=donotexists dbname=wat sslmode=error"); e == nil { 114 | panic("Connect with a wrong connection string should fail, but succeeded") 115 | } 116 | 117 | connectionString := "host=localhost port=5432 user=igor dbname=igor password=igor sslmode=disable connect_timeout=10" 118 | if db, e = igor.Connect(connectionString); e != nil { 119 | panic(e.Error()) 120 | } 121 | 122 | // Test igor.Wrap 123 | var connection *sql.DB 124 | if connection, e = sql.Open("postgres", connectionString); e != nil { 125 | panic(fmt.Sprintf("unable to connect to the databse with default connection string: %s", connectionString)) 126 | } 127 | 128 | if _, e := igor.Wrap(connection); e != nil { 129 | panic(fmt.Sprintf("Wrap: %s", e)) 130 | } 131 | 132 | // Exec raw query to create tables and test transactions (and Exec) 133 | tx := db.Begin() 134 | e = tx.Exec("DROP TABLE IF EXISTS users CASCADE") 135 | if e != nil { 136 | panic(e.Error()) 137 | } 138 | 139 | e = tx.Exec("DROP TABLE IF EXISTS nest_table CASCADE; DROP TABLE IF EXISTS other_table CASCADE;") 140 | if e != nil { 141 | panic(e.Error()) 142 | } 143 | e = tx.Exec(` 144 | CREATE TABLE other_table( 145 | id bigserial not null primary key, 146 | random_value text 147 | ); 148 | 149 | CREATE TABLE users ( 150 | counter bigserial NOT NULL PRIMARY KEY, 151 | last timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, 152 | notify_story jsonb DEFAULT '{}'::jsonb NOT NULL, 153 | private boolean DEFAULT false NOT NULL, 154 | lang character varying(2) DEFAULT 'en'::character varying NOT NULL, 155 | username character varying(90) NOT NULL, 156 | password character varying(60) NOT NULL, 157 | name character varying(60) NOT NULL, 158 | surname character varying(60) NOT NULL, 159 | email character varying(350) NOT NULL, 160 | gender boolean NOT NULL, 161 | birth_date date NOT NULL, 162 | board_lang character varying(2) DEFAULT 'en'::character varying NOT NULL, 163 | timezone character varying(35) DEFAULT 'UTC'::character varying NOT NULL, 164 | viewonline boolean DEFAULT true NOT NULL, 165 | remote_addr inet DEFAULT '127.0.0.1'::inet NOT NULL, 166 | http_user_agent text DEFAULT ''::text NOT NULL, 167 | registration_time timestamp(0) with time zone DEFAULT now() NOT NULL, 168 | -- NULLABLE FK 169 | other_table_id bigint references other_table(id) 170 | )`) 171 | if e != nil { 172 | panic(e.Error()) 173 | } 174 | 175 | // Exec can work with multiple statements if there are not parameters 176 | // and thus we are not using prepared statements. 177 | e = tx.Exec(`DROP TABLE IF EXISTS profiles CASCADE; 178 | 179 | CREATE TABLE profiles ( 180 | counter bigserial NOT NULL PRIMARY KEY, 181 | website character varying(350) DEFAULT ''::character varying NOT NULL, 182 | quotes text DEFAULT ''::text NOT NULL, 183 | biography text DEFAULT ''::text NOT NULL, 184 | github character varying(350) DEFAULT ''::character varying NOT NULL, 185 | skype character varying(350) DEFAULT ''::character varying NOT NULL, 186 | jabber character varying(350) DEFAULT ''::character varying NOT NULL, 187 | yahoo character varying(350) DEFAULT ''::character varying NOT NULL, 188 | userscript character varying(128) DEFAULT ''::character varying NOT NULL, 189 | template smallint DEFAULT 0 NOT NULL, 190 | dateformat character varying(25) DEFAULT 'd/m/Y, H:i'::character varying NOT NULL, 191 | facebook character varying(350) DEFAULT ''::character varying NOT NULL, 192 | twitter character varying(350) DEFAULT ''::character varying NOT NULL, 193 | steam character varying(350) DEFAULT ''::character varying NOT NULL, 194 | push boolean DEFAULT false NOT NULL, 195 | pushregtime timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL, 196 | mobile_template smallint DEFAULT 1 NOT NULL, 197 | closed boolean DEFAULT false NOT NULL, 198 | template_variables jsonb DEFAULT '{}'::jsonb NOT NULL 199 | )`) 200 | if e != nil { 201 | panic(e.Error()) 202 | } 203 | 204 | e = tx.Exec("ALTER TABLE profiles ADD CONSTRAINT profiles_users_fk FOREIGN KEY(counter) references users(counter) ON DELETE CASCADE") 205 | if e != nil { 206 | panic(e.Error()) 207 | } 208 | 209 | e = tx.Exec("CREATE TABLE nest_table(id bigserial not null PRIMARY KEY, slice_of_string text[] not null, slice_of_int64 bigint[] not null)") 210 | if e != nil { 211 | panic(e.Error()) 212 | } 213 | 214 | if e = tx.Commit(); e != nil { 215 | panic(e.Error()) 216 | } 217 | 218 | logger := log.New(os.Stdout, "igor-log: ", log.LUTC) 219 | db.Log(logger) 220 | } 221 | 222 | // createUser creates a test user (since the primary key is a bigserial, each call creates a new user) 223 | func createUser() User { 224 | user := User{ 225 | Username: "igor", 226 | Password: "please store hashed password", 227 | Name: "Paolo", 228 | Surname: "Galeone", 229 | Email: "please validate the @email . com", 230 | Gender: true, 231 | BirthDate: time.Now(), 232 | } 233 | 234 | if e = db.Create(&user); e != nil { 235 | panic(fmt.Sprintf("Create(&user) filling fields having no default should work, but got: %s\n", e.Error())) 236 | } 237 | return user 238 | } 239 | 240 | // createProfile creates the profile for a test user (since the primary key is a bigserial, each call creates a new user) 241 | func createProfile(id uint64) Profile { 242 | profile := Profile{Counter: id} 243 | if e = db.Create(&profile); e != nil { 244 | panic(fmt.Sprintf("Create(&profile) failed: %s\n", e.Error())) 245 | } 246 | return profile 247 | } 248 | 249 | func TestPanicWhenCallingOnEmptyModel(t *testing.T) { 250 | panicNumber := 0 251 | defer func() { 252 | // catch panic of db.Model(nil) 253 | if r := recover(); r != nil { 254 | if panicNumber == 0 { 255 | t.Log("All right") 256 | panicNumber++ 257 | } else { 258 | t.Error("Too many panics") 259 | } 260 | } 261 | }() 262 | 263 | // must panic 264 | db.Model(nil) 265 | } 266 | 267 | func TestCreateWithNestedStruct(t *testing.T) { 268 | row := NestTable{} 269 | row.ID = 1 270 | row.SliceOfInt64 = []int64{1, 2} 271 | row.SliceOfString = []string{"slice", "support yeah"} 272 | if e = db.Create(&row); e != nil { 273 | t.Errorf("Inserting a new row with a type that uses a nested struct should be possible. But got %v", e) 274 | } 275 | } 276 | 277 | func TestScanWithEmptyResult(t *testing.T) { 278 | // scan with empty result should fail 279 | user := User{} 280 | if e = db.Model(User{}).Where(&User{Counter: 9999}).Scan(&user); e == nil { 281 | t.Error("Scan with no parameters should fail, but succeeded") 282 | } 283 | if !errors.Is(e, sql.ErrNoRows) { 284 | t.Errorf("Scan with no parameters should return EmptyResults error, but got: %s\n", e) 285 | } 286 | 287 | } 288 | 289 | func TestModelCreateUpdatesSelectDelete(t *testing.T) { 290 | if db.Create(&User{}) == nil { 291 | t.Error("Create an user without assign a value to fields that have no default should fail") 292 | } 293 | 294 | user := createUser() 295 | user.Profile = createProfile(user.Counter) 296 | 297 | // First 298 | var p Profile 299 | 300 | if e = db.First(&p, uint64(99)); e == nil { 301 | t.Errorf("Expected First to return an error when there are no rows to fetch, but succeeded: %v", p) 302 | } 303 | 304 | zeroValue := Profile{} 305 | if !reflect.DeepEqual(p, zeroValue) { 306 | t.Errorf("After a failed First, the input parameter should remain unchanged, but are different. Got %v expected %v", p, zeroValue) 307 | } 308 | 309 | if e = db.First(&p, user.Counter); e != nil { 310 | t.Errorf("First failed: %s\n", e.Error()) 311 | } 312 | 313 | if !reflect.DeepEqual(p, user.Profile) { 314 | t.Error("Fetched profile should be deep equals to the created profile") 315 | } 316 | 317 | if user.Lang != "en" { 318 | t.Errorf("Auto update of struct fields having default values on the DBMS should work, but failed. Expected lang=en got %s", user.Lang) 319 | } 320 | 321 | // change user language 322 | user.Lang = "it" 323 | if e = db.Updates(&user); e != nil { 324 | t.Errorf("Updates should work but got: %s\n", e.Error()) 325 | } 326 | 327 | // Scan without parameters should fail 328 | if e = db.Model(User{}).Select("lang").Where(user).Scan(); e == nil { 329 | t.Error("Scan without a parameter should fail, but succeeded") 330 | } 331 | 332 | // Select lang stored in the db 333 | var lang string 334 | if e = db.Model(User{}).Select("lang").Where(user).Scan(&lang); e != nil { 335 | t.Errorf("Scan failed: %s\n", e.Error()) 336 | } 337 | 338 | if lang != "it" { 339 | t.Errorf("The fetched language (%s) is different to the expected one (%s)\n", lang, user.Lang) 340 | } 341 | 342 | if e = db.Delete(&user); e != nil { 343 | t.Errorf("Delete of a user (using the primary key) should work, but got: %s\n", e.Error()) 344 | } 345 | 346 | // Now user is empty. Thus a new .Delete(&user) should fail 347 | if e = db.Delete(&user); e == nil { 348 | t.Error("Delete of an empty object should fail, but succeeded") 349 | } 350 | } 351 | 352 | func TestJoinsTableSelectDeleteWhere(t *testing.T) { 353 | // create 6 user and profiles 354 | var ids []uint64 355 | for i := 0; i < 6; i++ { 356 | user := createUser() 357 | ids = append(ids, user.Counter) 358 | createProfile(user.Counter) 359 | } 360 | 361 | var users []User 362 | if e = db.Model(User{}).Scan(&users); e != nil { 363 | t.Errorf("Scan on structs should work but got: %s\n", e.Error()) 364 | } 365 | if len(users) != 6 { 366 | t.Errorf("Expected 6 users but got: %d\n", len(users)) 367 | } 368 | 369 | var fetchedIds []uint64 370 | if e = db.Model(User{}).Order("counter asc").Pluck("counter", &fetchedIds); e != nil { 371 | t.Errorf("Pluck should work but got: %s\n", e.Error()) 372 | } 373 | 374 | for i := 0; i < 6; i++ { 375 | if ids[i] != fetchedIds[i] { 376 | t.Errorf("Expected %d in position %d but got: %d\n", ids[i], i, fetchedIds[i]) 377 | } 378 | } 379 | 380 | // select $1::int, $2::int, $3::it, counter from users join profiles on user.counter = profiles.counter 381 | // where user.counter = $4 382 | var one, two, three, four int 383 | u := (User{}).TableName() 384 | p := (Profile{}).TableName() 385 | if e = db.Select("?::int, ?::int, ?::int, "+u+".counter", 1, 2, 3). 386 | Table(u). 387 | Joins("JOIN "+p+" ON "+u+".counter = "+p+".counter"). 388 | Where(&User{Counter: 4}).Scan(&one, &two, &three, &four); e != nil { 389 | t.Error(e.Error()) 390 | } 391 | 392 | if one != 1 || two != 2 || three != 3 || four != 4 { 393 | t.Errorf("problem in scanning results, expected 1,2,3,4 got: %d,%d,%d,%d", one, two, three, four) 394 | } 395 | 396 | // Count 397 | var count uint8 398 | if e = db.Model(User{}).Count(&count); e != nil { 399 | t.Errorf("problem counting users: %s\n", e.Error()) 400 | } 401 | 402 | if count != 6 { 403 | t.Errorf("Problem with count. Expected 6 users but counted %d", count) 404 | } 405 | 406 | if e = db.Where("counter IN (?)", ids).Delete(User{}); e != nil { 407 | t.Errorf("delete in range should work but got: %s\n", e.Error()) 408 | } 409 | 410 | // clear slice and pluck again 411 | fetchedIds = nil 412 | _ = db.Model(User{}).Order("counter asc").Pluck("counter", &fetchedIds) 413 | if len(fetchedIds) != 0 { 414 | t.Errorf("delete in range failed, pluck returned ids that must have been deleted") 415 | } 416 | } 417 | 418 | func TestJSON(t *testing.T) { 419 | t.Parallel() 420 | user := createUser() 421 | var emptyJSON = make(igor.JSON) 422 | 423 | if !reflect.DeepEqual(user.NotifyStory, emptyJSON) { 424 | t.Errorf("JSON notifyStory should be empty but got: %s instead of %s\n", user.NotifyStory, emptyJSON) 425 | } 426 | 427 | var ns = make(igor.JSON) 428 | 429 | ns["0"] = struct { 430 | From uint64 `json:"from"` 431 | To uint64 `json:"to"` 432 | Message string `json:"message"` 433 | }{ 434 | From: 1, 435 | To: 1, 436 | Message: "hi bob", 437 | } 438 | ns["numbers"] = 1 439 | ns["test"] = 2 440 | 441 | user.NotifyStory = ns 442 | 443 | if e = db.Updates(&user); e != nil { 444 | t.Errorf("updates should work but got: %s\n", e.Error()) 445 | } 446 | 447 | // To use JSON with json, use: 448 | // printableJSON, _ := json.Marshal(user.NotifyStory) 449 | // fmt.Printf("%s\n", printableJSON) 450 | 451 | var nsNew igor.JSON 452 | if e = db.Model(User{}).Select("notify_story").Where(&user).Scan(&nsNew); e != nil { 453 | t.Errorf("Problem scanning into igor.JSON: %s\n", e.Error()) 454 | } 455 | 456 | if !reflect.DeepEqual(ns, nsNew) { 457 | t.Errorf("fetched notify story is different from the saved one\n%s vs %s", ns, nsNew) 458 | } 459 | 460 | if e = db.Delete(&user); e != nil { 461 | t.Errorf("Delete should work but returned %s", e.Error()) 462 | } 463 | } 464 | 465 | func TestNotifications(t *testing.T) { 466 | t.Parallel() 467 | count := 0 468 | if e = db.Listen("notification_without_payload", func(payload ...string) { 469 | count++ 470 | t.Log("Received notification on channel: notification_without_payload\n") 471 | }); e != nil { 472 | t.Fatalf("Unable to listen on channel: %s\n", e.Error()) 473 | } 474 | 475 | for i := 0; i < 4; i++ { 476 | if e = db.Notify("notification_without_payload"); e != nil { 477 | t.Fatalf("Unable to send notification: %s\n", e.Error()) 478 | } 479 | } 480 | 481 | // wait some time to handle all notifications 482 | time.Sleep(100 * time.Millisecond) 483 | if count != 4 { 484 | t.Errorf("Expected to receive 4 notifications, but counted only: %d\n", count) 485 | } 486 | 487 | // listen on an opened channel should fail 488 | if e = db.Listen("notification_without_payload", func(payload ...string) {}); e == nil { 489 | t.Errorf("Listen on an opened channel should fail, but succeeded\n") 490 | } 491 | 492 | // Handle payload 493 | 494 | // listen on more channels, with payload 495 | count = 0 496 | if e = db.Listen("np", func(payload ...string) { 497 | count++ 498 | t.Logf("channel np: received payload: %s\n", payload) 499 | }); e != nil { 500 | t.Fatalf("Unable to listen on channel: %s\n", e.Error()) 501 | } 502 | 503 | // test sending payload with notify 504 | for i := 0; i < 4; i++ { 505 | if e = db.Notify("np", strconv.Itoa(i)+" payload"); e != nil { 506 | t.Fatalf("Unable to send notification with payload: %s\n", e.Error()) 507 | } 508 | } 509 | 510 | // wait some time to handle all notifications 511 | time.Sleep(100 * time.Millisecond) 512 | if count != 4 { 513 | t.Errorf("Expected to receive 4 notifications, but counted only: %d\n", count) 514 | } 515 | 516 | // test unlisten 517 | if e = db.Unlisten("notification_without_payload"); e != nil { 518 | t.Errorf("Unable to unlisten from notification_without_payload, got: %s\n", e.Error()) 519 | } 520 | 521 | // test UnlistenAll 522 | if e = db.UnlistenAll(); e != nil { 523 | t.Errorf("Unable to unlistenAll, got: %s\n", e.Error()) 524 | } 525 | } 526 | 527 | func TestCTE(t *testing.T) { 528 | t.Parallel() 529 | var ids []uint64 530 | ids = append(ids, createUser().Counter) 531 | ids = append(ids, createUser().Counter) 532 | ids = append(ids, createUser().Counter) 533 | 534 | var usernames []string 535 | e = db.CTE(`WITH full_users_id AS ( 536 | SELECT counter FROM users WHERE name = ? AND counter = any(?))`, "Paolo", ids).Table("full_users_id as fui").Select("username").Joins("JOIN users ON fui.counter = users.counter").Scan(&usernames) 537 | if e != nil { 538 | t.Fatalf(e.Error()) 539 | } 540 | if len(usernames) != 3 { 541 | t.Fatalf("Expected 3, but got: %d\n", len(usernames)) 542 | } 543 | if e = db.Model(User{}).Where("name", "Paolo").Delete(User{}); e != nil { 544 | t.Errorf("Delete should work but returned %s", e.Error()) 545 | } 546 | } 547 | -------------------------------------------------------------------------------- /json.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2016-2023 Paolo Galeone. All right reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Thanks to: https://coussej.github.io/2016/02/16/Handling-JSONB-in-Go-Structs/ 18 | 19 | package igor 20 | 21 | import ( 22 | "database/sql/driver" 23 | "encoding/json" 24 | "errors" 25 | ) 26 | 27 | // JSON is the Go type used to handle JSON PostgreSQL type 28 | type JSON map[string]interface{} 29 | 30 | // Value implements driver.Valuer interface 31 | func (js JSON) Value() (driver.Value, error) { 32 | return json.Marshal(js) 33 | } 34 | 35 | // Scan implements sql.Scanner interface 36 | func (js *JSON) Scan(src interface{}) error { 37 | if src == nil { 38 | *js = make(JSON) 39 | return nil 40 | } 41 | source, ok := src.([]byte) 42 | if !ok { 43 | return errors.New("type assertion .([]byte) failed") 44 | } 45 | 46 | if err := json.Unmarshal(source, js); err != nil { 47 | return err 48 | } 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /notifications.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2016-2023 Paolo Galeone. All right reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package igor 18 | 19 | import ( 20 | "errors" 21 | "fmt" 22 | "strings" 23 | "time" 24 | 25 | "github.com/lib/pq" 26 | ) 27 | 28 | // Listen executes `LISTEN channel`. Uses f to handle received notifications on chanel. 29 | // On error logs error messages (if a logs exists) 30 | func (db *Database) Listen(channel string, f func(payload ...string)) error { 31 | // Create a new listener only if Listen is called for the first time 32 | if db.listener == nil { 33 | db.listenerCallbacks = make(map[string]func(...string)) 34 | 35 | reportProblem := func(ev pq.ListenerEventType, err error) { 36 | if err != nil { 37 | db.printLog(err.Error()) 38 | } 39 | } 40 | db.listener = pq.NewListener(db.connectionString, 10*time.Second, time.Minute, reportProblem) 41 | 42 | if db.listener == nil { 43 | return errors.New("unable to create a new listener") 44 | } 45 | 46 | // detach event handler 47 | go func() { 48 | for { 49 | select { 50 | case notification := <-db.listener.Notify: 51 | // Try 3 times to handle the notification. It may happen that the callback is not yet registered 52 | // and the notification has been sent before the callback has been registered 53 | for i := 0; i < 3; i++ { 54 | if callback, ok := db.listenerCallbacks[notification.Channel]; ok { 55 | go callback(notification.Extra) 56 | break 57 | } 58 | db.printLog(fmt.Sprintf("[%d] Unhandled notification on channel %s with payload %s. Callback not registered\n", i+1, notification.Channel, notification.Extra)) 59 | time.Sleep(5 * time.Second) 60 | } 61 | case <-time.After(90 * time.Second): 62 | go func() { 63 | if db.listener.Ping() != nil { 64 | db.printLog(fmt.Sprintf("Error checking server connection for channel %s\n", channel)) 65 | return 66 | } 67 | }() 68 | } 69 | } 70 | }() 71 | } 72 | 73 | if _, alreadyIn := db.listenerCallbacks[channel]; alreadyIn { 74 | return errors.New("Already subscribed to channel " + channel) 75 | } 76 | 77 | db.listenerCallbacks[channel] = f 78 | 79 | if err := db.listener.Listen(channel); err != nil { 80 | return err 81 | } 82 | 83 | return nil 84 | } 85 | 86 | // Unlisten executes `UNLISTEN channel`. Unregister function f, that was registered with Listen(channel ,f). 87 | func (db *Database) Unlisten(channel string) error { 88 | defer db.clear() 89 | db = db.clone() 90 | if db.listener == nil { 91 | return errors.New("you must create a new listener first, calling Listen(channel)") 92 | } 93 | 94 | if channel == "*" { 95 | return db.listener.UnlistenAll() 96 | } 97 | return db.listener.Unlisten(channel) 98 | } 99 | 100 | // UnlistenAll executes `UNLISTEN *`. Thus do not receive any notification from any channel 101 | func (db *Database) UnlistenAll() error { 102 | defer db.clear() 103 | db = db.clone() 104 | return db.Unlisten("*") 105 | } 106 | 107 | // Notify sends a notification on channel, optional payloads are joined together and comma separated 108 | func (db *Database) Notify(channel string, payload ...string) error { 109 | defer db.clear() 110 | db = db.clone() 111 | pl := strings.Join(payload, ",") 112 | if len(pl) > 0 { 113 | return db.Exec("SELECT pg_notify(?, ?)", channel, pl) 114 | } 115 | return db.Exec("NOTIFY " + handleIdentifier(channel)) 116 | } 117 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2016-2023 Paolo Galeone. All right reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package igor 18 | 19 | import ( 20 | "database/sql" 21 | "log" 22 | 23 | "github.com/lib/pq" 24 | ) 25 | 26 | // DBModel is the interface implemented by every struct that is a relation on the DB 27 | type DBModel interface { 28 | //TableName returns the associated table name 29 | TableName() string 30 | } 31 | 32 | // TxDB Interface to wrap methods common to *sql.Tx and *sql.DB 33 | type TxDB interface { 34 | Prepare(query string) (*sql.Stmt, error) 35 | Exec(query string, args ...interface{}) (sql.Result, error) 36 | Query(query string, args ...interface{}) (*sql.Rows, error) 37 | QueryRow(query string, args ...interface{}) *sql.Row 38 | } 39 | 40 | // Database is IGOR 41 | type Database struct { 42 | connection TxDB 43 | db TxDB 44 | rawRows *sql.Rows 45 | tables []string 46 | joinTables []string 47 | models []DBModel 48 | logger *log.Logger 49 | cte string 50 | cteSelectValues []interface{} 51 | selectFields string 52 | updateCreateValues []interface{} 53 | updateCreateFields []string 54 | whereValues []interface{} 55 | whereFields []string 56 | order string 57 | limit int 58 | offset int 59 | varCount int 60 | connectionString string 61 | listener *pq.Listener 62 | listenerCallbacks map[string]func(...string) 63 | } 64 | --------------------------------------------------------------------------------