This library is still in development and the API may change, see the roadmap for more information.
This is not an ORM. The client is just a tiny simple wrapper around database/sql
that provides support for simple querying pattern. It supports and provides
extra utilities that can be used with that makes it actually useful.
If you need anything more than what the API provides, you can use the Raw method.
- No unnecessary extra abstraction, should be compatible with standard
database/sql - Opt-in for features that make common complex query patterns simple
- Be opinionated and enforce some usage patterns best practices
- Minimum use of
reflect - Some common utilities for everyday usage like
sqlxscan while still being compatible with standardsqllib - GraphQL (+Relay Connection) cursor pagination
- Limit and offset pagination built in and enforced
If your DML is not a simple query that is not supported, just use the Raw method instead.
We'll keep it intentionally simple:
- No JOINS
- No ORM features
- No transaction support to build complex queries
- Honesty most non-trival query patterns are not added
If you think you need more patterns/utilities/methods/helpers, and it's actually useful that is hard to do without a wrapper, feel free to open a PR.
Get the library with:
go get "github.com/sourcesoft/ssql"First create the client by connecting to a database of your choice.
package main
import (
"context"
"database/sql"
_ "github.com/lib/pq"
"github.com/sourcesoft/ssql"
)
func main() {
...
ctx := context.Background()
psqlInfo := fmt.Sprintf("host=%s port=%d user=%s "+
"password=%s dbname=%s sslmode=disable", ...)
dbCon, err := sql.Open("postgres", psqlInfo)
if err != nil {
panic(err)
}
// You can also pass nil as options.
options := ssql.Options{
Tag: "sql", // Struct tag used for SQL field name (defaults to 'sql').
LogLevel: ssql.LevelDebug, // By default loggin is disabled.
MainSortField: "created_at",
MainSortDirection: ssql.DirectionDesc,
}
client, err := ssql.NewClient(ctx, dbCon, &options)
if err != nil {
panic(err)
}
...Note that we have passed MainSortField and MainSortDirection options which is the default
field and sorting direction used for pagination. SSQL library enforces these fields to be specified in either
in the client Options or you can pass them as part of the query options to Find method to override the default.
Only Find method requires these two options, without them it will return early with an error.
See queries like FindOne or other APIs to see how to use the client to execute queries.
When using insert, you will be passing the variable of a struct, which ssql uses reflect package to
extract the sql tag by default (you can customize it in the client options).
type User struct {
ID *string `json:"id,omitempty" sql:"id" graph:"id" rel:"pk"`
Username *string `json:"username,omitempty" sql:"username" graph:"username"`
Email *string `json:"email,omitempty" sql:"email" graph:"email"`
EmailVerified *bool `json:"emailVerified,omitempty" sql:"email_verified" graph:"emailVerified"`
Active *bool `json:"active,omitempty" sql:"active" graph:"active"`
UpdatedAt *int `json:"updatedAt,omitempty" sql:"updated_at" graph:"updatedAt"`
CreatedAt *int `json:"createdAt,omitempty" sql:"created_at" graph:"createdAt"`
DeletedAt *int `json:"deletedAt,omitempty" sql:"deleted_at" graph:"deletedAt"`
}
// Sample record.
fID := "7f8d1637-ca82-4b1b-91dc-0828c98ebb34"
fUsername := "test"
fEmail := "test@domain.com"
ts := 1673899847
// Insert a new row.
newUser := User{
ID: &fID,
Username: &fUsername,
Email: &fEmail,
UpdatedAt: &ts,
CreatedAt: &ts,
}
// You can pass any struct as is.
_, err = client.Insert(ctx, "user", newUser)
if err != nil {
panic(err)
}Having the ID of the record you can simply update it by passing the struct variable.
// Update row by ID.
fEmail = "new@test.com"
newUser.Email = &fEmail
res, err := client.UpdateOne(ctx, "user", "id", fID, newUser)
if err != nil {
log.Error().Err(err).Msg("Postgres update user error")
panic(err)
}
if count, err := (*res).RowsAffected(); count < 1 {
log.Error().Err(err).Msg("Postgres update user error, or not found")
panic(err)
}You can also create a condition array to update all the matching fields.
...
// Add some custom conditions.
conds := []*ssql.ConditionPair{{
Field: "active",
Value: true,
Op: ssql.OPEqual, // '=' operator.
}}
fFalse = false
newUser.Active = &fFalse
res, err := client.Update(ctx, "user", conds, updatedUser)
...res, err = client.DeleteOne(ctx, "user", "id", fID)
if err != nil {
log.Error().Err(err).Msg("Cannot delete user by ID from Postgres")
panic(err)
}
if count, err := (*res).RowsAffected(); count < 1 {
log.Error().Err(err).Msg("User not found")
panic(err)
}You can also create a condition array to delete all the matching fields.
...
// Add some custom conditions.
conds := []*ssql.ConditionPair{{
Field: "active",
Value: false,
Op: ssql.OPEqual, // '=' operator.
}}
res, err = client.Delete(ctx, "user", conds)
if err != nil {
log.Error().Err(err).Msg("Cannot delete user")
panic(err)
}
...Having a primary key and finding your record using that is a common use case.
You can also pass the key (id in the following example) to look up.
rows, err := client.FindOne(ctx, "user", "id", "7f8d1637-ca82-4b1b-91dc-0828c98ebb34")
if err != nil {
panic(err)
}
// You can scan all the fields to the struct directly.
var resp User
if err := ssql.ScanOne(&resp, rows); err != nil {
panic(err)
}
logger.Print("user %+v", resp)Check the examples folder to see more.
Let's see how a minimal simple Find query looks like.
First build the query options
// setting up pagination.
limit := 10
params := ssql.Params{
OffsetParams: &ssql.OffsetParams{
Limit: &limit,
// There's also offset available.
},
// There's also 'order' available.
// There's also 'cursor' pagination available.
}
opts := ssql.SQLQueryOptions{
Table: "user",
WithTotalCount: shouldReturnTotalCount,
Params: ¶ms,
MainSortField: "created_at", // Used for cursor/offset pagination
MainSortDirection: ssql.DirectionDesc,
}Note SSQL library enforces MainSortField and MainSortDirection fields to be specified in either
the client Options (as default) or you can pass them as part of the query options to Find method here to override the default.
Only Find method requires these two options, without them it will return early with an error.
If you are unsure what field to use for MainSortField, you can choose the auto-increment ID or (if the PK is sth like GUID) you can
choose a field that has epoch timestamp on it, eg: created_at or updated_at.
Current MainSortField only supports integer values, support for timestamp SQL types will be added soon.
For the rest of the documentation we will not mention these two options, assuming you have specified them at the top level in client options which is used as a fallback default config.
Run the query by using the Find method.
// Executing the query.
result, err := client.Find(ctx, &opts)
if err != nil {
log.Error().Err(err).Msg("Cannot find users")
panic(err)
}Scan the rows into your arrays of custom structs.
// Reading through the results.
var users []User
for result.Rows.Next() {
var user User
if err := ssql.ScanRow(&user, result.Rows); err != nil {
log.Error().Err(err).Msg("Cannot scan users")
}
users = append(users, user)
}ssql also provides a powerful SuperScan function that takes care of Relay Connection and cursor
pagination complexity. It also does the Scan itself for us and then spits out a PageInfo object:
type PageInfo struct {
HasPreviousPage *bool `json:"hasPreviousPage,omitempty"`
HasNextPage *bool `json:"hasNextPage,omitempty"`
StartCursor *string `json:"startCursor,omitempty"`
EndCursor *string `json:"endCursor,omitempty"`
TotalCount *int `json:"-"`
}As you see, it's very similar to Relay connection type, in fact you can just use it as is in your GraphQL response.
Most of the code is same up to running the Find method.
// setting up pagination.
limit := 10
params := ssql.Params{
CursorParams: &ssql.CursorParams{
First: &limit, // Get first 10 rows only.
},
}
opts := ssql.SQLQueryOptions{
Table: "user",
WithTotalCount: shouldReturnTotalCount,
Params: ¶ms,
}
// Executing the query.
result, err := client.Find(ctx, &opts)
if err != nil {
log.Error().Err(err).Msg("Cannot find users")
panic(err)
}Note that we used CursorParams setting First option instead of offset. It's
recommended to use CursorParams instead of OffsetParams as options, this allows
the return PageInfo of SuperScan to return a more correct and complete result.
Use the returned result and create a variable to store your list in. Pass
both to SuperScan method and that's it.
var users []User
pageInfo, err := ssql.SuperScan(&users, result)
if err != nil {
log.Error().Err(err).Msg("SuperScan failed")
panic(err)
}You can use offset and limit pagination.
limit := 10
offset := 3
params := ssql.Params{
OffsetParams: &ssql.OffsetParams{
Limit: &limit,
Offset: &offset
},
}
// Same as before use the params in query options argument.
opts := ssql.SQLQueryOptions{
Table: "user",
Params: ¶ms,
}
result, err := client.Find(ctx, &opts)If you use SuperScan in your queries, you can then use the PageInfo object that has
the StartCursor and EndCursor.
// From previous query
var users []User
pageInfo, err := ssql.SuperScan(&users, result)
if err != nil {
log.Error().Err(err).Msg("SuperScan failed")
panic(err)
}
// Now that we have pageInfo object, we can use the next cursor.
params := ssql.Params{
CursorParams: &ssql.CursorParams{
After: pageInfo.EndCursor, // If you have the previous cursor, you can pass it here to continue the pagination.
First: 10, // Get first 10 results (works like LIMIT).
Last: nil, // Work same as first (LIMIT) but reverses the order of querying.
},
}
// Same as before use the params in query options argument.
opts := ssql.SQLQueryOptions{
Table: "user",
Fields: dbFields,
WithTotalCount: shouldReturnTotalCount,
Params: ¶ms,
Conditions: conds,
}
result, err := client.Find(ctx, &opts)You can have one or many sorting configs. The order matters.
params := ssql.Params{
SortParams = []*ssql.SortParams{{
Direction: "asc",
Field: "hits",
}}
}
// Same as before use the params in query options argument.
opts := ssql.SQLQueryOptions{
Table: "user",
Fields: dbFields,
WithTotalCount: shouldReturnTotalCount,
Params: ¶ms,
Conditions: conds,
}
result, err := client.Find(ctx, &opts)
You can set one or many conditions which will translate to WHERE clause in the final query.
...
userIDs := []string{...} // some list of user IDs
// Add some custom conditions.
conds := []*ssql.ConditionPair{
{
Field: "active",
Value: true,
Op: ssql.OPEqual, // '=' operator.
},
{
Field: "user_id",
Value: userIDs,
Op: ssql.OPLogicalIn, // Example of "IN" operator.
},
}
// Same as before use the params in query options argument.
opts := ssql.SQLQueryOptions{
Table: "user",
Fields: dbFields,
WithTotalCount: shouldReturnTotalCount,
Params: ¶ms,
Conditions: conds,
}
result, err := client.Find(ctx, &opts)As mentioned in SuperScan section, you can use it to return a pageInfo object for GraphQL Relay Connections:
type PageInfo struct {
HasPreviousPage *bool `json:"hasPreviousPage,omitempty"`
HasNextPage *bool `json:"hasNextPage,omitempty"`
StartCursor *string `json:"startCursor,omitempty"`
EndCursor *string `json:"endCursor,omitempty"`
TotalCount *int `json:"-"`
}Using the result, call the SuperScan to get you the PageInfo object.
// First let's get a list of rows.
result, err := rp.Client.Find(ctx, &opts)
if err != nil {
log.Logger(ctx).Error().Err(err).Msg("Cannot find users")
panic(err)
}
var users []User
pageInfo, err := ssql.SuperScan(&users, result)
if err != nil {
log.Error().Err(err).Msg("SuperScan failed")
panic(err)
}If the provided API doesn't satisfy the usage you need, feel free to just run a raw custom query.
...
// Add some custom conditions.
values := []interface{}{true}
raw := "SELECT * FROM \"user\" WHERE active = $1"
res, err = client.Raw(ctx, raw, values)
if err != nil {
log.Error().Err(err).Msg("Cannot execute raw query")
panic(err)
}
...Much of the headache working with SQL in golang is to have a mapping between your struct fields and SQL columns.
Imagine we have type in Go that describes our user object.
type User struct {
ID *string `json:"id,omitempty" sql:"id"`
Username *string `json:"username,omitempty" sql:"username"`
Email *string `json:"email,omitempty" sql:"email"`
EmailVerified *bool `json:"emailVerified,omitempty" sql:"email_verified"`
Active *bool `json:"active,omitempty" sql:"active"`
UpdatedAt *int `json:"updatedAt,omitempty" sql:"updated_at"`
CreatedAt *int `json:"createdAt,omitempty" sql:"created_at"`
DeletedAt *int `json:"deletedAt,omitempty" sql:"deleted_at"`
}
user := User{
ID: "...",
Username: "...",
...
}We want to insert the above user record to our PostgreSQL database.
// You can pass any struct as is.
_, err = client.Insert(ctx, "user", newUser)
if err != nil {
panic(err)
}This is because ssql internally uses reflect package in this case during runtime to get the tags.
However for find records (using Find method) with query options, you will need to pass the SQL fields themselves as a argument to the find
method. This is because Find internally avoids using reflect and expects the plain text fields to be determined as query options.
To do this you can use ExtractStructMappings(tags []string, s interface{}) helper function that simply returns type of:
type TagMappings map[string]map[string]stringIn the following example the first-level map is the tag name requested, and the second-level is the either by tags or by field.
type User struct {
...
CreatedAt *int `json:"createdAt,omitempty" sql:"created_at"`
...
}
// Request tags for mappings for `sql` and `json`.
var userMappingsByTags, userMappingsByFields = ssql.ExtractStructMappings([]string{"sql", "json"}, model.User{})
// Get field name by tag name we know.
userMappingsByTags["sql"]["created_at"] // will return `CreatedAt` (struct field name)
userMappingsByTags["json"]["createdAt"] // will return `CreatedAt` (struct field name)
// Get json/sql tag by field name
userMappingsByField["sql"]["CreatedAt"] // will return `created_at` (sql tag value)
userMappingsByField["json"]["CreatedAt"] // will return `createdAt` (json tag value)Knowing this we can use these helper functions to run the expensive extraction of tags/fields only one time during startup
instead of for each find since it uses reflect internally.
To do this call ExtractStructMappings outside of your insert function/method in the same file for your type, then use it to
populate the fields array.
Note that even though we recommend calling this helper function outside of frequently called functions/methods,
but ExtractStructMappings still internally caches the result of heavy reflects operations so technically each type in your
code base will only use reflect once.
// Calling this one time only outside of our function.
var _, userMappingsByFields = ssql.ExtractStructMappings([]string{"rel", "sql"}, model.User{})
...
func MyInsertRowFunction(user *User) {
dbUserFields := map[string]bool{}
// Let's convert our struct type to a map of string that keys are the SQL column names.
for fieldName := range user {
dbUserFields[userMappingsByFields["sql"][fieldName]] = true
}
...
opts := ssql.SQLQueryOptions{
Table: "user",
Fields: dbFields, // Fields expect a map[string]bool which we now have.
...
}
// Executing the query.
result, err := client.Find(ctx, &opts)
if err != nil {
log.Error().Err(err).Msg("Cannot find users")
panic(err)
}
}Use ScanOne if you are selecting/expecting one result. You can pass your struct pointer as is to scan it
without mapping individual fields like the standard database/sql library forces you to.
rows, err := client.FindOne(ctx, "user", "id", "7f8d1637-ca82-4b1b-91dc-0828c98ebb34")
if err != nil {
log.Error().Err(err).Msg("Cannot select by ID")
panic(err)
}
var resp User
if err := ssql.ScanOne(&resp, rows); err != nil {
log.Error().Err(err).Msg("Cannot get resp by ID from Postgres")
panic(err)
}Use ScanRow inside the rows.Next() loop to populate your users array.
result, err := client.Find(ctx, &opts)
if err != nil {
log.Error().Err(err).Msg("Cannot find users")
panic(err)
}
// Reading through the results.
var users []User
for result.Rows.Next() {
var user User
if err := ssql.ScanRow(&user, result.Rows); err != nil {
log.Error().Err(err).Msg("Cannot scan users")
}
users = append(users, user)
}- Add support for OR operator.
- Add tests.
- Add support for LIKE and NOT logical operators.
- Add support for timestamp types to be used as cursor fields (not just epoch).
- Add full example of GraphQL usage.
- Add mock package.
- Add benchmarks.
- Thanks to scany,
ScanRowandScanOneare actually just wrappers around scany library. - Thanks to this comment.