core

package
v0.17.23 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 12, 2022 License: Apache-2.0 Imports: 45 Imported by: 1

Documentation

Overview

Package core provides an API to include and use the GraphJin compiler with your own code. For detailed documentation visit https://graphjin.com

Example usage:

package main

import (
	"database/sql"
	"fmt"
	"time"
	"github.com/dosco/graphjin/core"
	_ "github.com/jackc/pgx/v4/stdlib"
)

func main() {
	db, err := sql.Open("pgx", "postgres://postgrs:@localhost:5432/example_db")
	if err != nil {
		log.Fatal(err)
	}

	gj, err := core.NewGraphJin(nil, db)
	if err != nil {
		log.Fatal(err)
	}

	query := `
		query {
			posts {
			id
			title
		}
	}`

	ctx = context.WithValue(ctx, core.UserIDKey, 1)

	res, err := gj.GraphQL(ctx, query, nil)
	if err != nil {
		log.Fatal(err)
	}

}
Example (BlockQueryWithRoles)
gql := `query {
		users {
			id
			full_name
			email
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
conf.RolesQuery = `SELECT * FROM users WHERE id = $user_id`
conf.Roles = []core.Role{{Name: "disabled_user", Match: "disabled = true"}}

err := conf.AddRoleTable("disabled_user", "users", core.Query{Block: true})
if err != nil {
	panic(err)
}

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 50)
res, err := gj.GraphQL(ctx, gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": null}
Example (BulkInsert)
gql := `mutation {
		users(insert: $data) {
			id
			email
		}
	}`

vars := json.RawMessage(`{
		"data": [{
			"id": 1002,
			"email": "user1002@test.com",
			"full_name": "User 1002",
			"stripe_id": "payment_id_1002",
			"category_counts": [{"category_id": 1, "count": 400},{"category_id": 2, "count": 600}]
		},
		{
			"id": 1003,
			"email": "user1003@test.com",
			"full_name": "User 1003",
			"stripe_id": "payment_id_1003",
			"category_counts": [{"category_id": 2, "count": 400},{"category_id": 3, "count": 600}]
		}]
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": [{"id": 1002, "email": "user1002@test.com"}, {"id": 1003, "email": "user1003@test.com"}]}
Example (InlineInsert)
gql := `mutation {
		users(insert: { id: $id, email: $email, full_name: $full_name }) {
			id
			email
			full_name
		}
	}`

vars := json.RawMessage(`{
		"id": 1007,
		"email": "user1007@test.com",
		"full_name": "User 1007"
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": [{"id": 1007, "email": "user1007@test.com", "full_name": "User 1007"}]}
Example (InlineInsertWithValidation)
gql := `mutation 
		@constraint(variable: "email", format: "email", min: 1, max: 100)
		@constraint(variable: "full_name", requiredIf: { id: 1007 } ) {
		users(insert: { id: $id, email: $email, full_name: $full_name }) {
			id
			email
			full_name
		}
	}`

vars := json.RawMessage(`{
		"id": 1007,
		"email": "not_an_email"
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

Validation Failed: $full_name: Key: '' Error:Field validation for '' failed on the 'required_if' tag
Validation Failed: $email: Key: '' Error:Field validation for '' failed on the 'email' tag
validation failed
Example (Insert)
gql := `mutation {
		users(insert: $data) {
			id
			email
		}
	}`

vars := json.RawMessage(`{
		"data": {
			"id": 1001,
			"email": "user1001@test.com",
			"full_name": "User 1001",
			"stripe_id": "payment_id_1001",
			"category_counts": [{"category_id": 1, "count": 400},{"category_id": 2, "count": 600}]
		}
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": [{"id": 1001, "email": "user1001@test.com"}]}
Example (InsertIntoMultipleRelatedTables1)
gql := `mutation {
		purchases(insert: $data) {
			quantity
			customer {
				id
				full_name
				email
			}
			product {
				id
				name
				price
			}
		}
	}`

vars := json.RawMessage(`{
		"data": {
			"id": 3001,
			"quantity": 5,
			"customer": {
				"id": 1004,
				"email": "user1004@test.com",
				"full_name": "User 1004",
				"stripe_id": "payment_id_1004",
				"category_counts": [{"category_id": 1, "count": 400},{"category_id": 2, "count": 600}]
			},
			"product": {
				"id": 2002,
				"name": "Product 2002",
				"description": "Description for product 2002",
				"price": 2012.5,
				"tags": ["Tag 1", "Tag 2"],
				"category_ids": [1, 2, 3, 4, 5],
				"owner_id": 3
			}
		}
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"purchases": [{"product": {"id": 2002, "name": "Product 2002", "price": 2012.5}, "customer": {"id": 1004, "email": "user1004@test.com", "full_name": "User 1004"}, "quantity": 5}]}
Example (InsertIntoMultipleRelatedTables2)
gql := `mutation {
		users(insert: $data) {
			id
			full_name
			email
			products {
				id
				name
				price
			}
		}
	}`

vars := json.RawMessage(`{
		"data": {
			"id": 1005,
			"email": "user1005@test.com",
			"full_name": "User 1005",
			"stripe_id": "payment_id_1005",
			"category_counts": [{"category_id": 1, "count": 400},{"category_id": 2, "count": 600}],
			"products": {
				"id": 2003,
				"name": "Product 2003",
				"description": "Description for product 2003",
				"price": 2013.5,
				"tags": ["Tag 1", "Tag 2"],
				"category_ids": [1, 2, 3, 4, 5],
				"owner_id": 3
			}
		}
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": [{"id": 1005, "email": "user1005@test.com", "products": [{"id": 2003, "name": "Product 2003", "price": 2013.5}], "full_name": "User 1005"}]}
Example (InsertIntoMultipleRelatedTables3)
gql := `mutation {
		products(insert: $data) {
			id
			name
			owner {
				id
				full_name
				email
			}
		}
	}`

vars := json.RawMessage(`{
		"data": {
			"id": 2004,
			"name": "Product 2004",
			"description": "Description for product 2004",
			"price": 2014.5,
			"tags": ["Tag 1", "Tag 2"],
			"category_ids": [1, 2, 3, 4, 5],
			"owner": {
				"id": 1006,
				"email": "user1006@test.com",
				"full_name": "User 1006",
				"stripe_id": "payment_id_1006",
				"category_counts": [{"category_id": 1, "count": 400},{"category_id": 2, "count": 600}]
			}
		}
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"id": 2004, "name": "Product 2004", "owner": {"id": 1006, "email": "user1006@test.com", "full_name": "User 1006"}}]}
Example (InsertIntoRecursiveRelationship)
gql := `mutation {
		comments(insert: $data, where: { id: { in: [5001, 5002] }}) {
			id
			reply_to_id
		}
	}`

vars := json.RawMessage(`{
		"data": {
			"id": 5001,
			"body": "Comment body 5001",
			"created_at": "now",
			"comments": {
				"find": "children",
				"id": 5002,
				"body": "Comment body 5002",
				"created_at": "now"	
			}
		}
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"comments": [{"id": 5001, "reply_to_id": null}, {"id": 5002, "reply_to_id": 5001}]}
Example (InsertIntoRecursiveRelationshipAndConnectTable1)
gql := `mutation {
		comments(insert: $data, where: { id: { in: [5, 5003] }}) {
			id
			reply_to_id
		}
	}`

vars := json.RawMessage(`{
		"data": {
			"id": 5003,
			"body": "Comment body 5003",
			"created_at": "now",
			"comments": {
				"find": "children",
				"connect": { "id": 5 }
			}
		}
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"comments": [{"id": 5003, "reply_to_id": null}, {"id": 5, "reply_to_id": 5003}]}
Example (InsertIntoRecursiveRelationshipAndConnectTable2)
gql := `mutation {
  	comments(insert: $data) @object {
			id
			product {
				id
			}
			commenter {
				id
			}
			comments(find: "children") {
				id
			}
  	}
  }`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

vars := json.RawMessage(`{
			"data": {
				"id":  5004,
				"body": "Comment body 5004",
				"created_at": "now",
				"comments": {
					"connect": { "id": 6 },
					"find": "children"
				},
				"product": {
					"connect": { "id": 26 }
				},
				"commenter":{
					"connect":{ "id": 3 }
				}
			}
		}`)

ctx := context.WithValue(context.Background(), core.UserIDKey, 50)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"comments": {"id": 5004, "product": {"id": 26}, "comments": [{"id": 6}], "commenter": {"id": 3}}}
Example (InsertIntoTableAndConnectToRelatedTableWithArrayColumn)
gql := `mutation {
		products(insert: $data) {
			id
			name
			categories {
				id
				name
			}
		}
	}`

vars := json.RawMessage(`{
		"data": {
			"id": 2006,
			"name": "Product 2006",
			"description": "Description for product 2006",
			"price": 2016.5,
			"tags": ["Tag 1", "Tag 2"],
			"categories": {
				"connect": { "id": [1, 2, 3, 4, 5] }
			}
		}
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
conf.Tables = []core.Table{
	{Name: "products", Columns: []core.Column{{Name: "category_ids", ForeignKey: "categories.id"}}},
}

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"id": 2006, "name": "Product 2006", "categories": [{"id": 1, "name": "Category 1"}, {"id": 2, "name": "Category 2"}, {"id": 3, "name": "Category 3"}, {"id": 4, "name": "Category 4"}, {"id": 5, "name": "Category 5"}]}]}
Example (InsertIntoTableAndConnectToRelatedTables)
gql := `mutation {
		products(insert: $data) {
			id
			name
			owner {
				id
				full_name
				email
			}
		}
	}`

vars := json.RawMessage(`{
		"data": {
			"id": 2005,
			"name": "Product 2005",
			"description": "Description for product 2005",
			"price": 2015.5,
			"tags": ["Tag 1", "Tag 2"],
			"category_ids": [1, 2, 3, 4, 5],
			"owner": {
				"connect": { "id": 6 }
			}
		}
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"id": 2005, "name": "Product 2005", "owner": {"id": 6, "email": "user6@test.com", "full_name": "User 6"}}]}
Example (InsertWithCamelToSnakeCase)
gql := `mutation {
		products(insert: $data) {
			id
			name
			owner {
				id
				email
			}
		}
	}`

vars := json.RawMessage(`{
		"data": {
			"id": 2007,
			"name": "Product 2007",
			"description": "Description for product 2007",
			"price": 2011.5,
			"tags": ["Tag 1", "Tag 2"],
			"categoryIds": [1, 2, 3, 4, 5]
		}
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true, EnableCamelcase: true})
err := conf.AddRoleTable("user", "products", core.Insert{
	Presets: map[string]string{"ownerId": "$user_id"},
})

if err != nil {
	panic(err)
}

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"id": 2007, "name": "Product 2007", "owner": {"id": 3, "email": "user3@test.com"}}]}
Example (InsertWithPresets)
gql := `mutation {
		products(insert: $data) {
			id
			name
			owner {
				id
				email
			}
		}
	}`

vars := json.RawMessage(`{
		"data": {
			"id": 2001,
			"name": "Product 2001",
			"description": "Description for product 2001",
			"price": 2011.5,
			"tags": ["Tag 1", "Tag 2"],
			"category_ids": [1, 2, 3, 4, 5]
		}
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
err := conf.AddRoleTable("user", "products", core.Insert{
	Presets: map[string]string{"owner_id": "$user_id"},
})

if err != nil {
	panic(err)
}

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"id": 2001, "name": "Product 2001", "owner": {"id": 3, "email": "user3@test.com"}}]}
Example (Query)
gql := `query {
		products(limit: 3) {
			id
			owner {
				id
				fullName: full_name
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"id": 1, "owner": {"id": 1, "fullName": "User 1"}}, {"id": 2, "owner": {"id": 2, "fullName": "User 2"}}, {"id": 3, "owner": {"id": 3, "fullName": "User 3"}}]}
Example (QueryByID)
gql := `query {
		products(id: $id) {
			id
			name
		}
	}`

vars := json.RawMessage(`{
		"id": 2
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": {"id": 2, "name": "Product 2"}}
Example (QueryBySearch)
gql := `query {
		products(search: $query, limit: 5) {
			id
			name
		}
	}`

vars := json.RawMessage(`{
		"query": "\"Product 3\""
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"id": 3, "name": "Product 3"}]}
Example (QueryChildrenWithParent)
gql := `query {
		products(limit: 2) {
			name
			price
			owner {
				email
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"name": "Product 1", "owner": {"email": "user1@test.com"}, "price": 11.5}, {"name": "Product 2", "owner": {"email": "user2@test.com"}, "price": 12.5}]}
Example (QueryManyToManyViaJoinTable1)
gql := `query {
		products(limit: 2) {
			name
			customer {
				email
			}
			owner {
				email
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"name": "Product 1", "owner": {"email": "user1@test.com"}, "customer": [{"email": "user2@test.com"}]}, {"name": "Product 2", "owner": {"email": "user2@test.com"}, "customer": [{"email": "user3@test.com"}]}]}
Example (QueryManyToManyViaJoinTable2)
gql := `query {
		users {
			email
			full_name
			products {
				name
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true, DefaultLimit: 2})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": [{"email": "user1@test.com", "products": [{"name": "Product 1"}], "full_name": "User 1"}, {"email": "user2@test.com", "products": [{"name": "Product 2"}], "full_name": "User 2"}]}
Example (QueryParentAndChildrenViaArrayColumn)
gql := `
	query {
		products(limit: 2) {
			name
			price
			categories {
				id
				name
			}
		}
		categories {
			name
			products {
				name
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true, DefaultLimit: 2})
conf.Tables = []core.Table{{
	Name: "products",
	Columns: []core.Column{
		{Name: "category_ids", ForeignKey: "categories.id", Array: true},
	},
},
}

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"name": "Product 1", "price": 11.5, "categories": [{"id": 1, "name": "Category 1"}, {"id": 2, "name": "Category 2"}]}, {"name": "Product 2", "price": 12.5, "categories": [{"id": 1, "name": "Category 1"}, {"id": 2, "name": "Category 2"}]}], "categories": [{"name": "Category 1", "products": [{"name": "Product 1"}, {"name": "Product 2"}]}, {"name": "Category 2", "products": [{"name": "Product 1"}, {"name": "Product 2"}]}]}
Example (QueryParentsWithChildren)
gql := `query {
		users(order_by: { id: asc }, limit: 2) {
			email
			products {
				name
				price
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": [{"email": "user1@test.com", "products": [{"name": "Product 1", "price": 11.5}]}, {"email": "user2@test.com", "products": [{"name": "Product 2", "price": 12.5}]}]}
Example (QueryWithAggregation)
gql := `query {
		products(where: { id: { lteq: 100 } }) {
			count_id
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"count_id": 100}]}
Example (QueryWithAggregationBlockedColumn)
gql := `query {
		products {
			sum_price
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
err := conf.AddRoleTable("anon", "products", core.Query{
	Columns: []string{"id", "name"},
})
if err != nil {
	panic(err)
}

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

column blocked: sum (anon)
Example (QueryWithAlternateFieldNames)
gql := `query {
		comments(limit: 2) {
			id
			commenter {
				email
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"comments": [{"id": 1, "commenter": {"email": "user1@test.com"}}, {"id": 2, "commenter": {"email": "user2@test.com"}}]}
Example (QueryWithCamelToSnakeCase)
gql := `query {
		hotProducts(where: { productID: { eq: 55 } }) {
			countryCode
			countProductID
			products {
				id
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true, EnableCamelcase: true})
conf.Tables = []core.Table{
	{
		Name: "hot_products",
		Columns: []core.Column{
			{Name: "product_id", Type: "int", ForeignKey: "products.id"},
		},
	},
}

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"hotProducts": [{"products": {"id": 55}, "countryCode": "US", "countProductID": 1}]}
Example (QueryWithCursorPagination)
gql := `query {
		products(
			where: { id: { lesser_or_equals: 100 } }
			first: 3
			after: $cursor
			order_by: { price: desc }) {
			name
		}
		products_cursor
	}`

vars := json.RawMessage(`{"cursor": null}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, vars, nil)
if err != nil {
	fmt.Println(err)
	return
}

type result struct {
	Products json.RawMessage `json:"products"`
	Cursor   string          `json:"products_cursor"`
}

var val result
if err := json.Unmarshal(res.Data, &val); err != nil {
	fmt.Println(err)
	return
}

if val.Cursor == "" {
	fmt.Println("product_cursor value missing")
	return
}

fmt.Println(string(val.Products))
Output:

[{"name": "Product 100"}, {"name": "Product 99"}, {"name": "Product 98"}]
Example (QueryWithDynamicOrderBy)
gql := `query getProducts {
		products(order_by: $order, where: { id: { lt: 6 } }, limit: 5) {
			id
			price
		}
	}`

conf := newConfig(&core.Config{
	DBType:           dbType,
	DisableAllowList: true,
	Tables: []core.Table{{
		Name: "products",
		OrderBy: map[string][]string{
			"price_and_id": {"price desc", "id asc"},
			"just_id":      {"id asc"},
		},
	}},
})

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

vars1 := json.RawMessage(`{ "order": "price_and_id" }`)

res1, err1 := gj.GraphQL(context.Background(), gql, vars1, nil)
if err != nil {
	fmt.Println(err1)
} else {
	fmt.Println(string(res1.Data))
}

vars2 := json.RawMessage(`{ "order": "just_id" }`)

res2, err2 := gj.GraphQL(context.Background(), gql, vars2, nil)
if err != nil {
	fmt.Println(err2)
} else {
	fmt.Println(string(res2.Data))
}
Output:

{"products": [{"id": 5, "price": 15.5}, {"id": 4, "price": 14.5}, {"id": 3, "price": 13.5}, {"id": 2, "price": 12.5}, {"id": 1, "price": 11.5}]}
{"products": [{"id": 1, "price": 11.5}, {"id": 2, "price": 12.5}, {"id": 3, "price": 13.5}, {"id": 4, "price": 14.5}, {"id": 5, "price": 15.5}]}
Example (QueryWithFragments1)
gql := `
	fragment userFields1 on user {
		id
		email
	}

	query {
		users {
			...userFields2
			stripe_id
			...userFields1
		}
	}

	fragment userFields2 on user {
		full_name
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true, DefaultLimit: 2})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": [{"id": 1, "email": "user1@test.com", "full_name": "User 1", "stripe_id": "payment_id_1001"}, {"id": 2, "email": "user2@test.com", "full_name": "User 2", "stripe_id": "payment_id_1002"}]}
Example (QueryWithFragments2)
gql := `
	query {
		users {
			...userFields2

			stripe_id
			...userFields1
		}
	}

	fragment userFields1 on user {
		id
		email
	}

	fragment userFields2 on user {
		full_name
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true, DefaultLimit: 2})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": [{"id": 1, "email": "user1@test.com", "full_name": "User 1", "stripe_id": "payment_id_1001"}, {"id": 2, "email": "user2@test.com", "full_name": "User 2", "stripe_id": "payment_id_1002"}]}
Example (QueryWithFragments3)
gql := `
	fragment userFields1 on user {
		id
		email
	}

	fragment userFields2 on user {
		full_name

		...userFields1
	}

	query {
		users {
			...userFields2
			stripe_id
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true, DefaultLimit: 2})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": [{"id": 1, "email": "user1@test.com", "full_name": "User 1", "stripe_id": "payment_id_1001"}, {"id": 2, "email": "user2@test.com", "full_name": "User 2", "stripe_id": "payment_id_1002"}]}
Example (QueryWithFunctionsBlocked)
gql := `query {
		products {
			sum_price
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
err := conf.AddRoleTable("anon", "products", core.Query{
	DisableFunctions: true,
})
if err != nil {
	panic(err)
}

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

functions blocked: price (anon)
Example (QueryWithFunctionsWithWhere)
gql := `query {
		products(where: { id: { lesser_or_equals: 100 } }) {
			max_price
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"max_price": 110.5}]}
Example (QueryWithJsonColumn)
gql := `query {
		users(id: 1) {
			id
			category_counts {
				count
				category {
					name
				}
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
conf.Tables = []core.Table{
	{
		Name:  "category_counts",
		Table: "users",
		Type:  "json",
		Columns: []core.Column{
			{Name: "category_id", Type: "int", ForeignKey: "categories.id"},
			{Name: "count", Type: "int"},
		},
	},
}

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": {"id": 1, "category_counts": [{"count": 400, "category": {"name": "Category 1"}}, {"count": 600, "category": {"name": "Category 2"}}]}}
Example (QueryWithLimitOffsetOrderByDistinctAndWhere)
gql := `query {
		products(
			# returns only 5 items
			limit: 5,

			# starts from item 10, commented out for now
			# offset: 10,

			# orders the response items by highest price
			order_by: { price: desc },

			# no duplicate prices returned
			distinct: [ price ]

			# only items with an id >= 50 and < 100 are returned
			where: { id: { and: { greater_or_equals: 50, lt: 100 } } }) {
			id
			name
			price
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"id": 99, "name": "Product 99", "price": 109.5}, {"id": 98, "name": "Product 98", "price": 108.5}, {"id": 97, "name": "Product 97", "price": 107.5}, {"id": 96, "name": "Product 96", "price": 106.5}, {"id": 95, "name": "Product 95", "price": 105.5}]}
Example (QueryWithMultipleTopLevelTables)
gql := `query {
		products(id: $id) {
			id
			name
			customer {
				email
			}
		}
		users(id: $id) {
			id
			email
		}
		purchases(id: $id) {
			id
		}
	}`

vars := json.RawMessage(`{ "id": 1 }`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": {"id": 1, "email": "user1@test.com"}, "products": {"id": 1, "name": "Product 1", "customer": [{"email": "user2@test.com"}]}, "purchases": {"id": 1}}
Example (QueryWithNestedOrderBy)
gql := `query getProducts {
				products(order_by: { users: { email: desc }, id: desc}, where: { id: { lt: 6 } }, limit: 5) {
					id
					price
				}
	       }`

conf := newConfig(&core.Config{
	DBType:           dbType,
	DisableAllowList: true,
})

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res1, err1 := gj.GraphQL(context.Background(), gql, nil, nil)
if err1 != nil {
	fmt.Println(res1.SQL())
	fmt.Println(err1)
} else {
	fmt.Println(string(res1.Data))
}
Output:

{"products": [{"id": 5, "price": 15.5}, {"id": 4, "price": 14.5}, {"id": 3, "price": 13.5}, {"id": 2, "price": 12.5}, {"id": 1, "price": 11.5}]}
Example (QueryWithRecursiveRelationship1)
gql := `query {
		reply : comments(id: $id) {
			id
			comments(
				where: { id: { lt: 50 } }, 
				limit: 5,
				find: "parents") {
				id
			}
		}
	}`

vars := json.RawMessage(`{"id": 50 }`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"reply": {"id": 50, "comments": [{"id": 49}, {"id": 48}, {"id": 47}, {"id": 46}, {"id": 45}]}}
Example (QueryWithRecursiveRelationship2)
gql := `query {
		comments(id: 95) {
			id
			replies: comments(find: "children") {
				id
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"comments": {"id": 95, "replies": [{"id": 96}, {"id": 97}, {"id": 98}, {"id": 99}, {"id": 100}]}}
Example (QueryWithRecursiveRelationshipAndAggregations)
gql := `query {
		comments(id: 95) {
			id
			replies: comments(find: "children") {
				count_id
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"comments": {"id": 95, "replies": [{"count_id": 5}]}}
Example (QueryWithRemoteAPIJoin)
gql := `query {
		users {
			email
			payments {
				desc
			}
		}
	}`

// fake remote api service
go func() {
	http.HandleFunc("/payments/", func(w http.ResponseWriter, r *http.Request) {
		id := r.URL.Path[10:]
		fmt.Fprintf(w, `{"data":[{"desc":"Payment 1 for %s"},{"desc": "Payment 2 for %s"}]}`,
			id, id)
	})
	log.Fatal(http.ListenAndServe("localhost:12345", nil))
}()

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true, DefaultLimit: 2})
conf.Resolvers = []core.ResolverConfig{{
	Name:      "payments",
	Type:      "remote_api",
	Table:     "users",
	Column:    "stripe_id",
	StripPath: "data",
	Props:     core.ResolverProps{"url": "http://localhost:12345/payments/$id"},
}}

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": [{"email": "user1@test.com", "payments":[{"desc":"Payment 1 for payment_id_1001"},{"desc": "Payment 2 for payment_id_1001"}]}, {"email": "user2@test.com", "payments":[{"desc":"Payment 1 for payment_id_1002"},{"desc": "Payment 2 for payment_id_1002"}]}]}
Example (QueryWithScriptDirective)
gql := `query @script(name: "test.js") {
		usersByID(id: $id)  {
			id
			email
		}
	}`

script := `
	function request(vars) {
		return { id: 2 };
	}
	
	function response(json) {
		json.usersByID.email = "u...@test.com";
		return json;
	}
	`

dir, err := ioutil.TempDir("", "test")
if err != nil {
	panic(err)
}
defer os.RemoveAll(dir)

err = ioutil.WriteFile(path.Join(dir, "test.js"), []byte(script), 0600)
if err != nil {
	panic(err)
}

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true, ScriptPath: dir})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"usersByID":{"email":"u...@test.com","id":2}}
Example (QueryWithScriptDirectiveUsingGraphQL)
gql := `query @script(name: "test.js") {
		usersByID(id: 2)  {
			id
			email
		}
	}`

script := `
	function response(json) {
		let val = graphql('query { users(id: 1) { id email } }')
		json.usersByID.email = val.users.email
		return json;
	}
	`

dir, err := ioutil.TempDir("", "test")
if err != nil {
	panic(err)
}
defer os.RemoveAll(dir)

err = ioutil.WriteFile(path.Join(dir, "test.js"), []byte(script), 0600)
if err != nil {
	panic(err)
}

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true, ScriptPath: dir})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"usersByID":{"email":"user1@test.com","id":2}}
Example (QueryWithScriptDirectiveUsingHttp)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, `{ "hello": "world" }`)
}))
defer ts.Close()

gql := `query @script(name: "test.js") {
		usersByID(id: 2)  {
			id
			email
		}
	}`

script := `
	function response(json) {
		let val = http.get("` + ts.URL + `")
		return JSON.parse(val);
	}
	`

dir, err := ioutil.TempDir("", "test")
if err != nil {
	panic(err)
}
defer os.RemoveAll(dir)

err = ioutil.WriteFile(path.Join(dir, "test.js"), []byte(script), 0600)
if err != nil {
	panic(err)
}

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true, ScriptPath: dir})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"hello":"world"}
Example (QueryWithSkipAndIncludeDirectives)
gql := `
	query {
		products(limit: 2) @include(if: $test) {
			id
			name
		}
		users(limit: 3) @skip(if: $test) {
			id
		}
	}`

vars := json.RawMessage(`{ "test": true }`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": [], "products": [{"id": 1, "name": "Product 1"}, {"id": 2, "name": "Product 2"}]}
Example (QueryWithSkippingAuthRequiredSelectors)
gql := `query {
		products(limit: 2) {
			id
			name
			owner(where: { id: { eq: $user_id } }) {
				id
				email
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"id": 1, "name": "Product 1", "owner": null}, {"id": 2, "name": "Product 2", "owner": null}]}
Example (QueryWithSyntheticTables)
gql := `query {
		me @object {
			email
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
conf.Tables = []core.Table{{Name: "me", Table: "users"}}
err := conf.AddRoleTable("user", "me", core.Query{
	Filters: []string{`{ id: $user_id }`},
	Limit:   1,
})
if err != nil {
	panic(err)
}

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 1)
res, err := gj.GraphQL(ctx, gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"me": {"email": "user1@test.com"}}
Example (QueryWithUnionForPolymorphicRelationships)
gql := `
	fragment userFields on user {
		email
	}

	fragment productFields on product {
		name
	}

	query {
		notifications {
			id
			verb
			subject {
				...on users {
					...userFields
				}
				...on products {
					...productFields
				}
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true, DefaultLimit: 2})
conf.Tables = []core.Table{{
	Name:    "subject",
	Type:    "polymorphic",
	Columns: []core.Column{{Name: "subject_id", ForeignKey: "subject_type.id"}},
}}

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"notifications": [{"id": 1, "verb": "Joined", "subject": {"email": "user1@test.com"}}, {"id": 2, "verb": "Bought", "subject": {"name": "Product 2"}}]}
Example (QueryWithUser)
gql := `query {
		products(where: { owner_id: { eq: $user_id } }) {
			id
			owner {
				id
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 31)
res, err := gj.GraphQL(ctx, gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"id": 31, "owner": {"id": 31}}]}
Example (QueryWithVariables)
gql := `query {
		products(id: $product_id, where: { price: { gt: $product_price } }) {
			id
			name
		}
	}`

vars := json.RawMessage(`{ "product_id": 70 }`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
conf.Vars = map[string]string{"product_price": "50"}

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": {"id": 70, "name": "Product 70"}}
Example (QueryWithView)
gql := `query {
		hot_products(limit: 3) {
			product {
				id
				name
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
conf.Tables = []core.Table{
	{
		Name: "hot_products",
		Columns: []core.Column{
			{Name: "product_id", Type: "int", ForeignKey: "products.id"},
		},
	},
}

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"hot_products": [{"product": {"id": 51, "name": "Product 51"}}, {"product": {"id": 52, "name": "Product 52"}}, {"product": {"id": 53, "name": "Product 53"}}]}
Example (QueryWithWhereGreaterThanOrLesserThan)
gql := `query {
		products(
			limit: 3
			where: {
				or: {
					price: { gt: 20 },
					price: { lt: 22 }
				} }
			) {
			id
			name
			price
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"id": 1, "name": "Product 1", "price": 11.5}, {"id": 2, "name": "Product 2", "price": 12.5}, {"id": 3, "name": "Product 3", "price": 13.5}]}
Example (QueryWithWhereIn)
gql := `query {
		products(where: { id: { in: $list } }) {
			id
		}
	}`

vars := json.RawMessage(`{
		"list": [1,2,3]
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"id": 1}, {"id": 2}, {"id": 3}]}
Example (QueryWithWhereNotIsNullAndGreaterThan)
gql := `query {
		products(
			where: {
				and: [
					{ not: { id: { is_null: true } } },
					{ price: { gt: 10 } },
				] 
			} 
			limit: 3) {
			id
			name
			price
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

res, err := gj.GraphQL(context.Background(), gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"id": 1, "name": "Product 1", "price": 11.5}, {"id": 2, "name": "Product 2", "price": 12.5}, {"id": 3, "name": "Product 3", "price": 13.5}]}
Example (QueryWithWhereOnRelatedTable)
gql := `query {
		products(where: { owner: { id: { or: [ { eq: $user_id }, { eq: 3 } ] } } }, limit: 2) {
			id
			owner {
				id
				email
			}
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 2)
res, err := gj.GraphQL(ctx, gql, nil, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": [{"id": 2, "owner": {"id": 2, "email": "user2@test.com"}}, {"id": 3, "owner": {"id": 3, "email": "user3@test.com"}}]}
Example (Subscription)
gql := `subscription test {
		users(id: $id) {
			id
			email
			phone
		}
	}`

vars := json.RawMessage(`{ "id": 3 }`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true, SubsPollDuration: 1})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

m, err := gj.Subscribe(context.Background(), gql, vars, nil)
if err != nil {
	fmt.Println(err)
	return
}
for i := 0; i < 10; i++ {
	msg := <-m.Result
	fmt.Println(string(msg.Data))

	// update user phone in database to trigger subscription
	q := fmt.Sprintf(`UPDATE users SET phone = '650-447-000%d' WHERE id = 3`, i)
	if _, err := db.Exec(q); err != nil {
		panic(err)
	}
}
Output:

{"users": {"id": 3, "email": "user3@test.com", "phone": null}}
{"users": {"id": 3, "email": "user3@test.com", "phone": "650-447-0000"}}
{"users": {"id": 3, "email": "user3@test.com", "phone": "650-447-0001"}}
{"users": {"id": 3, "email": "user3@test.com", "phone": "650-447-0002"}}
{"users": {"id": 3, "email": "user3@test.com", "phone": "650-447-0003"}}
{"users": {"id": 3, "email": "user3@test.com", "phone": "650-447-0004"}}
{"users": {"id": 3, "email": "user3@test.com", "phone": "650-447-0005"}}
{"users": {"id": 3, "email": "user3@test.com", "phone": "650-447-0006"}}
{"users": {"id": 3, "email": "user3@test.com", "phone": "650-447-0007"}}
{"users": {"id": 3, "email": "user3@test.com", "phone": "650-447-0008"}}
Example (SubscriptionWithCursor)
// query to fetch existing chat messages
// gql1 := `query {
// 	chats(first: 3, after: $cursor) {
// 		id
// 		body
// 	}
// 	chats_cursor
// }`

// query to subscribe to new chat messages
gql2 := `subscription {
		chats(first: 1, after: $cursor) {
			id
			body
		}
	}`

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true, SubsPollDuration: 1})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

// struct to hold the cursor value from fetching the existing
// chat messages
// res := struct {
// 	Cursor string `json:"chats_cursor"`
// }{}

// execute query for existing chat messages
// m1, err := gj.GraphQL(context.Background(), gql1, nil, nil)
// if err != nil {
// 	fmt.Println(err)
// 	return
// }

// extract out the cursor `chats_cursor` to use in the subscription
// if err := json.Unmarshal(m1.Data, &res); err != nil {
// 	fmt.Println(err)
// 	return
// }

// replace cursor value to make test work since it's encrypted
// v1 := cursorRegex.ReplaceAllString(string(m1.Data), "cursor_was_here")
// fmt.Println(v1)

// create variables with the previously extracted cursor value to
// pass to the new chat messages subscription
// vars := json.RawMessage(`{ "cursor": "` + res.Cursor + `" }`)
vars := json.RawMessage(`{ "cursor": null }`)

// subscribe to new chat messages using the cursor
m2, err := gj.Subscribe(context.Background(), gql2, vars, nil)
if err != nil {
	fmt.Println(err)
	return
}

go func() {
	for i := 6; i < 20; i++ {
		// insert a new chat message
		q := fmt.Sprintf(`INSERT INTO chats (id, body) VALUES (%d, 'New chat message %d')`, i, i)
		if _, err := db.Exec(q); err != nil {
			panic(err)
		}
		time.Sleep(3 * time.Second)
	}
}()

for i := 0; i < 19; i++ {
	msg := <-m2.Result
	// replace cursor value to make test work since it's encrypted
	v2 := cursorRegex.ReplaceAllString(string(msg.Data), "cursor_was_here")
	fmt.Println(v2)
}
Output:

{"chats": [{"id": 1, "body": "This is chat message number 1"}], "chats_cursor_was_here"}
{"chats": [{"id": 2, "body": "This is chat message number 2"}], "chats_cursor_was_here"}
{"chats": [{"id": 3, "body": "This is chat message number 3"}], "chats_cursor_was_here"}
{"chats": [{"id": 4, "body": "This is chat message number 4"}], "chats_cursor_was_here"}
{"chats": [{"id": 5, "body": "This is chat message number 5"}], "chats_cursor_was_here"}
{"chats": [{"id": 6, "body": "New chat message 6"}], "chats_cursor_was_here"}
{"chats": [{"id": 7, "body": "New chat message 7"}], "chats_cursor_was_here"}
{"chats": [{"id": 8, "body": "New chat message 8"}], "chats_cursor_was_here"}
{"chats": [{"id": 9, "body": "New chat message 9"}], "chats_cursor_was_here"}
{"chats": [{"id": 10, "body": "New chat message 10"}], "chats_cursor_was_here"}
{"chats": [{"id": 11, "body": "New chat message 11"}], "chats_cursor_was_here"}
{"chats": [{"id": 12, "body": "New chat message 12"}], "chats_cursor_was_here"}
{"chats": [{"id": 13, "body": "New chat message 13"}], "chats_cursor_was_here"}
{"chats": [{"id": 14, "body": "New chat message 14"}], "chats_cursor_was_here"}
{"chats": [{"id": 15, "body": "New chat message 15"}], "chats_cursor_was_here"}
{"chats": [{"id": 16, "body": "New chat message 16"}], "chats_cursor_was_here"}
{"chats": [{"id": 17, "body": "New chat message 17"}], "chats_cursor_was_here"}
{"chats": [{"id": 18, "body": "New chat message 18"}], "chats_cursor_was_here"}
{"chats": [{"id": 19, "body": "New chat message 19"}], "chats_cursor_was_here"}
Example (Update)
gql := `mutation {
		products(id: $id, update: $data) {
			id
			name
		}
	}`

vars := json.RawMessage(`{ 
		"id": 100,
		"data": { 
			"name": "Updated Product 100",
			"description": "Description for updated product 100"
		} 
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"products": {"id": 100, "name": "Updated Product 100"}}
Example (UpdateMultipleRelatedTables1)
gql := `mutation {
		purchases(id: $id, update: $data) {
			quantity
			customer {
				full_name
			}
			product {
				description
			}
		}
	}`

vars := json.RawMessage(`{
		"id": 100,
		"data": {
			"quantity": 6,
			"customer": {
				"full_name": "Updated user related to purchase 100"
			},
			"product": {
				"description": "Updated product related to purchase 100"
			}
		}
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"purchases": {"product": {"description": "Updated product related to purchase 100"}, "customer": {"full_name": "Updated user related to purchase 100"}, "quantity": 6}}
Example (UpdateTableAndConnectToRelatedTables)
gql := `mutation {
		users(id: $id, update: $data) {
			full_name
			products {
				id
			}
		}
	}`

vars := json.RawMessage(`{
		"id": 100,
		"data": {
			"full_name": "Updated user 100",
			"products": {
				"connect": { "id": 99 },
				"disconnect": { "id": 100 }
			}
		}
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": {"products": [{"id": 99}], "full_name": "Updated user 100"}}
Example (UpdateTableAndRelatedTable)
gql := `mutation {
		users(id: $id, update: $data) {
			full_name
			products {
				id
			}
		}
	}`

vars := json.RawMessage(`{
		"id": 90,
		"data": {
			"full_name": "Updated user 90",
			"products": {
				"where": { "id": { "gt": 1 } },
				"name": "Updated Product 90"
			}
		}
	}`)

conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
gj, err := core.NewGraphJin(conf, db)
if err != nil {
	panic(err)
}

ctx := context.WithValue(context.Background(), core.UserIDKey, 3)
res, err := gj.GraphQL(ctx, gql, vars, nil)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(string(res.Data))
}
Output:

{"users": {"products": [{"id": 90}], "full_name": "Updated user 90"}}
Example (VeryComplexQuery)
conf := newConfig(&core.Config{DBType: dbType, DisableAllowList: true})
conf.Tables = []core.Table{
	{
		Name:  "category_counts",
		Table: "users",
		Type:  "json",
		Columns: []core.Column{
			{Name: "category_id", Type: "int", ForeignKey: "categories.id"},
			{Name: "count", Type: "int"},
		},
	},
	{
		Name:    "products",
		Columns: []core.Column{{Name: "category_ids", ForeignKey: "categories.id"}},
	},
}

gj, err := core.NewGraphJin(conf, db)
if err != nil {
	fmt.Println(err)
	return
}

res, err := gj.GraphQL(context.Background(), benchGQL, nil, nil)
if err != nil {
	fmt.Println(err)
	return
}

fmt.Println(string(res.Data))
Output:

{"products": [{"id": 27, "name": "Product 27", "owner": {"email": "user27@test.com", "picture": null, "full_name": "User 27", "category_counts": [{"count": 400, "category": {"name": "Category 1"}}, {"count": 600, "category": {"name": "Category 2"}}]}, "price": 37.5, "category": [{"id": 1, "name": "Category 1"}, {"id": 2, "name": "Category 2"}]}]}

Index

Examples

Constants

View Source
const (
	// Name of the authentication provider. Eg. google, github, etc
	UserIDProviderKey contextkey = iota

	// The raw user id (jwt sub) value
	UserIDRawKey

	// User ID value for authenticated users
	UserIDKey

	// User role if pre-defined
	UserRoleKey
)

Constants to set values on the context passed to the NewGraphJin function

Variables

This section is empty.

Functions

func GetConfigName added in v0.17.7

func GetConfigName() string

Types

type Column

type Column struct {
	ID         int32
	Name       string
	Type       string
	Primary    bool
	Array      bool
	ForeignKey string `mapstructure:"related_to"`
}

Column struct defines a database column

type Config

type Config struct {
	// SecretKey is used to encrypt opaque values such as
	// the cursor. Auto-generated if not set
	SecretKey string `mapstructure:"secret_key"`

	// DisableAllowList when set to true entirely disables the
	// allow list workflow and all queries are always compiled
	// even in production (Warning possible security concern)
	DisableAllowList bool `mapstructure:"disable_allow_list"`

	// ConfigPath is the default path to find all configuration
	// files and scripts under
	ConfigPath string `mapstructure:"config_path"`

	// ScriptPath if the path to the script files if not set the
	// path is assumed to be the same as the config path
	ScriptPath string `mapstructure:"script_path"`

	// SetUserID forces the database session variable `user.id` to
	// be set to the user id
	SetUserID bool `mapstructure:"set_user_id"`

	// DefaultBlock ensures that in anonymous mode (role 'anon') all tables
	// are blocked from queries and mutations. To open access to tables in
	// anonymous mode they have to be added to the 'anon' role config
	DefaultBlock bool `mapstructure:"default_block"`

	// Vars is a map of hardcoded variables that can be leveraged in your
	// queries. (eg. variable admin_id will be $admin_id in the query)
	Vars map[string]string `mapstructure:"variables"`

	// HeaderVars is a map of dynamic variables that map to http header values
	HeaderVars map[string]string `mapstructure:"header_variables"`

	// Blocklist is a list of tables and columns that should be filtered
	// out from any and all queries
	Blocklist []string

	// Resolvers contain the configs for custom resolvers. For example the `remote_api`
	// resolver would join json from a remote API into your query response
	Resolvers []ResolverConfig

	// Tables contains all table specific configuration such as aliased tables
	// creating relationships between tables, etc
	Tables []Table

	// RolesQuery if set enabled attribute based access control. This query
	// is used to fetch the user attribute that then dynamically define the users
	// role
	RolesQuery string `mapstructure:"roles_query"`

	// Roles contains all the configuration for all the roles you want to support
	// `user` and `anon` are two default roles. User role is for when a user ID is
	// available and Anon when it's not
	//
	// If you're using the RolesQuery config to enable atribute based acess control then
	// you can add more custom roles
	Roles []Role

	// Inflections is to add additionally singular to plural mappings
	// to the engine (eg. sheep: sheep)
	Inflections []string `mapstructure:"inflections"`

	// Disable inflections. Inflections are deprecated and will be
	// removed in next major version
	EnableInflection bool `mapstructure:"enable_inflection"`

	// Customize singular suffix
	// By default is set to "ByID"
	SingularSuffix string `mapstructure:"singular_suffix"`

	// Database type name Defaults to 'postgres' (options: mysql, postgres)
	DBType string `mapstructure:"db_type"`

	// Log warnings and other debug information
	Debug bool

	// SubsPollDuration is the database polling duration (in seconds)
	// used by subscriptions to query for updates.
	// Default set to 5 seconds
	SubsPollDuration time.Duration `mapstructure:"subs_poll_duration"`

	// DefaultLimit sets the default max limit (number of rows) when a
	// limit is not defined in the query or the table role config
	// Default set to 20
	DefaultLimit int `mapstructure:"default_limit"`

	// DisableAgg disables all aggregation functions like count, sum, etc
	DisableAgg bool `mapstructure:"disable_agg_functions"`

	// DisableFuncs disables all functions like count, length,  etc
	DisableFuncs bool `mapstructure:"disable_functions"`

	// EnableCamelcase enables autp camel case terms in GraphQL to snake case in SQL
	EnableCamelcase bool `mapstructure:"enable_camelcase"`

	// Enable production mode. This defaults to true if GO_ENV is set to
	// "production". When true the allow list is enforced
	Production bool

	// DBSchemaPollDuration sets the duration for polling the database
	// schema to detect changes to it. GraphJin is reinitialized when a
	// change is detected
	DBSchemaPollDuration time.Duration `mapstructure:"db_schema_poll_duration"`
	// contains filtered or unexported fields
}

Core struct contains core specific config value

func ReadInConfig

func ReadInConfig(configFile string) (*Config, error)

ReadInConfig reads in the config file for the environment specified in the GO_ENV environment variable. This is the best way to create a new GraphJin config.

func ReadInConfigFS added in v0.17.0

func ReadInConfigFS(configFile string, fs afero.Fs) (*Config, error)

ReadInConfigFS is the same as ReadInConfig but it also takes a filesytem as an argument

func (*Config) AddRoleTable

func (c *Config) AddRoleTable(role, table string, conf interface{}) error

AddRoleTable function is a helper function to make it easy to add per-table row-level config

func (*Config) RemoveRoleTable added in v0.16.7

func (c *Config) RemoveRoleTable(role, table string) error

func (*Config) SetResolver added in v0.15.56

func (c *Config) SetResolver(name string, fn refunc) error

type Delete

type Delete struct {
	Filters []string
	Columns []string
	Block   bool
}

Delete struct contains access control values for delete operations

type Error added in v0.16.28

type Error struct {
	Message string `json:"message"`
}

type GraphJin

type GraphJin struct {
	atomic.Value
}

func NewGraphJin

func NewGraphJin(conf *Config, db *sql.DB, options ...Option) (*GraphJin, error)

NewGraphJin creates the GraphJin struct, this involves querying the database to learn its schemas and relationships

func (*GraphJin) GraphQL

func (g *GraphJin) GraphQL(
	c context.Context,
	query string,
	vars json.RawMessage,
	rc *ReqConfig) (*Result, error)

GraphQL function is called on the GraphJin struct to convert the provided GraphQL query into an SQL query and execute it on the database. In production mode prepared statements are directly used and no query compiling takes places.

In developer mode all names queries are saved into a file `allow.list` and in production mode only queries from this file can be run.

func (*GraphJin) IsProd added in v0.16.28

func (g *GraphJin) IsProd() bool

IsProd return true for production mode or false for development mode

func (*GraphJin) Reload added in v0.16.104

func (g *GraphJin) Reload() error

Reload does database discover and reinitializes GraphJin.

func (*GraphJin) Subscribe

func (g *GraphJin) Subscribe(
	c context.Context,
	query string,
	vars json.RawMessage,
	rc *ReqConfig) (*Member, error)

GraphQLEx is the extended version of the Subscribe function allowing for request specific config.

type Insert

type Insert struct {
	Filters []string
	Columns []string
	Presets map[string]string
	Block   bool
}

Insert struct contains access control values for insert operations

type Member

type Member struct {
	Result chan *Result
	// contains filtered or unexported fields
}

func (*Member) String

func (m *Member) String() string

func (*Member) Unsubscribe

func (m *Member) Unsubscribe()

type Namespace added in v0.17.22

type Namespace struct {
	Name string
	Set  bool
}

type OpType

type OpType int
const (
	OpUnknown OpType = iota
	OpQuery
	OpSubscription
	OpMutation
)

func Operation

func Operation(query string) (OpType, string)

Operation function return the operation type and name from the query. It uses a very fast algorithm to extract the operation without having to parse the query.

type Option added in v0.17.0

type Option func(*graphjin) error

func OptionSetFS added in v0.17.0

func OptionSetFS(fs afero.Fs) Option

func OptionSetNamespace added in v0.17.21

func OptionSetNamespace(namespace string) Option

type Query

type Query struct {
	Limit            int
	Filters          []string
	Columns          []string
	DisableFunctions bool `mapstructure:"disable_functions"`
	Block            bool
}

Query struct contains access control values for query operations

type ReqConfig

type ReqConfig struct {
	// Namespace is used to namespace requests within a single instance of GraphJin. For example queries with the same name
	// can exist in allow list in seperate namespaces.
	Namespace Namespace

	// APQKey is set when using GraphJin with automatic persisted queries
	APQKey string

	// Pass additional variables complex variables such as functions that return string values.
	Vars map[string]interface{}
}

ReqConfig is used to pass request specific config values to the GraphQLEx and SubscribeEx functions. Dynamic variables can be set here.

type Resolver added in v0.15.56

type Resolver interface {
	Resolve(ResolverReq) ([]byte, error)
}

Resolver interface is used to create custom resolvers Custom resolvers must return a JSON value to be merged into the response JSON.

Example Redis Resolver:

type Redis struct {
	Addr string
	client redis.Client
}

func newRedis(v map[string]interface{}) (*Redis, error) {
	re := &Redis{}
	if err := mapstructure.Decode(v, re); err != nil {
		return nil, err
	}
	re.client := redis.NewClient(&redis.Options{
		Addr:     re.Addr,
		Password: "", // no password set
		DB:       0,  // use default DB
	})
	return re, nil
}

func (r *remoteAPI) Resolve(req ResolverReq) ([]byte, error) {
	val, err := rdb.Get(ctx, req.ID).Result()
	if err != nil {
			return err
	}

	return val, nil
}

func main() {
	conf := core.Config{
		Resolvers: []Resolver{
			Name: "cached_profile",
			Type: "redis",
			Table: "users",
			Column: "id",
			Props: []ResolverProps{
				"addr": "localhost:6379",
			},
		},
	}

	gj.conf.SetResolver("redis", func(v ResolverProps) (Resolver, error) {
		return newRedis(v)
	})

	gj, err := core.NewGraphJin(conf, db)
	if err != nil {
		log.Fatal(err)
	}
}

type ResolverConfig added in v0.15.56

type ResolverConfig struct {
	Name      string
	Type      string
	Schema    string
	Table     string
	Column    string
	StripPath string        `mapstructure:"strip_path"`
	Props     ResolverProps `mapstructure:",remain"`
}

ResolverConfig struct defines a custom resolver

type ResolverProps added in v0.15.56

type ResolverProps map[string]interface{}

ResolverProps is a map of properties from the resolver config to be passed to the customer resolver's builder (new) function

type ResolverReq added in v0.15.56

type ResolverReq struct {
	ID  string
	Sel *qcode.Select
	Log *log.Logger
	*ReqConfig
}

type Result

type Result struct {
	Errors     []Error         `json:"errors,omitempty"`
	Data       json.RawMessage `json:"data,omitempty"`
	Extensions *extensions     `json:"extensions,omitempty"`
	// contains filtered or unexported fields
}

Result struct contains the output of the GraphQL function this includes resulting json from the database query and any error information

func (*Result) CacheControl added in v0.16.44

func (r *Result) CacheControl() string

func (*Result) Operation

func (r *Result) Operation() OpType

func (*Result) OperationName

func (r *Result) OperationName() string

func (*Result) QueryName

func (r *Result) QueryName() string

func (*Result) Role

func (r *Result) Role() string

func (*Result) SQL

func (r *Result) SQL() string

type Role

type Role struct {
	Name   string
	Match  string
	Tables []RoleTable
	// contains filtered or unexported fields
}

Role struct contains role specific access control values for for all database tables

func (*Role) GetTable

func (r *Role) GetTable(schema, name string) *RoleTable

type RoleTable

type RoleTable struct {
	Name     string
	Schema   string
	ReadOnly bool `mapstructure:"read_only"`

	Query  *Query
	Insert *Insert
	Update *Update
	Upsert *Upsert
	Delete *Delete
}

RoleTable struct contains role specific access control values for a database table

type Table

type Table struct {
	Name      string
	Schema    string
	Table     string
	Type      string
	Blocklist []string
	Columns   []Column
	OrderBy   map[string][]string `mapstructure:"order_by"`
}

Table struct defines a database table

type Update

type Update struct {
	Filters []string
	Columns []string
	Presets map[string]string
	Block   bool
}

Insert struct contains access control values for update operations

type Upsert

type Upsert struct {
	Filters []string
	Columns []string
	Presets map[string]string
	Block   bool
}

Directories

Path Synopsis
module
internal
crypto
Provides symmetric authenticated encryption using 256-bit AES-GCM with a random nonce.
Provides symmetric authenticated encryption using 256-bit AES-GCM with a random nonce.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL