Compare commits

...

6 Commits

39 changed files with 1696 additions and 931 deletions

4
.gitignore vendored
View File

@ -34,4 +34,6 @@ supergraph
*-fuzz.zip *-fuzz.zip
crashers crashers
suppressions suppressions
release release
.gofuzz
*-fuzz.zip

337
allow/allow.go Normal file
View File

@ -0,0 +1,337 @@
package allow
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"os"
"path"
"sort"
"strings"
)
const (
AL_QUERY int = iota + 1
AL_VARS
)
type Item struct {
Name string
key string
URI string
Query string
Vars json.RawMessage
}
type List struct {
filepath string
saveChan chan Item
}
type Config struct {
CreateIfNotExists bool
Persist bool
}
func New(cpath string, conf Config) (*List, error) {
al := List{}
if len(cpath) != 0 {
fp := path.Join(cpath, "allow.list")
if _, err := os.Stat(fp); err == nil {
al.filepath = fp
} else if !os.IsNotExist(err) {
return nil, err
}
}
if len(al.filepath) == 0 {
fp := "./allow.list"
if _, err := os.Stat(fp); err == nil {
al.filepath = fp
} else if !os.IsNotExist(err) {
return nil, err
}
}
if len(al.filepath) == 0 {
fp := "./config/allow.list"
if _, err := os.Stat(fp); err == nil {
al.filepath = fp
} else if !os.IsNotExist(err) {
return nil, err
}
}
if len(al.filepath) == 0 {
if !conf.CreateIfNotExists {
return nil, errors.New("allow.list not found")
}
if len(cpath) == 0 {
al.filepath = "./config/allow.list"
} else {
al.filepath = path.Join(cpath, "allow.list")
}
}
var err error
if conf.Persist {
al.saveChan = make(chan Item)
go func() {
for v := range al.saveChan {
if err = al.save(v); err != nil {
break
}
}
}()
}
if err != nil {
return nil, err
}
return &al, nil
}
func (al *List) IsPersist() bool {
return al.saveChan != nil
}
func (al *List) Add(vars []byte, query, uri string) error {
if al.saveChan == nil {
return errors.New("allow.list is read-only")
}
if len(query) == 0 {
return errors.New("empty query")
}
var q string
for i := 0; i < len(query); i++ {
c := query[i]
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' {
q = query
break
} else if c == '{' {
q = "query " + query
break
}
}
al.saveChan <- Item{
URI: uri,
Query: q,
Vars: vars,
}
return nil
}
func (al *List) Load() ([]Item, error) {
var list []Item
b, err := ioutil.ReadFile(al.filepath)
if err != nil {
return list, err
}
if len(b) == 0 {
return list, nil
}
var uri string
var varBytes []byte
itemMap := make(map[string]struct{})
s, e, c := 0, 0, 0
ty := 0
for {
fq := false
if c == 0 && b[e] == '#' {
s = e
for e < len(b) && b[e] != '\n' {
e++
}
if (e - s) > 2 {
uri = strings.TrimSpace(string(b[(s + 1):e]))
}
}
if e >= len(b) {
break
}
if matchPrefix(b, e, "query") || matchPrefix(b, e, "mutation") {
if c == 0 {
s = e
}
ty = AL_QUERY
} else if matchPrefix(b, e, "variables") {
if c == 0 {
s = e + len("variables") + 1
}
ty = AL_VARS
} else if b[e] == '{' {
c++
} else if b[e] == '}' {
c--
if c == 0 {
if ty == AL_QUERY {
fq = true
} else if ty == AL_VARS {
varBytes = b[s:(e + 1)]
}
ty = 0
}
}
if fq {
query := string(b[s:(e + 1)])
name := QueryName(query)
key := strings.ToLower(name)
if _, ok := itemMap[key]; !ok {
v := Item{
Name: name,
key: key,
URI: uri,
Query: query,
Vars: varBytes,
}
list = append(list, v)
}
varBytes = nil
}
e++
if e >= len(b) {
break
}
}
return list, nil
}
func (al *List) save(item Item) error {
item.Name = QueryName(item.Query)
item.key = strings.ToLower(item.Name)
if len(item.Name) == 0 {
return nil
}
list, err := al.Load()
if err != nil {
return err
}
index := -1
for i, v := range list {
if strings.EqualFold(v.Name, item.Name) {
index = i
break
}
}
if index != -1 {
list[index] = item
} else {
list = append(list, item)
}
f, err := os.Create(al.filepath)
if err != nil {
return err
}
defer f.Close()
sort.Slice(list, func(i, j int) bool {
return strings.Compare(list[i].key, list[j].key) == -1
})
for _, v := range list {
_, err := f.WriteString(fmt.Sprintf("# %s\n\n", v.URI))
if err != nil {
return err
}
if len(v.Vars) != 0 && !bytes.Equal(v.Vars, []byte("{}")) {
vj, err := json.MarshalIndent(v.Vars, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal vars: %v", err)
}
_, err = f.WriteString(fmt.Sprintf("variables %s\n\n", vj))
if err != nil {
return err
}
}
if v.Query[0] == '{' {
_, err = f.WriteString(fmt.Sprintf("query %s\n\n", v.Query))
} else {
_, err = f.WriteString(fmt.Sprintf("%s\n\n", v.Query))
}
if err != nil {
return err
}
}
return nil
}
func matchPrefix(b []byte, i int, s string) bool {
if (len(b) - i) < len(s) {
return false
}
for n := 0; n < len(s); n++ {
if b[(i+n)] != s[n] {
return false
}
}
return true
}
func QueryName(b string) string {
state, s := 0, 0
for i := 0; i < len(b); i++ {
switch {
case state == 2 && b[i] == '{':
return b[s:i]
case state == 2 && b[i] == ' ':
return b[s:i]
case state == 1 && b[i] == '{':
return ""
case state == 1 && b[i] != ' ':
s = i
state = 2
case state == 1 && b[i] == ' ':
continue
case i != 0 && b[i] == ' ' && (b[i-1] == 'n' || b[i-1] == 'y'):
state = 1
}
}
return ""
}

82
allow/allow_test.go Normal file
View File

@ -0,0 +1,82 @@
package allow
import (
"testing"
)
func TestGQLName1(t *testing.T) {
var q = `
query {
products(
distinct: [price]
where: { id: { and: { greater_or_equals: 20, lt: 28 } } }
) { id name } }`
name := QueryName(q)
if len(name) != 0 {
t.Fatal("Name should be empty, not ", name)
}
}
func TestGQLName2(t *testing.T) {
var q = `
query hakuna_matata {
products(
distinct: [price]
where: { id: { and: { greater_or_equals: 20, lt: 28 } } }
) {
id
name
}
}`
name := QueryName(q)
if name != "hakuna_matata" {
t.Fatal("Name should be 'hakuna_matata', not ", name)
}
}
func TestGQLName3(t *testing.T) {
var q = `
mutation means{ users { id } }`
// var v2 = ` { products( limit: 30, order_by: { price: desc }, distinct: [ price ] where: { id: { and: { greater_or_equals: 20, lt: 28 } } }) { id name price user { id email } } } `
name := QueryName(q)
if name != "means" {
t.Fatal("Name should be 'means', not ", name)
}
}
func TestGQLName4(t *testing.T) {
var q = `
query no_worries
users {
id
}
}`
name := QueryName(q)
if name != "no_worries" {
t.Fatal("Name should be 'no_worries', not ", name)
}
}
func TestGQLName5(t *testing.T) {
var q = `
{
users {
id
}
}`
name := QueryName(q)
if len(name) != 0 {
t.Fatal("Name should be empty, not ", name)
}
}

15
allow/fuzz_test.go Normal file
View File

@ -0,0 +1,15 @@
package allow
import "testing"
func TestFuzzCrashers(t *testing.T) {
var crashers = []string{
"query",
"q",
"que",
}
for _, f := range crashers {
_ = QueryName(f)
}
}

View File

@ -4,9 +4,9 @@ sidebar: auto
# Guide to Super Graph # Guide to Super Graph
Super Graph is a micro-service that instantly and without code gives you a high performance and secure GraphQL API. Your GraphQL queries are auto translated into a single fast SQL query. No more writing API code as you develop your web frontend just make the query you need and Super Graph will do the rest. Super Graph is a service that instantly and without code gives you a high performance and secure GraphQL API. Your GraphQL queries are auto translated into a single fast SQL query. No more spending weeks or months writing backend API code. Just make the query you need and Super Graph will do the rest.
Super Graph has a rich feature set like integrating with your existing Ruby on Rails apps, joining your DB with data from remote APIs, Role and Attribute based access control, Supoport for JWT tokens, DB migrations, seeding and a lot more. Super Graph has a rich feature set like integrating with your existing Ruby on Rails apps, joining your DB with data from remote APIs, Role and Attribute based access control, Support for JWT tokens, DB migrations, seeding and a lot more.
## Features ## Features
@ -47,14 +47,14 @@ open http://localhost:3000
open http://localhost:8080 open http://localhost:8080
``` ```
::: warning DEMO REQUIREMENTS ::: tip DEMO REQUIREMENTS
This demo requires `docker` you can either install it using `brew` or from the This demo requires `docker` you can either install it using `brew` or from the
docker website [https://docs.docker.com/docker-for-mac/install/](https://docs.docker.com/docker-for-mac/install/) docker website [https://docs.docker.com/docker-for-mac/install/](https://docs.docker.com/docker-for-mac/install/)
::: :::
#### Trying out GraphQL #### Trying out GraphQL
We currently fully support queries and mutations. Support for `subscriptions` is work in progress. For example the below GraphQL query would fetch two products that belong to the current user where the price is greater than 10. We fully support queries and mutations. For example the below GraphQL query would fetch two products that belong to the current user where the price is greater than 10.
#### GQL Query #### GQL Query
@ -76,32 +76,6 @@ query {
} }
``` ```
In another example the below GraphQL mutation would insert a product into the database. The first part of the below example is the variable data and the second half is the GraphQL mutation. For mutations data has to always ben passed as a variable.
```json
{
"data": {
"name": "Art of Computer Programming",
"description": "The Art of Computer Programming (TAOCP) is a comprehensive monograph written by computer scientist Donald Knuth",
"price": 30.5
}
}
```
```graphql
mutation {
product(insert: $data) {
id
name
}
}
```
The above GraphQL query returns the JSON result below. It handles all
kinds of complexity without you having to writing a line of code.
For example there is a while greater than `gt` and a limit clause on a child field. And the `avatar` field is renamed to `picture`. The `password` field is blocked and not returned. Finally the relationship between the `users` table and the `products` table is auto discovered and used.
#### JSON Result #### JSON Result
```json ```json
@ -128,19 +102,107 @@ For example there is a while greater than `gt` and a limit clause on a child fie
} }
``` ```
#### Try with an authenticated user ::: tip Testing with a user
In development mode you can use the `X-User-ID: 4` header to set a user id so you don't have to worries about cookies etc. This can be set using the *HTTP Headers* tab at the bottom of the web UI.
:::
In development mode you can use the `X-User-ID: 4` header to set a user id so you don't have to worries about cookies etc. This can be set using the *HTTP Headers* tab at the bottom of the web UI you'll see when you visit the above link. You can also directly run queries from the commandline like below. In another example the below GraphQL mutation would insert a product into the database. The first part of the below example is the variable data and the second half is the GraphQL mutation. For mutations data has to always ben passed as a variable.
#### Querying the GQL endpoint ```json
{
"data": {
"name": "Art of Computer Programming",
"description": "The Art of Computer Programming (TAOCP) is a comprehensive monograph written by computer scientist Donald Knuth",
"price": 30.5
}
}
```
```bash ```graphql
mutation {
product(insert: $data) {
id
name
}
}
```
# fetch the response json directly from the endpoint using user id 5 ## Why Super Graph
curl 'http://localhost:8080/api/v1/graphql' \
-H 'content-type: application/json' \ Let's take a simple example say you want to fetch 5 products priced over 12 dollars along with the photos of the products and the users that owns them. Additionally also fetch the last 10 of your own purchases along with the name and ID of the product you purchased. This is a common type of query to render a view in say an ecommerce app. Lets be honest it's not very exciting write and maintain. Keep in mind the data needed will only continue to grow and change as your app evolves. Developers might find that most ORMs will not be able to do all of this in a single SQL query and will require n+1 queries to fetch all the data and assembly it into the right JSON response.
-H 'X-User-ID: 5' \
--data-binary '{"query":"{ products { name price users { email }}}"}' What if I told you Super Graph will fetch all this data with a single SQL query and without you having to write a single line of code. Also as your app evolves feel free to evolve the query as you like. In our experience Super Graph saves us hundreds or thousands of man hours that we can put towards the more exciting parts of our app.
#### GraphQL Query
```graphql
query {
products(limit 5, where: { price: { gt: 12 } }) {
id
name
description
price
photos {
url
}
user {
id
email
picture : avatar
full_name
}
}
purchases(
limit 10,
order_by: { created_at: desc } ,
where: { user_id: { eq: $user_id } }
) {
id
created_at
product {
id
name
}
}
}
```
#### JSON Result
```json
"data": {
"products": [
{
"id": 1,
"name": "Oaked Arrogant Bastard Ale",
"description": "Coors lite, European Amber Lager, Perle, 1272 - American Ale II, 38 IBU, 6.4%, 9.7°Blg",
"price": 20,
"photos: [{
"url": "https://www.scienceworld.ca/wp-content/uploads/science-world-beer-flavours.jpg"
}],
"user": {
"id": 1,
"email": "user0@demo.com",
"picture": "https://robohash.org/sitaliquamquaerat.png?size=300x300&set=set1",
"full_name": "Mrs. Wilhemina Hilpert"
}
},
...
]
},
"purchases": [
{
"id": 5,
"created_at": "2020-01-24T05:34:39.880599",
"product": {
"id": 45,
"name": "Brooklyn Black",
}
},
...
]
}
``` ```
## Get Started ## Get Started
@ -651,8 +713,6 @@ query {
} }
``` ```
## Mutations
In GraphQL mutations is the operation type for when you need to modify data. Super Graph supports the `insert`, `update`, `upsert` and `delete`. You can also do complex nested inserts and updates. In GraphQL mutations is the operation type for when you need to modify data. Super Graph supports the `insert`, `update`, `upsert` and `delete`. You can also do complex nested inserts and updates.
When using mutations the data must be passed as variables since Super Graphs compiles the query into an prepared statement in the database for maximum speed. Prepared statements are are functions in your code when called they accept arguments and your variables are passed in as those arguments. When using mutations the data must be passed as variables since Super Graphs compiles the query into an prepared statement in the database for maximum speed. Prepared statements are are functions in your code when called they accept arguments and your variables are passed in as those arguments.
@ -836,8 +896,6 @@ mutation {
} }
``` ```
## Nested Mutations
Often you will need to create or update multiple related items at the same time. This can be done using nested mutations. For example you might need to create a product and assign it to a user, or create a user and his products at the same time. You just have to use simple json to define you mutation and Super Graph takes care of the rest. Often you will need to create or update multiple related items at the same time. This can be done using nested mutations. For example you might need to create a product and assign it to a user, or create a user and his products at the same time. You just have to use simple json to define you mutation and Super Graph takes care of the rest.
### Nested Insert ### Nested Insert
@ -906,7 +964,7 @@ mutation {
} }
``` ```
### Nested Updates ### Nested Update
Update a product item first and then assign it to a user Update a product item first and then assign it to a user
@ -966,7 +1024,7 @@ mutation {
} }
``` ```
## Using variables ## Using Variables
Variables (`$product_id`) and their values (`"product_id": 5`) can be passed along side the GraphQL query. Using variables makes for better client side code as well as improved server side SQL query caching. The build-in web-ui also supports setting variables. Not having to manipulate your GraphQL query string to insert values into it makes for cleaner Variables (`$product_id`) and their values (`"product_id": 5`) can be passed along side the GraphQL query. Using variables makes for better client side code as well as improved server side SQL query caching. The build-in web-ui also supports setting variables. Not having to manipulate your GraphQL query string to insert values into it makes for cleaner
and better client side code. and better client side code.
@ -988,6 +1046,104 @@ fetch('http://localhost:8080/api/v1/graphql', {
.then(res => console.log(res.data)); .then(res => console.log(res.data));
``` ```
## GraphQL with React
This is a quick simple example using `graphql.js` [https://github.com/f/graphql.js/](https://github.com/f/graphql.js/)
```js
import React, { useState, useEffect } from 'react'
import graphql from 'graphql.js'
// Create a GraphQL client pointing to Super Graph
var graph = graphql("http://localhost:3000/api/v1/graphql", { asJSON: true })
const App = () => {
const [user, setUser] = useState(null)
useEffect(() => {
async function action() {
// Use the GraphQL client to execute a graphQL query
// The second argument to the client are the variables you need to pass
const result = await graph(`{ user { id first_name last_name picture_url } }`)()
setUser(result)
}
action()
}, []);
return (
<div className="App">
<h1>{ JSON.stringify(user) }</h1>
</div>
);
}
```
export default App;
## Advanced Columns
The ablity to have `JSON/JSONB` and `Array` columns is often considered in the top most useful features of Postgres. There are many cases where using an array or a json column saves space and reduces complexity in your app. The only issue with these columns is the really that your SQL queries can get harder to write and maintain.
Super Graph steps in here to help you by supporting these columns right out of the box. It allows you to work with these columns just like you would with tables. Joining data against or modifying array columns using the `connect` or `disconnect` keywords in mutations is fully supported. Another very useful feature is the ability to treat `json` or `binary json (jsonb)` columns as seperate tables, even using them in nested queries joining against related tables. To replicate these features on your own will take a lot of complex SQL. Using Super Graph means you don't have to deal with any of this it just works.
### Array Columns
Configure a relationship between an array column `tag_ids` which contains integer id's for tags and the column `id` in the table `tags`.
```yaml
tables:
- name: posts
columns:
- name: tag_ids
related_to: tags.id
```
```graphql
query {
posts {
title
tags {
name
image
}
}
}
```
### JSON Column
Configure a JSON column called `tag_count` in the table `products` into a seperate table. This JSON column contains a json array of objects each with a tag id and a count of the number of times the tag was used. As a seperate table you can nest it into your GraphQL query and treat it like table using any of the standard features like `order_by`, `limit`, `where clauses`, etc.
The configuration below tells Super Graph to create a synthetic table called `tag_count` using the column `tag_count` from the `products` table. And that this new table has two columns `tag_id` and `count` of the listed types and with the defined relationships.
```yaml
tables:
- name: tag_count
table: products
columns:
- name: tag_id
type: bigint
related_to: tags.id
- name: count
type: int
```
```graphql
query {
products {
name
tag_counts {
count
tag {
name
}
}
}
}
```
## Full text search ## Full text search
Every app these days needs search. Enought his often means reaching for something heavy like Solr. While this will work why add complexity to your infrastructure when Postgres has really great Every app these days needs search. Enought his often means reaching for something heavy like Solr. While this will work why add complexity to your infrastructure when Postgres has really great
@ -1073,45 +1229,45 @@ class AddSearchColumn < ActiveRecord::Migration[5.1]
end end
``` ```
## GraphQL with React ## API Security
This is a quick simple example using `graphql.js` [https://github.com/f/graphql.js/](https://github.com/f/graphql.js/) One of the the most common questions I get asked is what happens if a user out on the internet sends queries
that we don't want run. For example how do we stop him from fetching all users or the emails of users. Our answer to this is that it is not an issue as this cannot happen, let me explain.
```js Super Graph runs in one of two modes `development` or `production`, this is controlled via the config value `production: false` when it's false it's running in development mode and when true, production. In development mode all the **named** queries (including mutations) are saved to the allow list `./config/allow.list`. While in production mode when Super Graph starts only the queries from this allow list file are registered with the database as [prepared statements](https://stackoverflow.com/questions/8263371/how-can-prepared-statements-protect-from-sql-injection-attacks).
import React, { useState, useEffect } from 'react'
import graphql from 'graphql.js'
// Create a GraphQL client pointing to Super Graph Prepared statements are designed by databases to be fast and secure. They protect against all kinds of sql injection attacks and since they are pre-processed and pre-planned they are much faster to run then raw sql queries. Also there's no GraphQL to SQL compiling happening in production mode which makes your queries lighting fast as they are directly sent to the database with almost no overhead.
var graph = graphql("http://localhost:3000/api/v1/graphql", { asJSON: true })
const App = () => { In short in production only queries listed in the allow list file `./config/allow.list` can be used, all other queries will be blocked.
const [user, setUser] = useState(null)
::: tip How to think about the allow list?
useEffect(() => { The allow list file is essentially a list of all your exposed API calls and the data that passes within them. It's very easy to build tooling to do things like parsing this file within your tests to ensure fields like `credit_card_no` are not accidently leaked. It's a great way to build compliance tooling and ensure your user data is always safe.
async function action() { :::
// Use the GraphQL client to execute a graphQL query
// The second argument to the client are the variables you need to pass This is an example of a named query, `getUserWithProducts` is the name you've given to this query it can be anything you like but should be unique across all you're queries. Only named queries are saved in the allow list in development mode.
const result = await graph(`{ user { id first_name last_name picture_url } }`)()
setUser(result)
```graphql
query getUserWithProducts {
users {
id
name
products {
id
name
price
} }
action() }
}, []);
return (
<div className="App">
<h1>{ JSON.stringify(user) }</h1>
</div>
);
} }
``` ```
export default App;
## Authentication ## Authentication
You can only have one type of auth enabled. You can either pick Rails or JWT. You can only have one type of auth enabled either Rails or JWT.
### Rails Auth (Devise / Warden) ### Ruby on Rails
Almost all Rails apps use Devise or Warden for authentication. Once the user is Almost all Rails apps use Devise or Warden for authentication. Once the user is
authenticated a session is created with the users ID. The session can either be authenticated a session is created with the users ID. The session can either be
@ -1177,14 +1333,22 @@ auth:
public_key_type: ecdsa #rsa public_key_type: ecdsa #rsa
``` ```
For JWT tokens we currently support tokens from a provider like Auth0 For JWT tokens we currently support tokens from a provider like Auth0 or if you have a custom solution then we look for the `user_id` in the `subject` claim of of the `id token`. If you pick Auth0 then we derive two variables from the token `user_id` and `user_id_provider` for to use in your filters.
or if you have a custom solution then we look for the `user_id` in the
`subject` claim of of the `id token`. If you pick Auth0 then we derive two variables from the token `user_id` and `user_id_provider` for to use in your filters.
We can get the JWT token either from the `authorization` header where we expect it to be a `bearer` token or if `cookie` is specified then we look there. We can get the JWT token either from the `authorization` header where we expect it to be a `bearer` token or if `cookie` is specified then we look there.
For validation a `secret` or a public key (ecdsa or rsa) is required. When using public keys they have to be in a PEM format file. For validation a `secret` or a public key (ecdsa or rsa) is required. When using public keys they have to be in a PEM format file.
#### Using CURL to test a query
```bash
# fetch the response json directly from the endpoint using user id 5
curl 'http://localhost:8080/api/v1/graphql' \
-H 'content-type: application/json' \
-H 'X-User-ID: 5' \
--data-binary '{"query":"{ products { name price users { email }}}"}'
```
## Access Control ## Access Control
It's common for APIs to control what information they return or insert based on the role of the user. In Super Graph we have two primary roles `user` and `anon` the first for users where a `user_id` is available the latter for users where it's not. It's common for APIs to control what information they return or insert based on the role of the user. In Super Graph we have two primary roles `user` and `anon` the first for users where a `user_id` is available the latter for users where it's not.
@ -1197,7 +1361,6 @@ The `user` role can be divided up into further roles based on attributes in the
Super Graph allows you to create roles dynamically using a `roles_query` and ` match` config values. Super Graph allows you to create roles dynamically using a `roles_query` and ` match` config values.
### Configure RBAC ### Configure RBAC
```yaml ```yaml

1
go.mod
View File

@ -12,6 +12,7 @@ require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/dlclark/regexp2 v1.2.0 // indirect github.com/dlclark/regexp2 v1.2.0 // indirect
github.com/dop251/goja v0.0.0-20190912223329-aa89e6a4c733 github.com/dop251/goja v0.0.0-20190912223329-aa89e6a4c733
github.com/dvyukov/go-fuzz v0.0.0-20191206100749-a378175e205c // indirect
github.com/fsnotify/fsnotify v1.4.7 github.com/fsnotify/fsnotify v1.4.7
github.com/garyburd/redigo v1.6.0 github.com/garyburd/redigo v1.6.0
github.com/go-sourcemap/sourcemap v2.1.2+incompatible // indirect github.com/go-sourcemap/sourcemap v2.1.2+incompatible // indirect

2
go.sum
View File

@ -54,6 +54,8 @@ github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dop251/goja v0.0.0-20190912223329-aa89e6a4c733 h1:cyNc40Dx5YNEO94idePU8rhVd3dn+sd04Arh0kDBAaw= github.com/dop251/goja v0.0.0-20190912223329-aa89e6a4c733 h1:cyNc40Dx5YNEO94idePU8rhVd3dn+sd04Arh0kDBAaw=
github.com/dop251/goja v0.0.0-20190912223329-aa89e6a4c733/go.mod h1:Mw6PkjjMXWbTj+nnj4s3QPXq1jaT0s5pC0iFD4+BOAA= github.com/dop251/goja v0.0.0-20190912223329-aa89e6a4c733/go.mod h1:Mw6PkjjMXWbTj+nnj4s3QPXq1jaT0s5pC0iFD4+BOAA=
github.com/dvyukov/go-fuzz v0.0.0-20191206100749-a378175e205c h1:/bXaeEuNG6V0HeyEGw11DYLW5BGsOPlcVRIXbHNUWSo=
github.com/dvyukov/go-fuzz v0.0.0-20191206100749-a378175e205c/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/garyburd/redigo v1.6.0 h1:0VruCpn7yAIIu7pWVClQC8wxCJEcG3nyzpMSHKi1PQc= github.com/garyburd/redigo v1.6.0 h1:0VruCpn7yAIIu7pWVClQC8wxCJEcG3nyzpMSHKi1PQc=

View File

@ -5,5 +5,5 @@ import (
) )
func main() { func main() {
serv.Init() serv.Cmd()
} }

54
psql/fuzz.go Normal file
View File

@ -0,0 +1,54 @@
// +build gofuzz
package psql
import (
"encoding/json"
"github.com/dosco/super-graph/qcode"
)
var (
qcompileTest, _ = qcode.NewCompiler(qcode.Config{})
schema = getTestSchema()
vars = NewVariables(map[string]string{
"admin_account_id": "5",
})
pcompileTest = NewCompiler(Config{
Schema: schema,
Vars: vars,
})
)
// FuzzerEntrypoint for Fuzzbuzz
func Fuzz(data []byte) int {
gql := `mutation {
product(insert: $data) {
id
name
user {
id
full_name
email
}
}
}`
qc, err := qcompileTest.Compile([]byte(gql), "user")
if err != nil {
panic("qcompile can't fail")
}
vars := map[string]json.RawMessage{
"data": json.RawMessage(data),
}
_, _, err = pcompileTest.CompileEx(qc, vars)
if err != nil {
return 0
}
return 1
}

View File

@ -15,7 +15,10 @@ func (c *compilerContext) renderInsert(qc *qcode.QCode, w io.Writer,
insert, ok := vars[qc.ActionVar] insert, ok := vars[qc.ActionVar]
if !ok { if !ok {
return 0, fmt.Errorf("Variable '%s' not !defined", qc.ActionVar) return 0, fmt.Errorf("variable '%s' not defined", qc.ActionVar)
}
if len(insert) == 0 {
return 0, fmt.Errorf("variable '%s' is empty", qc.ActionVar)
} }
io.WriteString(c.w, `WITH "_sg_input" AS (SELECT '{{`) io.WriteString(c.w, `WITH "_sg_input" AS (SELECT '{{`)
@ -147,7 +150,14 @@ func renderNestedInsertRelColumns(w io.Writer, item kvitem, values bool) error {
io.WriteString(w, `, `) io.WriteString(w, `, `)
} }
if values { if values {
colWithTable(w, v.relCP.Left.Table, v.relCP.Left.Col) if v._ctype > 0 {
io.WriteString(w, `"_x_`)
io.WriteString(w, v.relCP.Left.Table)
io.WriteString(w, `".`)
quoted(w, v.relCP.Left.Col)
} else {
colWithTable(w, v.relCP.Left.Table, v.relCP.Left.Col)
}
} else { } else {
quoted(w, v.relCP.Right.Col) quoted(w, v.relCP.Right.Col)
} }
@ -166,12 +176,18 @@ func renderNestedInsertRelTables(w io.Writer, item kvitem) error {
io.WriteString(w, `, `) io.WriteString(w, `, `)
} }
} else { } else {
// Render child foreign key columns if child-to-parent // Render tables needed to set values if child-to-parent
// relationship is one-to-many // relationship is one-to-many
for _, v := range item.items { for _, v := range item.items {
if v.relCP.Type == RelOneToMany { if v.relCP.Type == RelOneToMany {
quoted(w, v.relCP.Left.Table) if v._ctype > 0 {
io.WriteString(w, `, `) io.WriteString(w, `"_x_`)
io.WriteString(w, v.relCP.Left.Table)
io.WriteString(w, `", `)
} else {
quoted(w, v.relCP.Left.Table)
io.WriteString(w, `, `)
}
} }
} }
} }

View File

@ -249,7 +249,7 @@ func nestedInsertOneToManyWithConnect(t *testing.T) {
} }
}` }`
sql := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "users" AS (INSERT INTO "users" ("full_name", "email", "created_at", "updated_at") SELECT "t"."full_name", "t"."email", "t"."created_at", "t"."updated_at" FROM "_sg_input" i, json_populate_record(NULL::users, i.j) t RETURNING *), "products" AS ( UPDATE "products" SET "user_id" = "users"."id"FROM "users" WHERE ("products"."id" = '5') RETURNING "products".*) SELECT json_object_agg('user', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "users_0"."id" AS "id", "users_0"."full_name" AS "full_name", "users_0"."email" AS "email", "product_1_join"."json_1" AS "product") AS "json_row_0")) AS "json_0" FROM (SELECT "users"."id", "users"."full_name", "users"."email" FROM "users" LIMIT ('1') :: integer) AS "users_0" LEFT OUTER JOIN LATERAL (SELECT row_to_json((SELECT "json_row_1" FROM (SELECT "products_1"."id" AS "id", "products_1"."name" AS "name", "products_1"."price" AS "price") AS "json_row_1")) AS "json_1" FROM (SELECT "products"."id", "products"."name", "products"."price" FROM "products" WHERE ((("products"."user_id") = ("users_0"."id"))) LIMIT ('1') :: integer) AS "products_1" LIMIT ('1') :: integer) AS "product_1_join" ON ('true') LIMIT ('1') :: integer) AS "sel_0"` sql := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "users" AS (INSERT INTO "users" ("full_name", "email", "created_at", "updated_at") SELECT "t"."full_name", "t"."email", "t"."created_at", "t"."updated_at" FROM "_sg_input" i, json_populate_record(NULL::users, i.j) t RETURNING *), "products" AS ( UPDATE "products" SET "user_id" = "users"."id" FROM "users" WHERE ("products"."id"= ((i.j->'product'->'connect'->>'id'))::bigint) RETURNING "products".*) SELECT json_object_agg('user', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "users_0"."id" AS "id", "users_0"."full_name" AS "full_name", "users_0"."email" AS "email", "product_1_join"."json_1" AS "product") AS "json_row_0")) AS "json_0" FROM (SELECT "users"."id", "users"."full_name", "users"."email" FROM "users" LIMIT ('1') :: integer) AS "users_0" LEFT OUTER JOIN LATERAL (SELECT row_to_json((SELECT "json_row_1" FROM (SELECT "products_1"."id" AS "id", "products_1"."name" AS "name", "products_1"."price" AS "price") AS "json_row_1")) AS "json_1" FROM (SELECT "products"."id", "products"."name", "products"."price" FROM "products" WHERE ((("products"."user_id") = ("users_0"."id"))) LIMIT ('1') :: integer) AS "products_1" LIMIT ('1') :: integer) AS "product_1_join" ON ('true') LIMIT ('1') :: integer) AS "sel_0"`
vars := map[string]json.RawMessage{ vars := map[string]json.RawMessage{
"data": json.RawMessage(`{ "data": json.RawMessage(`{
@ -278,6 +278,10 @@ func nestedInsertOneToOneWithConnect(t *testing.T) {
product(insert: $data) { product(insert: $data) {
id id
name name
tags {
id
name
}
user { user {
id id
full_name full_name
@ -286,7 +290,7 @@ func nestedInsertOneToOneWithConnect(t *testing.T) {
} }
}` }`
sql := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "users" AS (SELECT * FROM "users" WHERE "users"."id" = '5' LIMIT 1), "products" AS (INSERT INTO "products" ("name", "price", "created_at", "updated_at", "user_id") SELECT "t"."name", "t"."price", "t"."created_at", "t"."updated_at", "users"."id" FROM "_sg_input" i, "users", json_populate_record(NULL::products, i.j) t RETURNING *) SELECT json_object_agg('product', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name", "user_1_join"."json_1" AS "user") AS "json_row_0")) AS "json_0" FROM (SELECT "products"."id", "products"."name", "products"."user_id" FROM "products" LIMIT ('1') :: integer) AS "products_0" LEFT OUTER JOIN LATERAL (SELECT row_to_json((SELECT "json_row_1" FROM (SELECT "users_1"."id" AS "id", "users_1"."full_name" AS "full_name", "users_1"."email" AS "email") AS "json_row_1")) AS "json_1" FROM (SELECT "users"."id", "users"."full_name", "users"."email" FROM "users" WHERE ((("users"."id") = ("products_0"."user_id"))) LIMIT ('1') :: integer) AS "users_1" LIMIT ('1') :: integer) AS "user_1_join" ON ('true') LIMIT ('1') :: integer) AS "sel_0"` sql := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "_x_users" AS (SELECT "id" FROM "_sg_input" i,"users" WHERE "users"."id"= ((i.j->'user'->'connect'->>'id'))::bigint LIMIT 1), "products" AS (INSERT INTO "products" ("name", "price", "created_at", "updated_at", "user_id") SELECT "t"."name", "t"."price", "t"."created_at", "t"."updated_at", "_x_users"."id" FROM "_sg_input" i, "_x_users", json_populate_record(NULL::products, i.j) t RETURNING *) SELECT json_object_agg('product', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name", "user_1_join"."json_1" AS "user", "tags_2_join"."json_2" AS "tags") AS "json_row_0")) AS "json_0" FROM (SELECT "products"."id", "products"."name", "products"."user_id", "products"."tags" FROM "products" LIMIT ('1') :: integer) AS "products_0" LEFT OUTER JOIN LATERAL (SELECT coalesce(json_agg("json_2"), '[]') AS "json_2" FROM (SELECT row_to_json((SELECT "json_row_2" FROM (SELECT "tags_2"."id" AS "id", "tags_2"."name" AS "name") AS "json_row_2")) AS "json_2" FROM (SELECT "tags"."id", "tags"."name" FROM "tags" WHERE ((("tags"."slug") = any ("products_0"."tags"))) LIMIT ('20') :: integer) AS "tags_2" LIMIT ('20') :: integer) AS "json_agg_2") AS "tags_2_join" ON ('true') LEFT OUTER JOIN LATERAL (SELECT row_to_json((SELECT "json_row_1" FROM (SELECT "users_1"."id" AS "id", "users_1"."full_name" AS "full_name", "users_1"."email" AS "email") AS "json_row_1")) AS "json_1" FROM (SELECT "users"."id", "users"."full_name", "users"."email" FROM "users" WHERE ((("users"."id") = ("products_0"."user_id"))) LIMIT ('1') :: integer) AS "users_1" LIMIT ('1') :: integer) AS "user_1_join" ON ('true') LIMIT ('1') :: integer) AS "sel_0"`
vars := map[string]json.RawMessage{ vars := map[string]json.RawMessage{
"data": json.RawMessage(`{ "data": json.RawMessage(`{
@ -310,6 +314,43 @@ func nestedInsertOneToOneWithConnect(t *testing.T) {
} }
} }
func nestedInsertOneToOneWithConnectArray(t *testing.T) {
gql := `mutation {
product(insert: $data) {
id
name
user {
id
full_name
email
}
}
}`
sql := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "_x_users" AS (SELECT "id" FROM "_sg_input" i,"users" WHERE "users"."id" = ANY((select a::bigint AS list from json_array_elements_text((i.j->'user'->'connect'->>'id')::json) AS a)) LIMIT 1), "products" AS (INSERT INTO "products" ("name", "price", "created_at", "updated_at", "user_id") SELECT "t"."name", "t"."price", "t"."created_at", "t"."updated_at", "_x_users"."id" FROM "_sg_input" i, "_x_users", json_populate_record(NULL::products, i.j) t RETURNING *) SELECT json_object_agg('product', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name", "user_1_join"."json_1" AS "user") AS "json_row_0")) AS "json_0" FROM (SELECT "products"."id", "products"."name", "products"."user_id" FROM "products" LIMIT ('1') :: integer) AS "products_0" LEFT OUTER JOIN LATERAL (SELECT row_to_json((SELECT "json_row_1" FROM (SELECT "users_1"."id" AS "id", "users_1"."full_name" AS "full_name", "users_1"."email" AS "email") AS "json_row_1")) AS "json_1" FROM (SELECT "users"."id", "users"."full_name", "users"."email" FROM "users" WHERE ((("users"."id") = ("products_0"."user_id"))) LIMIT ('1') :: integer) AS "users_1" LIMIT ('1') :: integer) AS "user_1_join" ON ('true') LIMIT ('1') :: integer) AS "sel_0"`
vars := map[string]json.RawMessage{
"data": json.RawMessage(`{
"name": "Apple",
"price": 1.25,
"created_at": "now",
"updated_at": "now",
"user": {
"connect": { "id": [1,2] }
}
}`),
}
resSQL, err := compileGQLToPSQL(gql, vars, "admin")
if err != nil {
t.Fatal(err)
}
if string(resSQL) != sql {
t.Fatal(errNotExpected)
}
}
func TestCompileInsert(t *testing.T) { func TestCompileInsert(t *testing.T) {
t.Run("simpleInsert", simpleInsert) t.Run("simpleInsert", simpleInsert)
t.Run("singleInsert", singleInsert) t.Run("singleInsert", singleInsert)
@ -320,4 +361,6 @@ func TestCompileInsert(t *testing.T) {
t.Run("nestedInsertOneToOne", nestedInsertOneToOne) t.Run("nestedInsertOneToOne", nestedInsertOneToOne)
t.Run("nestedInsertOneToManyWithConnect", nestedInsertOneToManyWithConnect) t.Run("nestedInsertOneToManyWithConnect", nestedInsertOneToManyWithConnect)
t.Run("nestedInsertOneToOneWithConnect", nestedInsertOneToOneWithConnect) t.Run("nestedInsertOneToOneWithConnect", nestedInsertOneToOneWithConnect)
t.Run("nestedInsertOneToOneWithConnectArray", nestedInsertOneToOneWithConnectArray)
} }

View File

@ -101,6 +101,9 @@ type renitem struct {
data map[string]json.RawMessage data map[string]json.RawMessage
} }
// TODO: Handle cases where a column name matches the child table name
// the child path needs to be exluded in the json sent to insert or update
func (c *compilerContext) handleKVItem(st *util.Stack, item kvitem) error { func (c *compilerContext) handleKVItem(st *util.Stack, item kvitem) error {
var data map[string]json.RawMessage var data map[string]json.RawMessage
var array bool var array bool
@ -124,9 +127,6 @@ func (c *compilerContext) handleKVItem(st *util.Stack, item kvitem) error {
if v[0] != '{' && v[0] != '[' { if v[0] != '{' && v[0] != '[' {
continue continue
} }
if _, ok := item.ti.ColMap[k]; ok {
continue
}
// Get child-to-parent relationship // Get child-to-parent relationship
relCP, err := c.schema.GetRel(k, item.key) relCP, err := c.schema.GetRel(k, item.key)
@ -152,13 +152,9 @@ func (c *compilerContext) handleKVItem(st *util.Stack, item kvitem) error {
id++ id++
} }
} else {
ti, err := c.schema.GetTable(k)
if err != nil {
return err
}
// Get parent-to-child relationship // Get parent-to-child relationship
relPC, err := c.schema.GetRel(item.key, k) } else if relPC, err := c.schema.GetRel(item.key, k); err == nil {
ti, err := c.schema.GetTable(k)
if err != nil { if err != nil {
return err return err
} }
@ -277,8 +273,12 @@ func (c *compilerContext) renderUnionStmt(w io.Writer, item renitem) error {
io.WriteString(w, ` SET `) io.WriteString(w, ` SET `)
quoted(w, item.relPC.Right.Col) quoted(w, item.relPC.Right.Col)
io.WriteString(w, ` = `) io.WriteString(w, ` = `)
// When setting the id of the connected table in a one-to-many setting
// we always overwrite the value including for array columns
colWithTable(w, item.relPC.Left.Table, item.relPC.Left.Col) colWithTable(w, item.relPC.Left.Table, item.relPC.Left.Col)
io.WriteString(w, `FROM `)
io.WriteString(w, ` FROM `)
quoted(w, item.relPC.Left.Table) quoted(w, item.relPC.Left.Table)
io.WriteString(w, ` WHERE`) io.WriteString(w, ` WHERE`)
@ -290,7 +290,7 @@ func (c *compilerContext) renderUnionStmt(w io.Writer, item renitem) error {
} else { } else {
io.WriteString(w, ` (`) io.WriteString(w, ` (`)
} }
if err := renderKVItemWhere(w, v); err != nil { if err := renderWhereFromJSON(w, v, "connect", v.val); err != nil {
return err return err
} }
io.WriteString(w, `)`) io.WriteString(w, `)`)
@ -313,7 +313,19 @@ func (c *compilerContext) renderUnionStmt(w io.Writer, item renitem) error {
quoted(w, item.ti.Name) quoted(w, item.ti.Name)
io.WriteString(w, ` SET `) io.WriteString(w, ` SET `)
quoted(w, item.relPC.Right.Col) quoted(w, item.relPC.Right.Col)
io.WriteString(w, ` = NULL`) io.WriteString(w, ` = `)
if item.relPC.Right.Array {
io.WriteString(w, ` array_remove(`)
quoted(w, item.relPC.Right.Col)
io.WriteString(w, `, `)
colWithTable(w, item.relPC.Left.Table, item.relPC.Left.Col)
io.WriteString(w, `)`)
} else {
io.WriteString(w, ` NULL`)
}
io.WriteString(w, ` FROM `) io.WriteString(w, ` FROM `)
quoted(w, item.relPC.Left.Table) quoted(w, item.relPC.Left.Table)
io.WriteString(w, ` WHERE`) io.WriteString(w, ` WHERE`)
@ -326,7 +338,7 @@ func (c *compilerContext) renderUnionStmt(w io.Writer, item renitem) error {
} else { } else {
io.WriteString(w, ` (`) io.WriteString(w, ` (`)
} }
if err := renderKVItemWhere(w, v); err != nil { if err := renderWhereFromJSON(w, v, "disconnect", v.val); err != nil {
return err return err
} }
io.WriteString(w, `)`) io.WriteString(w, `)`)
@ -434,7 +446,10 @@ func (c *compilerContext) renderUpsert(qc *qcode.QCode, w io.Writer,
upsert, ok := vars[qc.ActionVar] upsert, ok := vars[qc.ActionVar]
if !ok { if !ok {
return 0, fmt.Errorf("Variable '%s' not defined", qc.ActionVar) return 0, fmt.Errorf("variable '%s' not defined", qc.ActionVar)
}
if len(upsert) == 0 {
return 0, fmt.Errorf("variable '%s' is empty", qc.ActionVar)
} }
if ti.PrimaryCol == nil { if ti.PrimaryCol == nil {
@ -508,18 +523,33 @@ func (c *compilerContext) renderConnectStmt(qc *qcode.QCode, w io.Writer,
rel := item.relPC rel := item.relPC
// Render only for parent-to-child relationship of one-to-one // Render only for parent-to-child relationship of one-to-one
// For this to work the child needs to found first so it's primary key
// can be set in the related column on the parent object.
// Eg. Create product and connect a user to it.
if rel.Type != RelOneToOne { if rel.Type != RelOneToOne {
return nil return nil
} }
io.WriteString(w, `, `) io.WriteString(w, `, "_x_`)
quoted(w, item.ti.Name) io.WriteString(c.w, item.ti.Name)
io.WriteString(c.w, ` AS (`) io.WriteString(c.w, `" AS (SELECT `)
io.WriteString(c.w, `SELECT * FROM `) if rel.Left.Array {
io.WriteString(w, `array_agg(DISTINCT `)
quoted(w, rel.Right.Col)
io.WriteString(w, `) AS `)
quoted(w, rel.Right.Col)
} else {
quoted(w, rel.Right.Col)
}
io.WriteString(c.w, ` FROM "_sg_input" i,`)
quoted(c.w, item.ti.Name) quoted(c.w, item.ti.Name)
io.WriteString(c.w, ` WHERE `) io.WriteString(c.w, ` WHERE `)
if err := renderKVItemWhere(c.w, item.kvitem); err != nil { if err := renderWhereFromJSON(c.w, item.kvitem, "connect", item.kvitem.val); err != nil {
return err return err
} }
io.WriteString(c.w, ` LIMIT 1)`) io.WriteString(c.w, ` LIMIT 1)`)
@ -533,50 +563,105 @@ func (c *compilerContext) renderDisconnectStmt(qc *qcode.QCode, w io.Writer,
rel := item.relPC rel := item.relPC
// Render only for parent-to-child relationship of one-to-one // Render only for parent-to-child relationship of one-to-one
// For this to work the child needs to found first so it's
// null value can beset in the related column on the parent object.
// Eg. Update product and diconnect the user from it.
if rel.Type != RelOneToOne { if rel.Type != RelOneToOne {
return nil return nil
} }
io.WriteString(w, `, `) io.WriteString(w, `, "_x_`)
quoted(w, item.ti.Name) io.WriteString(c.w, item.ti.Name)
io.WriteString(c.w, ` AS (`) io.WriteString(c.w, `" AS (`)
io.WriteString(c.w, `SELECT * FROM (VALUES(NULL::`) if rel.Right.Array {
io.WriteString(w, rel.Right.col.Type) io.WriteString(c.w, `SELECT `)
io.WriteString(c.w, `)) AS LOOKUP(`) quoted(w, rel.Right.Col)
quoted(w, rel.Right.Col) io.WriteString(c.w, ` FROM "_sg_input" i,`)
io.WriteString(c.w, `))`) quoted(c.w, item.ti.Name)
io.WriteString(c.w, ` WHERE `)
if err := renderWhereFromJSON(c.w, item.kvitem, "connect", item.kvitem.val); err != nil {
return err
}
io.WriteString(c.w, ` LIMIT 1))`)
} else {
io.WriteString(c.w, `SELECT * FROM (VALUES(NULL::`)
io.WriteString(w, rel.Right.col.Type)
io.WriteString(c.w, `)) AS LOOKUP(`)
quoted(w, rel.Right.Col)
io.WriteString(c.w, `))`)
}
return nil return nil
} }
func renderKVItemWhere(w io.Writer, item kvitem) error { func renderWhereFromJSON(w io.Writer, item kvitem, key string, val []byte) error {
return renderWhereFromJSON(w, item.ti.Name, item.val)
}
func renderWhereFromJSON(w io.Writer, table string, val []byte) error {
var kv map[string]json.RawMessage var kv map[string]json.RawMessage
ti := item.ti
if err := json.Unmarshal(val, &kv); err != nil { if err := json.Unmarshal(val, &kv); err != nil {
return err return err
} }
i := 0 i := 0
for k, v := range kv { for k, v := range kv {
col, ok := ti.ColMap[k]
if !ok {
continue
}
if i != 0 { if i != 0 {
io.WriteString(w, ` AND `) io.WriteString(w, ` AND `)
} }
colWithTable(w, table, k)
io.WriteString(w, ` = '`) if v[0] == '[' {
switch v[0] { colWithTable(w, ti.Name, k)
case '"':
w.Write(v[1 : len(v)-1]) if col.Array {
default: io.WriteString(w, ` && `)
w.Write(v) } else {
io.WriteString(w, ` = `)
}
io.WriteString(w, `ANY((select a::`)
io.WriteString(w, col.Type)
io.WriteString(w, ` AS list from json_array_elements_text(`)
renderPathJSON(w, item, key, k)
io.WriteString(w, `::json) AS a))`)
} else if col.Array {
io.WriteString(w, `(`)
renderPathJSON(w, item, key, k)
io.WriteString(w, `)::`)
io.WriteString(w, col.Type)
io.WriteString(w, ` = ANY(`)
colWithTable(w, ti.Name, k)
io.WriteString(w, `)`)
} else {
colWithTable(w, ti.Name, k)
io.WriteString(w, `= (`)
renderPathJSON(w, item, key, k)
io.WriteString(w, `)::`)
io.WriteString(w, col.Type)
} }
io.WriteString(w, `'`)
i++ i++
} }
return nil return nil
} }
func renderPathJSON(w io.Writer, item kvitem, key1, key2 string) {
io.WriteString(w, `(i.j->`)
joinPath(w, item.path)
io.WriteString(w, `->'`)
io.WriteString(w, key1)
io.WriteString(w, `'->>'`)
io.WriteString(w, key2)
io.WriteString(w, `')`)
}
func renderCteName(w io.Writer, item kvitem) error { func renderCteName(w io.Writer, item kvitem) error {
io.WriteString(w, `"`) io.WriteString(w, `"`)
io.WriteString(w, item.ti.Name) io.WriteString(w, item.ti.Name)

View File

@ -3,7 +3,6 @@ package psql
import ( import (
"log" "log"
"os" "os"
"strings"
"testing" "testing"
"github.com/dosco/super-graph/qcode" "github.com/dosco/super-graph/qcode"
@ -128,92 +127,7 @@ func TestMain(m *testing.M) {
log.Fatal(err) log.Fatal(err)
} }
tables := []DBTable{ schema := getTestSchema()
DBTable{Name: "customers", Type: "table"},
DBTable{Name: "users", Type: "table"},
DBTable{Name: "products", Type: "table"},
DBTable{Name: "purchases", Type: "table"},
DBTable{Name: "tags", Type: "table"},
}
columns := [][]DBColumn{
[]DBColumn{
DBColumn{ID: 1, Name: "id", Type: "bigint", NotNull: true, PrimaryKey: true, UniqueKey: true},
DBColumn{ID: 2, Name: "full_name", Type: "character varying", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 3, Name: "phone", Type: "character varying", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 4, Name: "email", Type: "character varying", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 5, Name: "encrypted_password", Type: "character varying", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 6, Name: "reset_password_token", Type: "character varying", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 7, Name: "reset_password_sent_at", Type: "timestamp without time zone", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 8, Name: "remember_created_at", Type: "timestamp without time zone", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 9, Name: "created_at", Type: "timestamp without time zone", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 10, Name: "updated_at", Type: "timestamp without time zone", NotNull: true, PrimaryKey: false, UniqueKey: false}},
[]DBColumn{
DBColumn{ID: 1, Name: "id", Type: "bigint", NotNull: true, PrimaryKey: true, UniqueKey: true},
DBColumn{ID: 2, Name: "full_name", Type: "character varying", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 3, Name: "phone", Type: "character varying", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 4, Name: "avatar", Type: "character varying", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 5, Name: "email", Type: "character varying", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 6, Name: "encrypted_password", Type: "character varying", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 7, Name: "reset_password_token", Type: "character varying", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 8, Name: "reset_password_sent_at", Type: "timestamp without time zone", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 9, Name: "remember_created_at", Type: "timestamp without time zone", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 10, Name: "created_at", Type: "timestamp without time zone", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 11, Name: "updated_at", Type: "timestamp without time zone", NotNull: true, PrimaryKey: false, UniqueKey: false}},
[]DBColumn{
DBColumn{ID: 1, Name: "id", Type: "bigint", NotNull: true, PrimaryKey: true, UniqueKey: true},
DBColumn{ID: 2, Name: "name", Type: "character varying", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 3, Name: "description", Type: "text", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 4, Name: "price", Type: "numeric(7,2)", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 5, Name: "user_id", Type: "bigint", NotNull: false, PrimaryKey: false, UniqueKey: false, FKeyTable: "users", FKeyColID: []int16{1}},
DBColumn{ID: 6, Name: "created_at", Type: "timestamp without time zone", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 7, Name: "updated_at", Type: "timestamp without time zone", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 8, Name: "tsv", Type: "tsvector", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 9, Name: "tags", Type: "text[]", NotNull: false, PrimaryKey: false, UniqueKey: false, FKeyTable: "tags", FKeyColID: []int16{3}, Array: true}},
[]DBColumn{
DBColumn{ID: 1, Name: "id", Type: "bigint", NotNull: true, PrimaryKey: true, UniqueKey: true},
DBColumn{ID: 2, Name: "customer_id", Type: "bigint", NotNull: false, PrimaryKey: false, UniqueKey: false, FKeyTable: "customers", FKeyColID: []int16{1}},
DBColumn{ID: 3, Name: "product_id", Type: "bigint", NotNull: false, PrimaryKey: false, UniqueKey: false, FKeyTable: "products", FKeyColID: []int16{1}},
DBColumn{ID: 4, Name: "sale_type", Type: "character varying", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 5, Name: "quantity", Type: "integer", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 6, Name: "due_date", Type: "timestamp without time zone", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 7, Name: "returned", Type: "timestamp without time zone", NotNull: false, PrimaryKey: false, UniqueKey: false}},
[]DBColumn{
DBColumn{ID: 1, Name: "id", Type: "bigint", NotNull: true, PrimaryKey: true, UniqueKey: true},
DBColumn{ID: 2, Name: "name", Type: "text", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 3, Name: "slug", Type: "text", NotNull: false, PrimaryKey: false, UniqueKey: false}},
}
for i := range tables {
tables[i].Key = strings.ToLower(tables[i].Name)
for n := range columns[i] {
columns[i][n].Key = strings.ToLower(columns[i][n].Name)
}
}
schema := &DBSchema{
ver: 110000,
t: make(map[string]*DBTableInfo),
rm: make(map[string]map[string]*DBRel),
}
aliases := map[string][]string{
"users": []string{"mes"},
}
for i, t := range tables {
err := schema.addTable(t, columns[i], aliases)
if err != nil {
log.Fatal(err)
}
}
for i, t := range tables {
err := schema.updateRelationships(t, columns[i])
if err != nil {
log.Fatal(err)
}
}
vars := NewVariables(map[string]string{ vars := NewVariables(map[string]string{
"admin_account_id": "5", "admin_account_id": "5",

View File

@ -82,17 +82,21 @@ func (co *Compiler) compileQuery(qc *qcode.QCode, w io.Writer) (uint32, error) {
multiRoot := (len(qc.Roots) > 1) multiRoot := (len(qc.Roots) > 1)
st := NewIntStack() st := NewIntStack()
si := 0
if multiRoot { if multiRoot {
io.WriteString(c.w, `SELECT row_to_json("json_root") FROM (SELECT `) io.WriteString(c.w, `SELECT row_to_json("json_root") FROM (SELECT `)
for i, id := range qc.Roots { for _, id := range qc.Roots {
root := qc.Selects[id] root := qc.Selects[id]
if root.SkipRender {
continue
}
st.Push(root.ID + closeBlock) st.Push(root.ID + closeBlock)
st.Push(root.ID) st.Push(root.ID)
if i != 0 { if si != 0 {
io.WriteString(c.w, `, `) io.WriteString(c.w, `, `)
} }
@ -103,24 +107,34 @@ func (co *Compiler) compileQuery(qc *qcode.QCode, w io.Writer) (uint32, error) {
io.WriteString(c.w, `"`) io.WriteString(c.w, `"`)
alias(c.w, root.FieldName) alias(c.w, root.FieldName)
si++
} }
io.WriteString(c.w, ` FROM `) if si != 0 {
io.WriteString(c.w, ` FROM `)
}
} else { } else {
root := qc.Selects[0] root := qc.Selects[0]
if !root.SkipRender {
io.WriteString(c.w, `SELECT json_object_agg(`)
io.WriteString(c.w, `'`)
io.WriteString(c.w, root.FieldName)
io.WriteString(c.w, `', `)
io.WriteString(c.w, `json_`)
int2string(c.w, root.ID)
io.WriteString(c.w, `SELECT json_object_agg(`) st.Push(root.ID + closeBlock)
io.WriteString(c.w, `'`) st.Push(root.ID)
io.WriteString(c.w, root.FieldName)
io.WriteString(c.w, `', `)
io.WriteString(c.w, `json_`)
int2string(c.w, root.ID)
st.Push(root.ID + closeBlock) io.WriteString(c.w, `) FROM `)
st.Push(root.ID) si++
}
}
io.WriteString(c.w, `) FROM `) if si == 0 {
return 0, errors.New("all tables skipped. cannot render query")
} }
var ignored uint32 var ignored uint32
@ -161,6 +175,9 @@ func (co *Compiler) compileQuery(qc *qcode.QCode, w io.Writer) (uint32, error) {
continue continue
} }
child := &c.s[cid] child := &c.s[cid]
if child.SkipRender {
continue
}
st.Push(child.ID + closeBlock) st.Push(child.ID + closeBlock)
st.Push(child.ID) st.Push(child.ID)
@ -207,7 +224,7 @@ func (co *Compiler) compileQuery(qc *qcode.QCode, w io.Writer) (uint32, error) {
return ignored, nil return ignored, nil
} }
func (c *compilerContext) processChildren(sel *qcode.Select, ti *DBTableInfo) (uint32, []*qcode.Column) { func (c *compilerContext) processChildren(sel *qcode.Select, ti *DBTableInfo) (uint32, []*qcode.Column, error) {
var skipped uint32 var skipped uint32
cols := make([]*qcode.Column, 0, len(sel.Cols)) cols := make([]*qcode.Column, 0, len(sel.Cols))
@ -226,40 +243,63 @@ func (c *compilerContext) processChildren(sel *qcode.Select, ti *DBTableInfo) (u
rel, err := c.schema.GetRel(child.Name, ti.Name) rel, err := c.schema.GetRel(child.Name, ti.Name)
if err != nil { if err != nil {
skipped |= (1 << uint(id)) return 0, nil, err
continue //skipped |= (1 << uint(id))
//continue
} }
switch rel.Type { switch rel.Type {
case RelOneToOne, RelOneToMany: case RelOneToOne, RelOneToMany:
if _, ok := colmap[rel.Right.Col]; !ok { if _, ok := colmap[rel.Right.Col]; !ok {
cols = append(cols, &qcode.Column{Table: ti.Name, Name: rel.Right.Col, FieldName: rel.Right.Col}) cols = append(cols, &qcode.Column{Table: ti.Name, Name: rel.Right.Col, FieldName: rel.Right.Col})
colmap[rel.Right.Col] = struct{}{}
} }
colmap[rel.Right.Col] = struct{}{}
case RelOneToManyThrough: case RelOneToManyThrough:
if _, ok := colmap[rel.Left.Col]; !ok { if _, ok := colmap[rel.Left.Col]; !ok {
cols = append(cols, &qcode.Column{Table: ti.Name, Name: rel.Left.Col, FieldName: rel.Left.Col}) cols = append(cols, &qcode.Column{Table: ti.Name, Name: rel.Left.Col, FieldName: rel.Left.Col})
colmap[rel.Left.Col] = struct{}{}
}
case RelEmbedded:
if _, ok := colmap[rel.Left.Col]; !ok {
cols = append(cols, &qcode.Column{Table: ti.Name, Name: rel.Left.Col, FieldName: rel.Left.Col})
colmap[rel.Left.Col] = struct{}{}
} }
colmap[rel.Left.Col] = struct{}{}
case RelRemote: case RelRemote:
if _, ok := colmap[rel.Left.Col]; !ok { if _, ok := colmap[rel.Left.Col]; !ok {
cols = append(cols, &qcode.Column{Table: ti.Name, Name: rel.Left.Col, FieldName: rel.Right.Col}) cols = append(cols, &qcode.Column{Table: ti.Name, Name: rel.Left.Col, FieldName: rel.Right.Col})
colmap[rel.Left.Col] = struct{}{}
skipped |= (1 << uint(id))
} }
colmap[rel.Left.Col] = struct{}{}
skipped |= (1 << uint(id))
default: default:
skipped |= (1 << uint(id)) return 0, nil, fmt.Errorf("unknown relationship %s", rel)
//skipped |= (1 << uint(id))
} }
} }
return skipped, cols return skipped, cols, nil
} }
func (c *compilerContext) renderSelect(sel *qcode.Select, ti *DBTableInfo) (uint32, error) { func (c *compilerContext) renderSelect(sel *qcode.Select, ti *DBTableInfo) (uint32, error) {
skipped, childCols := c.processChildren(sel, ti) var rel *DBRel
var err error
if sel.ParentID != -1 {
parent := c.s[sel.ParentID]
rel, err = c.schema.GetRel(ti.Name, parent.Name)
if err != nil {
return 0, err
}
}
skipped, childCols, err := c.processChildren(sel, ti)
if err != nil {
return 0, err
}
hasOrder := len(sel.OrderBy) != 0 hasOrder := len(sel.OrderBy) != 0
// SELECT // SELECT
@ -271,9 +311,8 @@ func (c *compilerContext) renderSelect(sel *qcode.Select, ti *DBTableInfo) (uint
io.WriteString(c.w, `"`) io.WriteString(c.w, `"`)
if hasOrder { if hasOrder {
err := c.renderOrderBy(sel, ti) if err := c.renderOrderBy(sel, ti); err != nil {
if err != nil { return 0, err
return skipped, err
} }
} }
@ -302,8 +341,7 @@ func (c *compilerContext) renderSelect(sel *qcode.Select, ti *DBTableInfo) (uint
c.renderRemoteRelColumns(sel, ti) c.renderRemoteRelColumns(sel, ti)
err := c.renderJoinedColumns(sel, ti, skipped) if err = c.renderJoinedColumns(sel, ti, skipped); err != nil {
if err != nil {
return skipped, err return skipped, err
} }
@ -322,7 +360,7 @@ func (c *compilerContext) renderSelect(sel *qcode.Select, ti *DBTableInfo) (uint
// END-SELECT // END-SELECT
// FROM (SELECT .... ) // FROM (SELECT .... )
err = c.renderBaseSelect(sel, ti, childCols, skipped) err = c.renderBaseSelect(sel, ti, rel, childCols, skipped)
if err != nil { if err != nil {
return skipped, err return skipped, err
} }
@ -475,18 +513,22 @@ func (c *compilerContext) renderRemoteRelColumns(sel *qcode.Select, ti *DBTableI
} }
func (c *compilerContext) renderJoinedColumns(sel *qcode.Select, ti *DBTableInfo, skipped uint32) error { func (c *compilerContext) renderJoinedColumns(sel *qcode.Select, ti *DBTableInfo, skipped uint32) error {
colsRendered := len(sel.Cols) != 0
// columns previously rendered
i := len(sel.Cols)
for _, id := range sel.Children { for _, id := range sel.Children {
skipThis := hasBit(skipped, uint32(id)) if hasBit(skipped, uint32(id)) {
if colsRendered && !skipThis {
io.WriteString(c.w, ", ")
}
if skipThis {
continue continue
} }
childSel := &c.s[id] childSel := &c.s[id]
if childSel.SkipRender {
continue
}
if i != 0 {
io.WriteString(c.w, ", ")
}
//fmt.Fprintf(w, `"%s_%d_join"."%s" AS "%s"`, //fmt.Fprintf(w, `"%s_%d_join"."%s" AS "%s"`,
//s.Name, s.ID, s.Name, s.FieldName) //s.Name, s.ID, s.Name, s.FieldName)
@ -500,16 +542,17 @@ func (c *compilerContext) renderJoinedColumns(sel *qcode.Select, ti *DBTableInfo
io.WriteString(c.w, `" AS "`) io.WriteString(c.w, `" AS "`)
io.WriteString(c.w, childSel.FieldName) io.WriteString(c.w, childSel.FieldName)
io.WriteString(c.w, `"`) io.WriteString(c.w, `"`)
i++
} }
return nil return nil
} }
func (c *compilerContext) renderBaseSelect(sel *qcode.Select, ti *DBTableInfo, func (c *compilerContext) renderBaseSelect(sel *qcode.Select, ti *DBTableInfo, rel *DBRel,
childCols []*qcode.Column, skipped uint32) error { childCols []*qcode.Column, skipped uint32) error {
var groupBy []int var groupBy []int
isRoot := sel.ParentID == -1 isRoot := (rel == nil)
isFil := (sel.Where != nil && sel.Where.Op != qcode.OpNop) isFil := (sel.Where != nil && sel.Where.Op != qcode.OpNop)
isSearch := sel.Args["search"] != nil isSearch := sel.Args["search"] != nil
isAgg := false isAgg := false
@ -632,10 +675,6 @@ func (c *compilerContext) renderBaseSelect(sel *qcode.Select, ti *DBTableInfo,
} }
} }
// if i != 0 && len(sel.OrderBy) != 0 {
// io.WriteString(c.w, ", ")
// }
for _, ob := range sel.OrderBy { for _, ob := range sel.OrderBy {
if _, ok := colmap[ob.Col]; ok { if _, ok := colmap[ob.Col]; ok {
continue continue
@ -664,10 +703,7 @@ func (c *compilerContext) renderBaseSelect(sel *qcode.Select, ti *DBTableInfo,
io.WriteString(c.w, ` FROM `) io.WriteString(c.w, ` FROM `)
//fmt.Fprintf(w, ` FROM "%s"`, c.sel.Name) c.renderFrom(sel, ti, rel)
io.WriteString(c.w, `"`)
io.WriteString(c.w, ti.Name)
io.WriteString(c.w, `"`)
// if tn, ok := c.tmap[sel.Name]; ok { // if tn, ok := c.tmap[sel.Name]; ok {
// //fmt.Fprintf(w, ` FROM "%s" AS "%s"`, tn, c.sel.Name) // //fmt.Fprintf(w, ` FROM "%s" AS "%s"`, tn, c.sel.Name)
@ -693,11 +729,9 @@ func (c *compilerContext) renderBaseSelect(sel *qcode.Select, ti *DBTableInfo,
} }
io.WriteString(c.w, ` WHERE (`) io.WriteString(c.w, ` WHERE (`)
if err := c.renderRelationship(sel, ti); err != nil { if err := c.renderRelationship(sel, ti); err != nil {
return err return err
} }
if isFil { if isFil {
io.WriteString(c.w, ` AND `) io.WriteString(c.w, ` AND `)
if err := c.renderWhere(sel, ti); err != nil { if err := c.renderWhere(sel, ti); err != nil {
@ -752,14 +786,50 @@ func (c *compilerContext) renderBaseSelect(sel *qcode.Select, ti *DBTableInfo,
return nil return nil
} }
func (c *compilerContext) renderFrom(sel *qcode.Select, ti *DBTableInfo, rel *DBRel) error {
if rel != nil && rel.Type == RelEmbedded {
// json_to_recordset('[{"a":1,"b":[1,2,3],"c":"bar"}, {"a":2,"b":[1,2,3],"c":"bar"}]') as x(a int, b text, d text);
io.WriteString(c.w, `"`)
io.WriteString(c.w, rel.Left.Table)
io.WriteString(c.w, `", `)
io.WriteString(c.w, ti.Type)
io.WriteString(c.w, `_to_recordset(`)
colWithTable(c.w, rel.Left.Table, rel.Right.Col)
io.WriteString(c.w, `) AS `)
io.WriteString(c.w, `"`)
io.WriteString(c.w, ti.Name)
io.WriteString(c.w, `"`)
io.WriteString(c.w, `(`)
for i, col := range ti.Columns {
if i != 0 {
io.WriteString(c.w, `, `)
}
io.WriteString(c.w, col.Name)
io.WriteString(c.w, ` `)
io.WriteString(c.w, col.Type)
}
io.WriteString(c.w, `)`)
} else {
//fmt.Fprintf(w, ` FROM "%s"`, c.sel.Name)
io.WriteString(c.w, `"`)
io.WriteString(c.w, ti.Name)
io.WriteString(c.w, `"`)
}
return nil
}
func (c *compilerContext) renderOrderByColumns(sel *qcode.Select, ti *DBTableInfo) { func (c *compilerContext) renderOrderByColumns(sel *qcode.Select, ti *DBTableInfo) {
colsRendered := len(sel.Cols) != 0 //colsRendered := len(sel.Cols) != 0
for i := range sel.OrderBy { for i := range sel.OrderBy {
if colsRendered { //io.WriteString(w, ", ")
//io.WriteString(w, ", ") io.WriteString(c.w, `, `)
io.WriteString(c.w, `, `)
}
col := sel.OrderBy[i].Col col := sel.OrderBy[i].Col
//fmt.Fprintf(w, `"%s_%d"."%s" AS "%s_%d_%s_ob"`, //fmt.Fprintf(w, `"%s_%d"."%s" AS "%s_%d_%s_ob"`,
@ -834,7 +904,13 @@ func (c *compilerContext) renderRelationshipByName(table, parent string, id int3
io.WriteString(c.w, `) = (`) io.WriteString(c.w, `) = (`)
colWithTable(c.w, rel.Through, rel.Right.Col) colWithTable(c.w, rel.Through, rel.Right.Col)
} }
case RelEmbedded:
colWithTable(c.w, rel.Left.Table, rel.Left.Col)
io.WriteString(c.w, `) = (`)
colWithTableID(c.w, parent, id, rel.Left.Col)
} }
io.WriteString(c.w, `))`) io.WriteString(c.w, `))`)
return nil return nil

View File

@ -463,6 +463,56 @@ func multiRoot(t *testing.T) {
} }
} }
func jsonColumnAsTable(t *testing.T) {
gql := `query {
products {
id
name
tag_count {
count
tags {
name
}
}
}
}`
sql := `SELECT json_object_agg('products', json_0) FROM (SELECT coalesce(json_agg("json_0"), '[]') AS "json_0" FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name", "tag_count_1_join"."json_1" AS "tag_count") AS "json_row_0")) AS "json_0" FROM (SELECT "products"."id", "products"."name" FROM "products" LIMIT ('20') :: integer) AS "products_0" LEFT OUTER JOIN LATERAL (SELECT row_to_json((SELECT "json_row_1" FROM (SELECT "tag_count_1"."count" AS "count", "tags_2_join"."json_2" AS "tags") AS "json_row_1")) AS "json_1" FROM (SELECT "tag_count"."count", "tag_count"."tag_id" FROM "products", json_to_recordset("products"."tag_count") AS "tag_count"(tag_id bigint, count int) WHERE ((("products"."id") = ("products_0"."id"))) LIMIT ('1') :: integer) AS "tag_count_1" LEFT OUTER JOIN LATERAL (SELECT coalesce(json_agg("json_2"), '[]') AS "json_2" FROM (SELECT row_to_json((SELECT "json_row_2" FROM (SELECT "tags_2"."name" AS "name") AS "json_row_2")) AS "json_2" FROM (SELECT "tags"."name" FROM "tags" WHERE ((("tags"."id") = ("tag_count_1"."tag_id"))) LIMIT ('20') :: integer) AS "tags_2" LIMIT ('20') :: integer) AS "json_agg_2") AS "tags_2_join" ON ('true') LIMIT ('1') :: integer) AS "tag_count_1_join" ON ('true') LIMIT ('20') :: integer) AS "json_agg_0") AS "sel_0"`
resSQL, err := compileGQLToPSQL(gql, nil, "admin")
if err != nil {
t.Fatal(err)
}
if string(resSQL) != sql {
t.Fatal(errNotExpected)
}
}
func skipUserIDForAnonRole(t *testing.T) {
gql := `query {
products {
id
name
user(where: { id: { eq: $user_id } }) {
id
email
}
}
}`
sql := `SELECT json_object_agg('products', json_0) FROM (SELECT coalesce(json_agg("json_0"), '[]') AS "json_0" FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name") AS "json_row_0")) AS "json_0" FROM (SELECT "products"."id", "products"."name", "products"."user_id" FROM "products" LIMIT ('20') :: integer) AS "products_0" LIMIT ('20') :: integer) AS "json_agg_0") AS "sel_0"`
resSQL, err := compileGQLToPSQL(gql, nil, "anon")
if err != nil {
t.Fatal(err)
}
if string(resSQL) != sql {
t.Fatal(errNotExpected)
}
}
func blockedQuery(t *testing.T) { func blockedQuery(t *testing.T) {
gql := `query { gql := `query {
user(id: 5, where: { id: { gt: 3 } }) { user(id: 5, where: { id: { gt: 3 } }) {
@ -524,6 +574,8 @@ func TestCompileQuery(t *testing.T) {
t.Run("queryWithVariables", queryWithVariables) t.Run("queryWithVariables", queryWithVariables)
t.Run("withWhereOnRelations", withWhereOnRelations) t.Run("withWhereOnRelations", withWhereOnRelations)
t.Run("multiRoot", multiRoot) t.Run("multiRoot", multiRoot)
t.Run("jsonColumnAsTable", jsonColumnAsTable)
t.Run("skipUserIDForAnonRole", skipUserIDForAnonRole)
t.Run("blockedQuery", blockedQuery) t.Run("blockedQuery", blockedQuery)
t.Run("blockedFunctions", blockedFunctions) t.Run("blockedFunctions", blockedFunctions)
} }

View File

@ -15,6 +15,7 @@ type DBSchema struct {
type DBTableInfo struct { type DBTableInfo struct {
Name string Name string
Type string
Singular bool Singular bool
Columns []DBColumn Columns []DBColumn
PrimaryCol *DBColumn PrimaryCol *DBColumn
@ -29,6 +30,7 @@ const (
RelOneToOne RelType = iota + 1 RelOneToOne RelType = iota + 1
RelOneToMany RelOneToMany
RelOneToManyThrough RelOneToManyThrough
RelEmbedded
RelRemote RelRemote
) )
@ -51,7 +53,6 @@ type DBRel struct {
} }
func NewDBSchema(info *DBInfo, aliases map[string][]string) (*DBSchema, error) { func NewDBSchema(info *DBInfo, aliases map[string][]string) (*DBSchema, error) {
schema := &DBSchema{ schema := &DBSchema{
t: make(map[string]*DBTableInfo), t: make(map[string]*DBTableInfo),
rm: make(map[string]map[string]*DBRel), rm: make(map[string]map[string]*DBRel),
@ -83,6 +84,7 @@ func (s *DBSchema) addTable(
singular := flect.Singularize(t.Key) singular := flect.Singularize(t.Key)
s.t[singular] = &DBTableInfo{ s.t[singular] = &DBTableInfo{
Name: t.Name, Name: t.Name,
Type: t.Type,
Singular: true, Singular: true,
Columns: cols, Columns: cols,
ColMap: colmap, ColMap: colmap,
@ -92,6 +94,7 @@ func (s *DBSchema) addTable(
plural := flect.Pluralize(t.Key) plural := flect.Pluralize(t.Key)
s.t[plural] = &DBTableInfo{ s.t[plural] = &DBTableInfo{
Name: t.Name, Name: t.Name,
Type: t.Type,
Singular: false, Singular: false,
Columns: cols, Columns: cols,
ColMap: colmap, ColMap: colmap,
@ -136,20 +139,46 @@ func (s *DBSchema) updateRelationships(t DBTable, cols []DBColumn) error {
return fmt.Errorf("invalid foreign key table '%s'", ct) return fmt.Errorf("invalid foreign key table '%s'", ct)
} }
for _, c := range cols { for i := range cols {
if len(c.FKeyTable) == 0 || len(c.FKeyColID) == 0 { c := cols[i]
if len(c.FKeyTable) == 0 {
continue continue
} }
// Foreign key column name // Foreign key column name
ft := strings.ToLower(c.FKeyTable) ft := strings.ToLower(c.FKeyTable)
fcid := c.FKeyColID[0]
ti, ok := s.t[ft] ti, ok := s.t[ft]
if !ok { if !ok {
return fmt.Errorf("invalid foreign key table '%s'", ft) return fmt.Errorf("invalid foreign key table '%s'", ft)
} }
// This is an embedded relationship like when a json/jsonb column
// is exposed as a table
if c.Name == c.FKeyTable && len(c.FKeyColID) == 0 {
rel := &DBRel{Type: RelEmbedded}
rel.Left.col = cti.PrimaryCol
rel.Left.Table = cti.Name
rel.Left.Col = cti.PrimaryCol.Name
rel.Right.col = &c
rel.Right.Table = ti.Name
rel.Right.Col = c.Name
if err := s.SetRel(ft, ct, rel); err != nil {
return err
}
continue
}
if len(c.FKeyColID) == 0 {
continue
}
// Foreign key column id
fcid := c.FKeyColID[0]
fc, ok := ti.ColIDMap[fcid] fc, ok := ti.ColIDMap[fcid]
if !ok { if !ok {
return fmt.Errorf("invalid foreign key column id '%d' for table '%s'", return fmt.Errorf("invalid foreign key column id '%d' for table '%s'",
@ -188,10 +217,12 @@ func (s *DBSchema) updateRelationships(t DBTable, cols []DBColumn) error {
rel2 = &DBRel{Type: RelOneToMany} rel2 = &DBRel{Type: RelOneToMany}
} }
rel2.Left.col = fc
rel2.Left.Table = c.FKeyTable rel2.Left.Table = c.FKeyTable
rel2.Left.Col = fc.Name rel2.Left.Col = fc.Name
rel2.Left.Array = fc.Array rel2.Left.Array = fc.Array
rel2.Right.col = &c
rel2.Right.Table = t.Name rel2.Right.Table = t.Name
rel2.Right.Col = c.Name rel2.Right.Col = c.Name
rel2.Right.Array = c.Array rel2.Right.Array = c.Array
@ -249,9 +280,11 @@ func (s *DBSchema) updateSchemaOTMT(
rel1.Through = ti.Name rel1.Through = ti.Name
rel1.ColT = col2.Name rel1.ColT = col2.Name
rel1.Left.col = &col2
rel1.Left.Table = col2.FKeyTable rel1.Left.Table = col2.FKeyTable
rel1.Left.Col = fc2.Name rel1.Left.Col = fc2.Name
rel1.Right.col = &col1
rel1.Right.Table = ti.Name rel1.Right.Table = ti.Name
rel1.Right.Col = col1.Name rel1.Right.Col = col1.Name
@ -265,9 +298,11 @@ func (s *DBSchema) updateSchemaOTMT(
rel2.Through = ti.Name rel2.Through = ti.Name
rel2.ColT = col1.Name rel2.ColT = col1.Name
rel1.Left.col = fc1
rel2.Left.Table = col1.FKeyTable rel2.Left.Table = col1.FKeyTable
rel2.Left.Col = fc1.Name rel2.Left.Col = fc1.Name
rel1.Right.col = &col2
rel2.Right.Table = ti.Name rel2.Right.Table = ti.Name
rel2.Right.Col = col2.Name rel2.Right.Col = col2.Name

View File

@ -12,6 +12,8 @@ func (rt RelType) String() string {
return "one to many through" return "one to many through"
case RelRemote: case RelRemote:
return "remote" return "remote"
case RelEmbedded:
return "embedded"
} }
return "" return ""
} }

View File

@ -62,6 +62,20 @@ func GetDBInfo(db *pgxpool.Pool) (*DBInfo, error) {
return di, nil return di, nil
} }
func (di *DBInfo) AddTable(t DBTable, cols []DBColumn) {
t.ID = di.Tables[len(di.Tables)-1].ID
di.Tables = append(di.Tables, t)
di.colmap[t.Key] = make(map[string]*DBColumn, len(cols))
for i := range cols {
cols[i].ID = int16(i)
c := &cols[i]
di.colmap[t.Key][c.Key] = c
}
di.Columns = append(di.Columns, cols)
}
func (di *DBInfo) GetColumn(table, column string) (*DBColumn, bool) { func (di *DBInfo) GetColumn(table, column string) (*DBColumn, bool) {
v, ok := di.colmap[strings.ToLower(table)][strings.ToLower(column)] v, ok := di.colmap[strings.ToLower(table)][strings.ToLower(column)]
return v, ok return v, ok

102
psql/test_schema.go Normal file
View File

@ -0,0 +1,102 @@
package psql
import (
"log"
"strings"
)
func getTestSchema() *DBSchema {
tables := []DBTable{
DBTable{Name: "customers", Type: "table"},
DBTable{Name: "users", Type: "table"},
DBTable{Name: "products", Type: "table"},
DBTable{Name: "purchases", Type: "table"},
DBTable{Name: "tags", Type: "table"},
DBTable{Name: "tag_count", Type: "json"},
}
columns := [][]DBColumn{
[]DBColumn{
DBColumn{ID: 1, Name: "id", Type: "bigint", NotNull: true, PrimaryKey: true, UniqueKey: true},
DBColumn{ID: 2, Name: "full_name", Type: "character varying", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 3, Name: "phone", Type: "character varying", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 4, Name: "email", Type: "character varying", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 5, Name: "encrypted_password", Type: "character varying", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 6, Name: "reset_password_token", Type: "character varying", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 7, Name: "reset_password_sent_at", Type: "timestamp without time zone", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 8, Name: "remember_created_at", Type: "timestamp without time zone", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 9, Name: "created_at", Type: "timestamp without time zone", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 10, Name: "updated_at", Type: "timestamp without time zone", NotNull: true, PrimaryKey: false, UniqueKey: false}},
[]DBColumn{
DBColumn{ID: 1, Name: "id", Type: "bigint", NotNull: true, PrimaryKey: true, UniqueKey: true},
DBColumn{ID: 2, Name: "full_name", Type: "character varying", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 3, Name: "phone", Type: "character varying", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 4, Name: "avatar", Type: "character varying", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 5, Name: "email", Type: "character varying", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 6, Name: "encrypted_password", Type: "character varying", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 7, Name: "reset_password_token", Type: "character varying", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 8, Name: "reset_password_sent_at", Type: "timestamp without time zone", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 9, Name: "remember_created_at", Type: "timestamp without time zone", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 10, Name: "created_at", Type: "timestamp without time zone", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 11, Name: "updated_at", Type: "timestamp without time zone", NotNull: true, PrimaryKey: false, UniqueKey: false}},
[]DBColumn{
DBColumn{ID: 1, Name: "id", Type: "bigint", NotNull: true, PrimaryKey: true, UniqueKey: true},
DBColumn{ID: 2, Name: "name", Type: "character varying", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 3, Name: "description", Type: "text", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 4, Name: "price", Type: "numeric(7,2)", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 5, Name: "user_id", Type: "bigint", NotNull: false, PrimaryKey: false, UniqueKey: false, FKeyTable: "users", FKeyColID: []int16{1}},
DBColumn{ID: 6, Name: "created_at", Type: "timestamp without time zone", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 7, Name: "updated_at", Type: "timestamp without time zone", NotNull: true, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 8, Name: "tsv", Type: "tsvector", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 9, Name: "tags", Type: "text[]", NotNull: false, PrimaryKey: false, UniqueKey: false, FKeyTable: "tags", FKeyColID: []int16{3}, Array: true},
DBColumn{ID: 9, Name: "tag_count", Type: "json", NotNull: false, PrimaryKey: false, UniqueKey: false, FKeyTable: "tag_count", FKeyColID: []int16{}}},
[]DBColumn{
DBColumn{ID: 1, Name: "id", Type: "bigint", NotNull: true, PrimaryKey: true, UniqueKey: true},
DBColumn{ID: 2, Name: "customer_id", Type: "bigint", NotNull: false, PrimaryKey: false, UniqueKey: false, FKeyTable: "customers", FKeyColID: []int16{1}},
DBColumn{ID: 3, Name: "product_id", Type: "bigint", NotNull: false, PrimaryKey: false, UniqueKey: false, FKeyTable: "products", FKeyColID: []int16{1}},
DBColumn{ID: 4, Name: "sale_type", Type: "character varying", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 5, Name: "quantity", Type: "integer", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 6, Name: "due_date", Type: "timestamp without time zone", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 7, Name: "returned", Type: "timestamp without time zone", NotNull: false, PrimaryKey: false, UniqueKey: false}},
[]DBColumn{
DBColumn{ID: 1, Name: "id", Type: "bigint", NotNull: true, PrimaryKey: true, UniqueKey: true},
DBColumn{ID: 2, Name: "name", Type: "text", NotNull: false, PrimaryKey: false, UniqueKey: false},
DBColumn{ID: 3, Name: "slug", Type: "text", NotNull: false, PrimaryKey: false, UniqueKey: false}},
[]DBColumn{
DBColumn{ID: 1, Name: "tag_id", Type: "bigint", NotNull: false, PrimaryKey: false, UniqueKey: false, FKeyTable: "tags", FKeyColID: []int16{1}},
DBColumn{ID: 2, Name: "count", Type: "int", NotNull: false, PrimaryKey: false, UniqueKey: false}},
}
for i := range tables {
tables[i].Key = strings.ToLower(tables[i].Name)
for n := range columns[i] {
columns[i][n].Key = strings.ToLower(columns[i][n].Name)
}
}
schema := &DBSchema{
ver: 110000,
t: make(map[string]*DBTableInfo),
rm: make(map[string]map[string]*DBRel),
}
aliases := map[string][]string{
"users": []string{"mes"},
}
for i, t := range tables {
err := schema.addTable(t, columns[i], aliases)
if err != nil {
log.Fatal(err)
}
}
for i, t := range tables {
err := schema.updateRelationships(t, columns[i])
if err != nil {
log.Fatal(err)
}
}
return schema
}

View File

@ -15,7 +15,10 @@ func (c *compilerContext) renderUpdate(qc *qcode.QCode, w io.Writer,
update, ok := vars[qc.ActionVar] update, ok := vars[qc.ActionVar]
if !ok { if !ok {
return 0, fmt.Errorf("Variable '%s' not !defined", qc.ActionVar) return 0, fmt.Errorf("variable '%s' not !defined", qc.ActionVar)
}
if len(update) == 0 {
return 0, fmt.Errorf("variable '%s' is empty", qc.ActionVar)
} }
io.WriteString(c.w, `WITH "_sg_input" AS (SELECT '{{`) io.WriteString(c.w, `WITH "_sg_input" AS (SELECT '{{`)
@ -125,10 +128,10 @@ func (c *compilerContext) renderUpdateStmt(w io.Writer, qc *qcode.QCode, item re
if item.relPC.Type == RelOneToMany { if item.relPC.Type == RelOneToMany {
if conn, ok := item.data["where"]; ok { if conn, ok := item.data["where"]; ok {
io.WriteString(w, ` AND `) io.WriteString(w, ` AND `)
renderWhereFromJSON(w, item.ti.Name, conn) renderWhereFromJSON(w, item.kvitem, "where", conn)
} else if conn, ok := item.data["_where"]; ok { } else if conn, ok := item.data["_where"]; ok {
io.WriteString(w, ` AND `) io.WriteString(w, ` AND `)
renderWhereFromJSON(w, item.ti.Name, conn) renderWhereFromJSON(w, item.kvitem, "_where", conn)
} }
} }
io.WriteString(w, `)`) io.WriteString(w, `)`)
@ -165,9 +168,28 @@ func renderNestedUpdateRelColumns(w io.Writer, item kvitem, values bool) error {
for _, v := range item.items { for _, v := range item.items {
if v._ctype > 0 && v.relCP.Type == RelOneToMany { if v._ctype > 0 && v.relCP.Type == RelOneToMany {
if values { if values {
colWithTable(w, v.relCP.Left.Table, v.relCP.Left.Col) // if v.relCP.Right.Array {
// io.WriteString(w, `array_diff(`)
// colWithTable(w, v.relCP.Right.Table, v.relCP.Right.Col)
// io.WriteString(w, `, `)
// }
if v._ctype > 0 {
io.WriteString(w, `"_x_`)
io.WriteString(w, v.relCP.Left.Table)
io.WriteString(w, `".`)
quoted(w, v.relCP.Left.Col)
} else {
colWithTable(w, v.relCP.Left.Table, v.relCP.Left.Col)
}
// if v.relCP.Right.Array {
// io.WriteString(w, `)`)
// }
} else { } else {
quoted(w, v.relCP.Right.Col) quoted(w, v.relCP.Right.Col)
} }
} }
} }
@ -176,12 +198,13 @@ func renderNestedUpdateRelColumns(w io.Writer, item kvitem, values bool) error {
} }
func renderNestedUpdateRelTables(w io.Writer, item kvitem) error { func renderNestedUpdateRelTables(w io.Writer, item kvitem) error {
// Render child foreign key columns if child-to-parent // Render tables needed to set values if child-to-parent
// relationship is one-to-many // relationship is one-to-many
for _, v := range item.items { for _, v := range item.items {
if v._ctype > 0 && v.relCP.Type == RelOneToMany { if v._ctype > 0 && v.relCP.Type == RelOneToMany {
quoted(w, v.relCP.Left.Table) io.WriteString(w, `"_x_`)
io.WriteString(w, `, `) io.WriteString(w, v.relCP.Left.Table)
io.WriteString(w, `", `)
} }
} }

View File

@ -119,7 +119,7 @@ func nestedUpdateOneToMany(t *testing.T) {
} }
}` }`
sql := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "users" AS (UPDATE "users" SET ("full_name", "email", "created_at", "updated_at") = (SELECT "t"."full_name", "t"."email", "t"."created_at", "t"."updated_at" FROM "_sg_input" i, json_populate_record(NULL::users, i.j) t) WHERE (("users"."id") IS NOT DISTINCT FROM 8) RETURNING "users".*), "products" AS (UPDATE "products" SET ("name", "price", "created_at", "updated_at") = (SELECT "t"."name", "t"."price", "t"."created_at", "t"."updated_at" FROM "_sg_input" i, json_populate_record(NULL::products, i.j->'product') t) FROM "users" WHERE (("products"."user_id") = ("users"."id") AND "products"."id" = '2') RETURNING "products".*) SELECT json_object_agg('user', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "users_0"."id" AS "id", "users_0"."full_name" AS "full_name", "users_0"."email" AS "email", "product_1_join"."json_1" AS "product") AS "json_row_0")) AS "json_0" FROM (SELECT "users"."id", "users"."full_name", "users"."email" FROM "users" LIMIT ('1') :: integer) AS "users_0" LEFT OUTER JOIN LATERAL (SELECT row_to_json((SELECT "json_row_1" FROM (SELECT "products_1"."id" AS "id", "products_1"."name" AS "name", "products_1"."price" AS "price") AS "json_row_1")) AS "json_1" FROM (SELECT "products"."id", "products"."name", "products"."price" FROM "products" WHERE ((("products"."user_id") = ("users_0"."id"))) LIMIT ('1') :: integer) AS "products_1" LIMIT ('1') :: integer) AS "product_1_join" ON ('true') LIMIT ('1') :: integer) AS "sel_0"` sql := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "users" AS (UPDATE "users" SET ("full_name", "email", "created_at", "updated_at") = (SELECT "t"."full_name", "t"."email", "t"."created_at", "t"."updated_at" FROM "_sg_input" i, json_populate_record(NULL::users, i.j) t) WHERE (("users"."id") IS NOT DISTINCT FROM 8) RETURNING "users".*), "products" AS (UPDATE "products" SET ("name", "price", "created_at", "updated_at") = (SELECT "t"."name", "t"."price", "t"."created_at", "t"."updated_at" FROM "_sg_input" i, json_populate_record(NULL::products, i.j->'product') t) FROM "users" WHERE (("products"."user_id") = ("users"."id") AND "products"."id"= ((i.j->'product'->'where'->>'id'))::bigint) RETURNING "products".*) SELECT json_object_agg('user', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "users_0"."id" AS "id", "users_0"."full_name" AS "full_name", "users_0"."email" AS "email", "product_1_join"."json_1" AS "product") AS "json_row_0")) AS "json_0" FROM (SELECT "users"."id", "users"."full_name", "users"."email" FROM "users" LIMIT ('1') :: integer) AS "users_0" LEFT OUTER JOIN LATERAL (SELECT row_to_json((SELECT "json_row_1" FROM (SELECT "products_1"."id" AS "id", "products_1"."name" AS "name", "products_1"."price" AS "price") AS "json_row_1")) AS "json_1" FROM (SELECT "products"."id", "products"."name", "products"."price" FROM "products" WHERE ((("products"."user_id") = ("users_0"."id"))) LIMIT ('1') :: integer) AS "products_1" LIMIT ('1') :: integer) AS "product_1_join" ON ('true') LIMIT ('1') :: integer) AS "sel_0"`
vars := map[string]json.RawMessage{ vars := map[string]json.RawMessage{
"data": json.RawMessage(`{ "data": json.RawMessage(`{
@ -200,7 +200,7 @@ func nestedUpdateOneToManyWithConnect(t *testing.T) {
} }
}` }`
sql1 := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "users" AS (UPDATE "users" SET ("full_name", "email", "created_at", "updated_at") = (SELECT "t"."full_name", "t"."email", "t"."created_at", "t"."updated_at" FROM "_sg_input" i, json_populate_record(NULL::users, i.j) t) WHERE (("users"."id") = 6) RETURNING "users".*), "products_c" AS ( UPDATE "products" SET "user_id" = "users"."id"FROM "users" WHERE ("products"."id" = '7') RETURNING "products".*), "products_d" AS ( UPDATE "products" SET "user_id" = NULL FROM "users" WHERE ("products"."id" = '8') RETURNING "products".*), "products" AS (SELECT * FROM "products_c" UNION ALL SELECT * FROM "products_d") SELECT json_object_agg('user', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "users_0"."id" AS "id", "users_0"."full_name" AS "full_name", "users_0"."email" AS "email", "product_1_join"."json_1" AS "product") AS "json_row_0")) AS "json_0" FROM (SELECT "users"."id", "users"."full_name", "users"."email" FROM "users" LIMIT ('1') :: integer) AS "users_0" LEFT OUTER JOIN LATERAL (SELECT row_to_json((SELECT "json_row_1" FROM (SELECT "products_1"."id" AS "id", "products_1"."name" AS "name", "products_1"."price" AS "price") AS "json_row_1")) AS "json_1" FROM (SELECT "products"."id", "products"."name", "products"."price" FROM "products" WHERE ((("products"."user_id") = ("users_0"."id"))) LIMIT ('1') :: integer) AS "products_1" LIMIT ('1') :: integer) AS "product_1_join" ON ('true') LIMIT ('1') :: integer) AS "sel_0"` sql1 := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "users" AS (UPDATE "users" SET ("full_name", "email", "created_at", "updated_at") = (SELECT "t"."full_name", "t"."email", "t"."created_at", "t"."updated_at" FROM "_sg_input" i, json_populate_record(NULL::users, i.j) t) WHERE (("users"."id") = 6) RETURNING "users".*), "products_c" AS ( UPDATE "products" SET "user_id" = "users"."id" FROM "users" WHERE ("products"."id"= ((i.j->'product'->'connect'->>'id'))::bigint) RETURNING "products".*), "products_d" AS ( UPDATE "products" SET "user_id" = NULL FROM "users" WHERE ("products"."id"= ((i.j->'product'->'disconnect'->>'id'))::bigint) RETURNING "products".*), "products" AS (SELECT * FROM "products_c" UNION ALL SELECT * FROM "products_d") SELECT json_object_agg('user', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "users_0"."id" AS "id", "users_0"."full_name" AS "full_name", "users_0"."email" AS "email", "product_1_join"."json_1" AS "product") AS "json_row_0")) AS "json_0" FROM (SELECT "users"."id", "users"."full_name", "users"."email" FROM "users" LIMIT ('1') :: integer) AS "users_0" LEFT OUTER JOIN LATERAL (SELECT row_to_json((SELECT "json_row_1" FROM (SELECT "products_1"."id" AS "id", "products_1"."name" AS "name", "products_1"."price" AS "price") AS "json_row_1")) AS "json_1" FROM (SELECT "products"."id", "products"."name", "products"."price" FROM "products" WHERE ((("products"."user_id") = ("users_0"."id"))) LIMIT ('1') :: integer) AS "products_1" LIMIT ('1') :: integer) AS "product_1_join" ON ('true') LIMIT ('1') :: integer) AS "sel_0"`
vars := map[string]json.RawMessage{ vars := map[string]json.RawMessage{
"data": json.RawMessage(`{ "data": json.RawMessage(`{
@ -238,9 +238,9 @@ func nestedUpdateOneToOneWithConnect(t *testing.T) {
} }
}` }`
sql1 := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "users" AS (SELECT * FROM "users" WHERE "users"."id" = '5' AND "users"."email" = 'test@test.com' LIMIT 1), "products" AS (UPDATE "products" SET ("name", "price", "user_id") = (SELECT "t"."name", "t"."price", "users"."id" FROM "_sg_input" i, "users", json_populate_record(NULL::products, i.j) t) WHERE (("products"."id") = 9) RETURNING "products".*) SELECT json_object_agg('product', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name", "user_1_join"."json_1" AS "user") AS "json_row_0")) AS "json_0" FROM (SELECT "products"."id", "products"."name", "products"."user_id" FROM "products" LIMIT ('1') :: integer) AS "products_0" LEFT OUTER JOIN LATERAL (SELECT row_to_json((SELECT "json_row_1" FROM (SELECT "users_1"."id" AS "id", "users_1"."full_name" AS "full_name", "users_1"."email" AS "email") AS "json_row_1")) AS "json_1" FROM (SELECT "users"."id", "users"."full_name", "users"."email" FROM "users" WHERE ((("users"."id") = ("products_0"."user_id"))) LIMIT ('1') :: integer) AS "users_1" LIMIT ('1') :: integer) AS "user_1_join" ON ('true') LIMIT ('1') :: integer) AS "sel_0"` sql1 := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "_x_users" AS (SELECT "id" FROM "_sg_input" i,"users" WHERE "users"."id"= ((i.j->'user'->'connect'->>'id'))::bigint AND "users"."email"= ((i.j->'user'->'connect'->>'email'))::character varying LIMIT 1), "products" AS (UPDATE "products" SET ("name", "price", "user_id") = (SELECT "t"."name", "t"."price", "_x_users"."id" FROM "_sg_input" i, "_x_users", json_populate_record(NULL::products, i.j) t) WHERE (("products"."id") = 9) RETURNING "products".*) SELECT json_object_agg('product', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name", "user_1_join"."json_1" AS "user") AS "json_row_0")) AS "json_0" FROM (SELECT "products"."id", "products"."name", "products"."user_id" FROM "products" LIMIT ('1') :: integer) AS "products_0" LEFT OUTER JOIN LATERAL (SELECT row_to_json((SELECT "json_row_1" FROM (SELECT "users_1"."id" AS "id", "users_1"."full_name" AS "full_name", "users_1"."email" AS "email") AS "json_row_1")) AS "json_1" FROM (SELECT "users"."id", "users"."full_name", "users"."email" FROM "users" WHERE ((("users"."id") = ("products_0"."user_id"))) LIMIT ('1') :: integer) AS "users_1" LIMIT ('1') :: integer) AS "user_1_join" ON ('true') LIMIT ('1') :: integer) AS "sel_0"`
sql2 := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "users" AS (SELECT * FROM "users" WHERE "users"."email" = 'test@test.com' AND "users"."id" = '5' LIMIT 1), "products" AS (UPDATE "products" SET ("name", "price", "user_id") = (SELECT "t"."name", "t"."price", "users"."id" FROM "_sg_input" i, "users", json_populate_record(NULL::products, i.j) t) WHERE (("products"."id") = 9) RETURNING "products".*) SELECT json_object_agg('product', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name", "user_1_join"."json_1" AS "user") AS "json_row_0")) AS "json_0" FROM (SELECT "products"."id", "products"."name", "products"."user_id" FROM "products" LIMIT ('1') :: integer) AS "products_0" LEFT OUTER JOIN LATERAL (SELECT row_to_json((SELECT "json_row_1" FROM (SELECT "users_1"."id" AS "id", "users_1"."full_name" AS "full_name", "users_1"."email" AS "email") AS "json_row_1")) AS "json_1" FROM (SELECT "users"."id", "users"."full_name", "users"."email" FROM "users" WHERE ((("users"."id") = ("products_0"."user_id"))) LIMIT ('1') :: integer) AS "users_1" LIMIT ('1') :: integer) AS "user_1_join" ON ('true') LIMIT ('1') :: integer) AS "sel_0"` sql2 := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "_x_users" AS (SELECT "id" FROM "_sg_input" i,"users" WHERE "users"."email"= ((i.j->'user'->'connect'->>'email'))::character varying AND "users"."id"= ((i.j->'user'->'connect'->>'id'))::bigint LIMIT 1), "products" AS (UPDATE "products" SET ("name", "price", "user_id") = (SELECT "t"."name", "t"."price", "_x_users"."id" FROM "_sg_input" i, "_x_users", json_populate_record(NULL::products, i.j) t) WHERE (("products"."id") = 9) RETURNING "products".*) SELECT json_object_agg('product', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name", "user_1_join"."json_1" AS "user") AS "json_row_0")) AS "json_0" FROM (SELECT "products"."id", "products"."name", "products"."user_id" FROM "products" LIMIT ('1') :: integer) AS "products_0" LEFT OUTER JOIN LATERAL (SELECT row_to_json((SELECT "json_row_1" FROM (SELECT "users_1"."id" AS "id", "users_1"."full_name" AS "full_name", "users_1"."email" AS "email") AS "json_row_1")) AS "json_1" FROM (SELECT "users"."id", "users"."full_name", "users"."email" FROM "users" WHERE ((("users"."id") = ("products_0"."user_id"))) LIMIT ('1') :: integer) AS "users_1" LIMIT ('1') :: integer) AS "user_1_join" ON ('true') LIMIT ('1') :: integer) AS "sel_0"`
vars := map[string]json.RawMessage{ vars := map[string]json.RawMessage{
"data": json.RawMessage(`{ "data": json.RawMessage(`{
@ -273,7 +273,7 @@ func nestedUpdateOneToOneWithDisconnect(t *testing.T) {
} }
}` }`
sql := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "users" AS (SELECT * FROM (VALUES(NULL::bigint)) AS LOOKUP("id")), "products" AS (UPDATE "products" SET ("name", "price", "user_id") = (SELECT "t"."name", "t"."price", "users"."id" FROM "_sg_input" i, "users", json_populate_record(NULL::products, i.j) t) WHERE (("products"."id") = 2) RETURNING "products".*) SELECT json_object_agg('product', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name", "products_0"."user_id" AS "user_id") AS "json_row_0")) AS "json_0" FROM (SELECT "products"."id", "products"."name", "products"."user_id" FROM "products" LIMIT ('1') :: integer) AS "products_0" LIMIT ('1') :: integer) AS "sel_0"` sql := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "_x_users" AS (SELECT * FROM (VALUES(NULL::bigint)) AS LOOKUP("id")), "products" AS (UPDATE "products" SET ("name", "price", "user_id") = (SELECT "t"."name", "t"."price", "_x_users"."id" FROM "_sg_input" i, "_x_users", json_populate_record(NULL::products, i.j) t) WHERE (("products"."id") = 2) RETURNING "products".*) SELECT json_object_agg('product', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name", "products_0"."user_id" AS "user_id") AS "json_row_0")) AS "json_0" FROM (SELECT "products"."id", "products"."name", "products"."user_id" FROM "products" LIMIT ('1') :: integer) AS "products_0" LIMIT ('1') :: integer) AS "sel_0"`
vars := map[string]json.RawMessage{ vars := map[string]json.RawMessage{
"data": json.RawMessage(`{ "data": json.RawMessage(`{
@ -295,6 +295,37 @@ func nestedUpdateOneToOneWithDisconnect(t *testing.T) {
} }
} }
// func nestedUpdateOneToOneWithDisconnectArray(t *testing.T) {
// gql := `mutation {
// product(update: $data, id: 2) {
// id
// name
// user_id
// }
// }`
// sql := `WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "users" AS (SELECT * FROM (VALUES(NULL::bigint)) AS LOOKUP("id")), "products" AS (UPDATE "products" SET ("name", "price", "user_id") = (SELECT "t"."name", "t"."price", "users"."id" FROM "_sg_input" i, "users", json_populate_record(NULL::products, i.j) t) WHERE (("products"."id") = 2) RETURNING "products".*) SELECT json_object_agg('product', json_0) FROM (SELECT row_to_json((SELECT "json_row_0" FROM (SELECT "products_0"."id" AS "id", "products_0"."name" AS "name", "products_0"."user_id" AS "user_id") AS "json_row_0")) AS "json_0" FROM (SELECT "products"."id", "products"."name", "products"."user_id" FROM "products" LIMIT ('1') :: integer) AS "products_0" LIMIT ('1') :: integer) AS "sel_0"`
// vars := map[string]json.RawMessage{
// "data": json.RawMessage(`{
// "name": "Apple",
// "price": 1.25,
// "user": {
// "disconnect": { "id": 5 }
// }
// }`),
// }
// resSQL, err := compileGQLToPSQL(gql, vars, "admin")
// if err != nil {
// t.Fatal(err)
// }
// if string(resSQL) != sql {
// t.Fatal(errNotExpected)
// }
// }
func TestCompileUpdate(t *testing.T) { func TestCompileUpdate(t *testing.T) {
t.Run("singleUpdate", singleUpdate) t.Run("singleUpdate", singleUpdate)
t.Run("simpleUpdateWithPresets", simpleUpdateWithPresets) t.Run("simpleUpdateWithPresets", simpleUpdateWithPresets)
@ -304,4 +335,5 @@ func TestCompileUpdate(t *testing.T) {
t.Run("nestedUpdateOneToManyWithConnect", nestedUpdateOneToManyWithConnect) t.Run("nestedUpdateOneToManyWithConnect", nestedUpdateOneToManyWithConnect)
t.Run("nestedUpdateOneToOneWithConnect", nestedUpdateOneToOneWithConnect) t.Run("nestedUpdateOneToOneWithConnect", nestedUpdateOneToOneWithConnect)
t.Run("nestedUpdateOneToOneWithDisconnect", nestedUpdateOneToOneWithDisconnect) t.Run("nestedUpdateOneToOneWithDisconnect", nestedUpdateOneToOneWithDisconnect)
//t.Run("nestedUpdateOneToOneWithDisconnectArray", nestedUpdateOneToOneWithDisconnectArray)
} }

View File

@ -45,6 +45,7 @@ type trval struct {
query struct { query struct {
limit string limit string
fil *Exp fil *Exp
filNU bool
cols map[string]struct{} cols map[string]struct{}
disable struct { disable struct {
funcs bool funcs bool
@ -53,6 +54,7 @@ type trval struct {
insert struct { insert struct {
fil *Exp fil *Exp
filNU bool
cols map[string]struct{} cols map[string]struct{}
psmap map[string]string psmap map[string]string
pslist []string pslist []string
@ -60,14 +62,16 @@ type trval struct {
update struct { update struct {
fil *Exp fil *Exp
filNU bool
cols map[string]struct{} cols map[string]struct{}
psmap map[string]string psmap map[string]string
pslist []string pslist []string
} }
delete struct { delete struct {
fil *Exp fil *Exp
cols map[string]struct{} filNU bool
cols map[string]struct{}
} }
} }
@ -88,21 +92,21 @@ func (trv *trval) allowedColumns(qt QType) map[string]struct{} {
return nil return nil
} }
func (trv *trval) filter(qt QType) *Exp { func (trv *trval) filter(qt QType) (*Exp, bool) {
switch qt { switch qt {
case QTQuery: case QTQuery:
return trv.query.fil return trv.query.fil, trv.query.filNU
case QTInsert: case QTInsert:
return trv.insert.fil return trv.insert.fil, trv.insert.filNU
case QTUpdate: case QTUpdate:
return trv.update.fil return trv.update.fil, trv.update.filNU
case QTDelete: case QTDelete:
return trv.delete.fil return trv.delete.fil, trv.delete.filNU
case QTUpsert: case QTUpsert:
return trv.insert.fil return trv.insert.fil, trv.insert.filNU
} }
return nil return nil, false
} }
func listToMap(list []string) map[string]struct{} { func listToMap(list []string) map[string]struct{} {

View File

@ -4,7 +4,11 @@ package qcode
// FuzzerEntrypoint for Fuzzbuzz // FuzzerEntrypoint for Fuzzbuzz
func Fuzz(data []byte) int { func Fuzz(data []byte) int {
GetQType(string(data)) qt := GetQType(string(data))
if qt > QTUpsert {
panic("qt > QTUpsert")
}
qcompile, _ := NewCompiler(Config{}) qcompile, _ := NewCompiler(Config{})
_, err := qcompile.Compile(data, "user") _, err := qcompile.Compile(data, "user")

View File

@ -167,11 +167,6 @@ func parseSelectionSet(gql []byte) (*Operation, error) {
return nil, fmt.Errorf("invalid '%s' found after closing '}'", p.current()) return nil, fmt.Errorf("invalid '%s' found after closing '}'", p.current())
} }
// for i := p.pos; i < len(p.items); i++ {
// fmt.Printf("2>>>> %#v\n", p.items[i])
// }
//return nil, fmt.Errorf("unexpected token")
lexPool.Put(l) lexPool.Put(l)
return op, err return op, err

View File

@ -51,6 +51,7 @@ type Select struct {
Allowed map[string]struct{} Allowed map[string]struct{}
PresetMap map[string]string PresetMap map[string]string
PresetList []string PresetList []string
SkipRender bool
} }
type Column struct { type Column struct {
@ -187,7 +188,7 @@ func (com *Compiler) AddRole(role, table string, trc TRConfig) error {
trv := &trval{} trv := &trval{}
// query config // query config
trv.query.fil, err = compileFilter(trc.Query.Filters) trv.query.fil, trv.query.filNU, err = compileFilter(trc.Query.Filters)
if err != nil { if err != nil {
return err return err
} }
@ -198,7 +199,8 @@ func (com *Compiler) AddRole(role, table string, trc TRConfig) error {
trv.query.disable.funcs = trc.Query.DisableFunctions trv.query.disable.funcs = trc.Query.DisableFunctions
// insert config // insert config
if trv.insert.fil, err = compileFilter(trc.Insert.Filters); err != nil { trv.insert.fil, trv.insert.filNU, err = compileFilter(trc.Insert.Filters)
if err != nil {
return err return err
} }
trv.insert.cols = listToMap(trc.Insert.Columns) trv.insert.cols = listToMap(trc.Insert.Columns)
@ -206,7 +208,8 @@ func (com *Compiler) AddRole(role, table string, trc TRConfig) error {
trv.insert.pslist = mapToList(trv.insert.psmap) trv.insert.pslist = mapToList(trv.insert.psmap)
// update config // update config
if trv.update.fil, err = compileFilter(trc.Update.Filters); err != nil { trv.update.fil, trv.update.filNU, err = compileFilter(trc.Update.Filters)
if err != nil {
return err return err
} }
trv.update.cols = listToMap(trc.Update.Columns) trv.update.cols = listToMap(trc.Update.Columns)
@ -214,7 +217,8 @@ func (com *Compiler) AddRole(role, table string, trc TRConfig) error {
trv.update.pslist = mapToList(trv.update.psmap) trv.update.pslist = mapToList(trv.update.psmap)
// delete config // delete config
if trv.delete.fil, err = compileFilter(trc.Delete.Filters); err != nil { trv.delete.fil, trv.delete.filNU, err = compileFilter(trc.Delete.Filters)
if err != nil {
return err return err
} }
trv.delete.cols = listToMap(trc.Delete.Columns) trv.delete.cols = listToMap(trc.Delete.Columns)
@ -334,7 +338,7 @@ func (com *Compiler) compileQuery(qc *QCode, op *Operation, role string) error {
s.FieldName = s.Name s.FieldName = s.Name
} }
err := com.compileArgs(qc, s, field.Args) err := com.compileArgs(qc, s, field.Args, role)
if err != nil { if err != nil {
return err return err
} }
@ -388,9 +392,16 @@ func (com *Compiler) compileQuery(qc *QCode, op *Operation, role string) error {
func (com *Compiler) addFilters(qc *QCode, sel *Select, role string) { func (com *Compiler) addFilters(qc *QCode, sel *Select, role string) {
var fil *Exp var fil *Exp
var nu bool
if trv, ok := com.tr[role][sel.Name]; ok { if trv, ok := com.tr[role][sel.Name]; ok {
fil = trv.filter(qc.Type) fil, nu = trv.filter(qc.Type)
} else if role == "anon" {
// Tables not defined under the anon role will not be rendered
sel.SkipRender = true
return
} else { } else {
return return
} }
@ -399,6 +410,10 @@ func (com *Compiler) addFilters(qc *QCode, sel *Select, role string) {
return return
} }
if nu && role == "anon" {
sel.SkipRender = true
}
switch fil.Op { switch fil.Op {
case OpNop: case OpNop:
case OpFalse: case OpFalse:
@ -420,7 +435,7 @@ func (com *Compiler) addFilters(qc *QCode, sel *Select, role string) {
} }
} }
func (com *Compiler) compileArgs(qc *QCode, sel *Select, args []Arg) error { func (com *Compiler) compileArgs(qc *QCode, sel *Select, args []Arg, role string) error {
var err error var err error
var ka bool var ka bool
@ -435,7 +450,7 @@ func (com *Compiler) compileArgs(qc *QCode, sel *Select, args []Arg) error {
err, ka = com.compileArgSearch(sel, arg) err, ka = com.compileArgSearch(sel, arg)
case "where": case "where":
err, ka = com.compileArgWhere(sel, arg) err, ka = com.compileArgWhere(sel, arg, role)
case "orderby", "order_by", "order": case "orderby", "order_by", "order":
err, ka = com.compileArgOrderBy(sel, arg) err, ka = com.compileArgOrderBy(sel, arg)
@ -501,19 +516,20 @@ func (com *Compiler) setMutationType(qc *QCode, args []Arg) error {
return nil return nil
} }
func (com *Compiler) compileArgObj(st *util.Stack, arg *Arg) (*Exp, error) { func (com *Compiler) compileArgObj(st *util.Stack, arg *Arg) (*Exp, bool, error) {
if arg.Val.Type != NodeObj { if arg.Val.Type != NodeObj {
return nil, fmt.Errorf("expecting an object") return nil, false, fmt.Errorf("expecting an object")
} }
return com.compileArgNode(st, arg.Val, true) return com.compileArgNode(st, arg.Val, true)
} }
func (com *Compiler) compileArgNode(st *util.Stack, node *Node, usePool bool) (*Exp, error) { func (com *Compiler) compileArgNode(st *util.Stack, node *Node, usePool bool) (*Exp, bool, error) {
var root *Exp var root *Exp
var needsUser bool
if node == nil || len(node.Children) == 0 { if node == nil || len(node.Children) == 0 {
return nil, errors.New("invalid argument value") return nil, needsUser, errors.New("invalid argument value")
} }
pushChild(st, nil, node) pushChild(st, nil, node)
@ -526,7 +542,7 @@ func (com *Compiler) compileArgNode(st *util.Stack, node *Node, usePool bool) (*
intf := st.Pop() intf := st.Pop()
node, ok := intf.(*Node) node, ok := intf.(*Node)
if !ok || node == nil { if !ok || node == nil {
return nil, fmt.Errorf("16: unexpected value %v (%t)", intf, intf) return nil, needsUser, fmt.Errorf("16: unexpected value %v (%t)", intf, intf)
} }
// Objects inside a list // Objects inside a list
@ -542,13 +558,17 @@ func (com *Compiler) compileArgNode(st *util.Stack, node *Node, usePool bool) (*
ex, err := newExp(st, node, usePool) ex, err := newExp(st, node, usePool)
if err != nil { if err != nil {
return nil, err return nil, needsUser, err
} }
if ex == nil { if ex == nil {
continue continue
} }
if ex.Type == ValVar && ex.Val == "user_id" {
needsUser = true
}
if node.exp == nil { if node.exp == nil {
root = ex root = ex
} else { } else {
@ -571,7 +591,7 @@ func (com *Compiler) compileArgNode(st *util.Stack, node *Node, usePool bool) (*
nodePool.Put(node) nodePool.Put(node)
} }
return root, nil return root, needsUser, nil
} }
func (com *Compiler) compileArgID(sel *Select, arg *Arg) (error, bool) { func (com *Compiler) compileArgID(sel *Select, arg *Arg) (error, bool) {
@ -640,15 +660,19 @@ func (com *Compiler) compileArgSearch(sel *Select, arg *Arg) (error, bool) {
return nil, true return nil, true
} }
func (com *Compiler) compileArgWhere(sel *Select, arg *Arg) (error, bool) { func (com *Compiler) compileArgWhere(sel *Select, arg *Arg, role string) (error, bool) {
st := util.NewStack() st := util.NewStack()
var err error var err error
ex, err := com.compileArgObj(st, arg) ex, nu, err := com.compileArgObj(st, arg)
if err != nil { if err != nil {
return err, false return err, false
} }
if nu && role == "anon" {
sel.SkipRender = true
}
if sel.Where != nil { if sel.Where != nil {
ow := sel.Where ow := sel.Where
@ -976,27 +1000,32 @@ func pushChild(st *util.Stack, exp *Exp, node *Node) {
} }
func compileFilter(filter []string) (*Exp, error) { func compileFilter(filter []string) (*Exp, bool, error) {
var fl *Exp var fl *Exp
var needsUser bool
com := &Compiler{} com := &Compiler{}
st := util.NewStack() st := util.NewStack()
if len(filter) == 0 { if len(filter) == 0 {
return &Exp{Op: OpNop, doFree: false}, nil return &Exp{Op: OpNop, doFree: false}, false, nil
} }
for i := range filter { for i := range filter {
if filter[i] == "false" { if filter[i] == "false" {
return &Exp{Op: OpFalse, doFree: false}, nil return &Exp{Op: OpFalse, doFree: false}, false, nil
} }
node, err := ParseArgValue(filter[i]) node, err := ParseArgValue(filter[i])
if err != nil { if err != nil {
return nil, err return nil, false, err
} }
f, err := com.compileArgNode(st, node, false) f, nu, err := com.compileArgNode(st, node, false)
if err != nil { if err != nil {
return nil, err return nil, false, err
}
if nu {
needsUser = true
} }
// TODO: Invalid table names in nested where causes fail silently // TODO: Invalid table names in nested where causes fail silently
@ -1010,7 +1039,7 @@ func compileFilter(filter []string) (*Exp, error) {
fl = &Exp{Op: OpAnd, Children: []*Exp{fl, f}, doFree: false} fl = &Exp{Op: OpAnd, Children: []*Exp{fl, f}, doFree: false}
} }
} }
return fl, nil return fl, needsUser, nil
} }
func buildPath(a []string) string { func buildPath(a []string) string {

View File

@ -1,320 +0,0 @@
package serv
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"os"
"path"
"sort"
"strings"
)
const (
AL_QUERY int = iota + 1
AL_VARS
)
type allowItem struct {
name string
hash string
uri string
gql string
vars json.RawMessage
}
var _allowList allowList
type allowList struct {
list []*allowItem
index map[string]int
filepath string
saveChan chan *allowItem
active bool
}
func initAllowList(cpath string) {
_allowList = allowList{
index: make(map[string]int),
saveChan: make(chan *allowItem),
active: true,
}
if len(cpath) != 0 {
fp := path.Join(cpath, "allow.list")
if _, err := os.Stat(fp); err == nil {
_allowList.filepath = fp
} else if !os.IsNotExist(err) {
errlog.Fatal().Err(err).Send()
}
}
if len(_allowList.filepath) == 0 {
fp := "./allow.list"
if _, err := os.Stat(fp); err == nil {
_allowList.filepath = fp
} else if !os.IsNotExist(err) {
errlog.Fatal().Err(err).Send()
}
}
if len(_allowList.filepath) == 0 {
fp := "./config/allow.list"
if _, err := os.Stat(fp); err == nil {
_allowList.filepath = fp
} else if !os.IsNotExist(err) {
errlog.Fatal().Err(err).Send()
}
}
if len(_allowList.filepath) == 0 {
if conf.Production {
errlog.Fatal().Msg("allow.list not found")
}
if len(cpath) == 0 {
_allowList.filepath = "./config/allow.list"
} else {
_allowList.filepath = path.Join(cpath, "allow.list")
}
logger.Warn().Msg("allow.list not found")
} else {
_allowList.load()
}
go func() {
for v := range _allowList.saveChan {
_allowList.save(v)
}
}()
}
func (al *allowList) add(req *gqlReq) {
if al.saveChan == nil || len(req.ref) == 0 || len(req.Query) == 0 {
return
}
var query string
for i := 0; i < len(req.Query); i++ {
c := req.Query[i]
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' {
query = req.Query
break
} else if c == '{' {
query = "query " + req.Query
break
}
}
al.saveChan <- &allowItem{
uri: req.ref,
gql: query,
vars: req.Vars,
}
}
func (al *allowList) upsert(query, vars []byte, uri string) {
q := string(query)
hash := gqlHash(q, vars, "")
name := gqlName(q)
var key string
if len(name) != 0 {
key = name
} else {
key = hash
}
if i, ok := al.index[key]; !ok {
al.list = append(al.list, &allowItem{
name: name,
hash: hash,
uri: uri,
gql: q,
vars: vars,
})
al.index[key] = len(al.list) - 1
} else {
item := al.list[i]
item.name = name
item.hash = hash
item.gql = q
item.vars = vars
}
}
func (al *allowList) load() {
b, err := ioutil.ReadFile(al.filepath)
if err != nil {
log.Fatal(err)
}
if len(b) == 0 {
return
}
var uri string
var varBytes []byte
s, e, c := 0, 0, 0
ty := 0
for {
if c == 0 && b[e] == '#' {
s = e
for e < len(b) && b[e] != '\n' {
e++
}
if (e - s) > 2 {
uri = strings.TrimSpace(string(b[(s + 1):e]))
}
}
if e >= len(b) {
break
}
if matchPrefix(b, e, "query") || matchPrefix(b, e, "mutation") {
if c == 0 {
s = e
}
ty = AL_QUERY
} else if matchPrefix(b, e, "variables") {
if c == 0 {
s = e + len("variables") + 1
}
ty = AL_VARS
} else if b[e] == '{' {
c++
} else if b[e] == '}' {
c--
if c == 0 {
if ty == AL_QUERY {
al.upsert(b[s:(e+1)], varBytes, uri)
varBytes = nil
} else if ty == AL_VARS {
varBytes = b[s:(e + 1)]
}
ty = 0
}
}
e++
if e >= len(b) {
break
}
}
}
func (al *allowList) save(item *allowItem) {
var err error
item.hash = gqlHash(item.gql, item.vars, "")
item.name = gqlName(item.gql)
if len(item.name) == 0 {
key := item.hash
if _, ok := al.index[key]; ok {
return
}
al.list = append(al.list, item)
al.index[key] = len(al.list) - 1
} else {
key := item.name
if i, ok := al.index[key]; ok {
if al.list[i].hash == item.hash {
return
}
al.list[i] = item
} else {
al.list = append(al.list, item)
al.index[key] = len(al.list) - 1
}
}
f, err := os.Create(al.filepath)
if err != nil {
logger.Warn().Err(err).Msgf("Failed to write allow list: %s", al.filepath)
return
}
defer f.Close()
keys := []string{}
urlMap := make(map[string][]*allowItem)
for _, v := range al.list {
urlMap[v.uri] = append(urlMap[v.uri], v)
}
for k := range urlMap {
keys = append(keys, k)
}
sort.Strings(keys)
for i := range keys {
k := keys[i]
v := urlMap[k]
if _, err := f.WriteString(fmt.Sprintf("# %s\n\n", k)); err != nil {
logger.Error().Err(err).Send()
return
}
for i := range v {
if len(v[i].vars) != 0 && !bytes.Equal(v[i].vars, []byte("{}")) {
vj, err := json.MarshalIndent(v[i].vars, "", " ")
if err != nil {
logger.Warn().Err(err).Msg("Failed to write allow list 'vars' to file")
continue
}
_, err = f.WriteString(fmt.Sprintf("variables %s\n\n", vj))
if err != nil {
logger.Error().Err(err).Send()
return
}
}
if v[i].gql[0] == '{' {
_, err = f.WriteString(fmt.Sprintf("query %s\n\n", v[i].gql))
} else {
_, err = f.WriteString(fmt.Sprintf("%s\n\n", v[i].gql))
}
if err != nil {
logger.Error().Err(err).Send()
return
}
}
}
}
func matchPrefix(b []byte, i int, s string) bool {
if (len(b) - i) < len(s) {
return false
}
for n := 0; n < len(s); n++ {
if b[(i+n)] != s[n] {
return false
}
}
return true
}

View File

@ -35,8 +35,6 @@ func argMap(ctx context.Context, vars []byte) func(w io.Writer, tag string) (int
fields := jsn.Get(vars, [][]byte{[]byte(tag)}) fields := jsn.Get(vars, [][]byte{[]byte(tag)})
fmt.Println(">>", tag, string(vars))
if len(fields) == 0 { if len(fields) == 0 {
return 0, nil return 0, nil
} }

View File

@ -1,15 +1,13 @@
package serv package serv
import ( import (
"context"
"fmt" "fmt"
"os"
"runtime" "runtime"
"strings" "strings"
"github.com/dosco/super-graph/allow"
"github.com/dosco/super-graph/psql" "github.com/dosco/super-graph/psql"
"github.com/dosco/super-graph/qcode" "github.com/dosco/super-graph/qcode"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool" "github.com/jackc/pgx/v4/pgxpool"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -31,17 +29,18 @@ var (
) )
var ( var (
logger zerolog.Logger // logger for everything but errors logger zerolog.Logger // logger for everything but errors
errlog zerolog.Logger // logger for errors includes line numbers errlog zerolog.Logger // logger for errors includes line numbers
conf *config // parsed config conf *config // parsed config
confPath string // path to the config file confPath string // path to the config file
db *pgxpool.Pool // database connection pool db *pgxpool.Pool // database connection pool
schema *psql.DBSchema // database tables, columns and relationships schema *psql.DBSchema // database tables, columns and relationships
qcompile *qcode.Compiler // qcode compiler allowList *allow.List // allow.list is contains queries allowed in production
pcompile *psql.Compiler // postgres sql compiler qcompile *qcode.Compiler // qcode compiler
pcompile *psql.Compiler // postgres sql compiler
) )
func Init() { func Cmd() {
initLog() initLog()
rootCmd := &cobra.Command{ rootCmd := &cobra.Command{
@ -156,159 +155,6 @@ e.g. db:migrate -+1
} }
} }
func initLog() {
out := zerolog.ConsoleWriter{Out: os.Stderr}
logger = zerolog.New(out).With().Timestamp().Logger()
errlog = logger.With().Caller().Logger()
}
func initConf() (*config, error) {
vi := newConfig(getConfigName())
if err := vi.ReadInConfig(); err != nil {
return nil, err
}
inherits := vi.GetString("inherits")
if len(inherits) != 0 {
vi = newConfig(inherits)
if err := vi.ReadInConfig(); err != nil {
return nil, err
}
if vi.IsSet("inherits") {
errlog.Fatal().Msgf("inherited config (%s) cannot itself inherit (%s)",
inherits,
vi.GetString("inherits"))
}
vi.SetConfigName(getConfigName())
if err := vi.MergeInConfig(); err != nil {
return nil, err
}
}
c := &config{}
if err := c.Init(vi); err != nil {
return nil, fmt.Errorf("unable to decode config, %v", err)
}
logLevel, err := zerolog.ParseLevel(c.LogLevel)
if err != nil {
errlog.Error().Err(err).Msg("error setting log_level")
}
zerolog.SetGlobalLevel(logLevel)
return c, nil
}
func initDB(c *config, useDB bool) (*pgx.Conn, error) {
config, _ := pgx.ParseConfig("")
config.Host = c.DB.Host
config.Port = c.DB.Port
config.User = c.DB.User
config.Password = c.DB.Password
config.RuntimeParams = map[string]string{
"application_name": c.AppName,
"search_path": c.DB.Schema,
}
if useDB {
config.Database = c.DB.DBName
}
switch c.LogLevel {
case "debug":
config.LogLevel = pgx.LogLevelDebug
case "info":
config.LogLevel = pgx.LogLevelInfo
case "warn":
config.LogLevel = pgx.LogLevelWarn
case "error":
config.LogLevel = pgx.LogLevelError
default:
config.LogLevel = pgx.LogLevelNone
}
config.Logger = NewSQLLogger(logger)
db, err := pgx.ConnectConfig(context.Background(), config)
if err != nil {
return nil, err
}
return db, nil
}
func initDBPool(c *config) (*pgxpool.Pool, error) {
config, _ := pgxpool.ParseConfig("")
config.ConnConfig.Host = c.DB.Host
config.ConnConfig.Port = c.DB.Port
config.ConnConfig.Database = c.DB.DBName
config.ConnConfig.User = c.DB.User
config.ConnConfig.Password = c.DB.Password
config.ConnConfig.RuntimeParams = map[string]string{
"application_name": c.AppName,
"search_path": c.DB.Schema,
}
switch c.LogLevel {
case "debug":
config.ConnConfig.LogLevel = pgx.LogLevelDebug
case "info":
config.ConnConfig.LogLevel = pgx.LogLevelInfo
case "warn":
config.ConnConfig.LogLevel = pgx.LogLevelWarn
case "error":
config.ConnConfig.LogLevel = pgx.LogLevelError
default:
config.ConnConfig.LogLevel = pgx.LogLevelNone
}
config.ConnConfig.Logger = NewSQLLogger(logger)
// if c.DB.MaxRetries != 0 {
// opt.MaxRetries = c.DB.MaxRetries
// }
if c.DB.PoolSize != 0 {
config.MaxConns = conf.DB.PoolSize
}
db, err := pgxpool.ConnectConfig(context.Background(), config)
if err != nil {
return nil, err
}
return db, nil
}
func initCompiler() {
var err error
qcompile, pcompile, err = initCompilers(conf)
if err != nil {
errlog.Fatal().Err(err).Msg("failed to initialize compilers")
}
if err := initResolvers(); err != nil {
errlog.Fatal().Err(err).Msg("failed to initialized resolvers")
}
}
func initConfOnce() {
var err error
if conf == nil {
if conf, err = initConf(); err != nil {
errlog.Fatal().Err(err).Msg("failed to read config")
}
}
}
func cmdVersion(cmd *cobra.Command, args []string) { func cmdVersion(cmd *cobra.Command, args []string) {
fmt.Printf("%s\n", BuildDetails()) fmt.Printf("%s\n", BuildDetails())
} }

View File

@ -12,13 +12,15 @@ func cmdServ(cmd *cobra.Command, args []string) {
} }
if conf != nil { if conf != nil {
if db, err = initDBPool(conf); err != nil { db, err = initDBPool(conf)
if err == nil {
initCompiler()
initAllowList(confPath)
initPreparedList(confPath)
} else {
fatalInProd(err, "failed to connect to database") fatalInProd(err, "failed to connect to database")
} }
initCompiler()
initAllowList(confPath)
initPreparedList()
} }
initWatcher(confPath) initWatcher(confPath)

View File

@ -87,6 +87,7 @@ type config struct {
type configColumn struct { type configColumn struct {
Name string Name string
Type string
ForeignKey string `mapstructure:"related_to"` ForeignKey string `mapstructure:"related_to"`
} }
@ -313,7 +314,7 @@ func (c *config) getAliasMap() map[string][]string {
for i := range c.Tables { for i := range c.Tables {
t := c.Tables[i] t := c.Tables[i]
if len(t.Table) == 0 { if len(t.Table) == 0 || len(t.Columns) != 0 {
continue continue
} }

View File

@ -8,9 +8,61 @@ import (
"github.com/dosco/super-graph/qcode" "github.com/dosco/super-graph/qcode"
) )
func addTables(c *config, di *psql.DBInfo) error {
for _, t := range c.Tables {
if len(t.Table) == 0 || len(t.Columns) == 0 {
continue
}
if err := addTable(di, t.Columns, t); err != nil {
return err
}
}
return nil
}
func addTable(di *psql.DBInfo, cols []configColumn, t configTable) error {
bc, ok := di.GetColumn(t.Table, t.Name)
if !ok {
return fmt.Errorf(
"Column '%s' not found on table '%s'",
t.Name, t.Table)
}
if bc.Type != "json" && bc.Type != "jsonb" {
return fmt.Errorf(
"Column '%s' in table '%s' is of type '%s'. Only JSON or JSONB is valid",
t.Name, t.Table, bc.Type)
}
table := psql.DBTable{
Name: t.Name,
Key: strings.ToLower(t.Name),
Type: bc.Type,
}
columns := make([]psql.DBColumn, 0, len(cols))
for i := range cols {
c := cols[i]
columns = append(columns, psql.DBColumn{
Name: c.Name,
Key: strings.ToLower(c.Name),
Type: c.Type,
})
}
di.AddTable(table, columns)
bc.FKeyTable = t.Name
return nil
}
func addForeignKeys(c *config, di *psql.DBInfo) error { func addForeignKeys(c *config, di *psql.DBInfo) error {
for _, t := range c.Tables { for _, t := range c.Tables {
for _, c := range t.Columns { for _, c := range t.Columns {
if len(c.ForeignKey) == 0 {
continue
}
if err := addForeignKey(di, c, t); err != nil { if err := addForeignKey(di, c, t); err != nil {
return err return err
} }
@ -23,7 +75,7 @@ func addForeignKey(di *psql.DBInfo, c configColumn, t configTable) error {
c1, ok := di.GetColumn(t.Name, c.Name) c1, ok := di.GetColumn(t.Name, c.Name)
if !ok { if !ok {
return fmt.Errorf( return fmt.Errorf(
"Invalid table '%s' or column '%s in config", "Invalid table '%s' or column '%s' in config",
t.Name, c.Name) t.Name, c.Name)
} }

View File

@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/cespare/xxhash/v2" "github.com/cespare/xxhash/v2"
"github.com/dosco/super-graph/allow"
"github.com/dosco/super-graph/qcode" "github.com/dosco/super-graph/qcode"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
"github.com/valyala/fasttemplate" "github.com/valyala/fasttemplate"
@ -107,7 +108,7 @@ func (c *coreContext) resolvePreparedSQL() ([]byte, *stmt, error) {
} }
ps, ok := _preparedList[gqlHash(c.req.Query, c.req.Vars, role)] ps, ok := _preparedList[stmtHash(allow.QueryName(c.req.Query), role)]
if !ok { if !ok {
return nil, nil, errUnauthorized return nil, nil, errUnauthorized
} }
@ -240,8 +241,10 @@ func (c *coreContext) resolveSQL() ([]byte, *stmt, error) {
} }
} }
if !conf.Production { if allowList.IsPersist() {
_allowList.add(&c.req) if err := allowList.Add(c.req.Vars, c.req.Query, c.req.ref); err != nil {
return nil, nil, err
}
} }
if len(stmts) > 1 { if len(stmts) > 1 {

View File

@ -4,7 +4,7 @@ package serv
func Fuzz(data []byte) int { func Fuzz(data []byte) int {
gql := string(data) gql := string(data)
gqlName(gql) QueryName(gql)
gqlHash(gql, nil, "") gqlHash(gql, nil, "")
return 1 return 1

View File

@ -10,7 +10,6 @@ func TestFuzzCrashers(t *testing.T) {
} }
for _, f := range crashers { for _, f := range crashers {
_ = gqlName(f)
gqlHash(f, nil, "") gqlHash(f, nil, "")
} }
} }

179
serv/init.go Normal file
View File

@ -0,0 +1,179 @@
package serv
import (
"context"
"fmt"
"os"
"github.com/dosco/super-graph/allow"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/rs/zerolog"
)
func initLog() {
out := zerolog.ConsoleWriter{Out: os.Stderr}
logger = zerolog.New(out).With().Timestamp().Logger()
errlog = logger.With().Caller().Logger()
}
func initConf() (*config, error) {
vi := newConfig(getConfigName())
if err := vi.ReadInConfig(); err != nil {
return nil, err
}
inherits := vi.GetString("inherits")
if len(inherits) != 0 {
vi = newConfig(inherits)
if err := vi.ReadInConfig(); err != nil {
return nil, err
}
if vi.IsSet("inherits") {
errlog.Fatal().Msgf("inherited config (%s) cannot itself inherit (%s)",
inherits,
vi.GetString("inherits"))
}
vi.SetConfigName(getConfigName())
if err := vi.MergeInConfig(); err != nil {
return nil, err
}
}
c := &config{}
if err := c.Init(vi); err != nil {
return nil, fmt.Errorf("unable to decode config, %v", err)
}
logLevel, err := zerolog.ParseLevel(c.LogLevel)
if err != nil {
errlog.Error().Err(err).Msg("error setting log_level")
}
zerolog.SetGlobalLevel(logLevel)
return c, nil
}
func initDB(c *config, useDB bool) (*pgx.Conn, error) {
config, _ := pgx.ParseConfig("")
config.Host = c.DB.Host
config.Port = c.DB.Port
config.User = c.DB.User
config.Password = c.DB.Password
config.RuntimeParams = map[string]string{
"application_name": c.AppName,
"search_path": c.DB.Schema,
}
if useDB {
config.Database = c.DB.DBName
}
switch c.LogLevel {
case "debug":
config.LogLevel = pgx.LogLevelDebug
case "info":
config.LogLevel = pgx.LogLevelInfo
case "warn":
config.LogLevel = pgx.LogLevelWarn
case "error":
config.LogLevel = pgx.LogLevelError
default:
config.LogLevel = pgx.LogLevelNone
}
config.Logger = NewSQLLogger(logger)
db, err := pgx.ConnectConfig(context.Background(), config)
if err != nil {
return nil, err
}
return db, nil
}
func initDBPool(c *config) (*pgxpool.Pool, error) {
config, _ := pgxpool.ParseConfig("")
config.ConnConfig.Host = c.DB.Host
config.ConnConfig.Port = c.DB.Port
config.ConnConfig.Database = c.DB.DBName
config.ConnConfig.User = c.DB.User
config.ConnConfig.Password = c.DB.Password
config.ConnConfig.RuntimeParams = map[string]string{
"application_name": c.AppName,
"search_path": c.DB.Schema,
}
switch c.LogLevel {
case "debug":
config.ConnConfig.LogLevel = pgx.LogLevelDebug
case "info":
config.ConnConfig.LogLevel = pgx.LogLevelInfo
case "warn":
config.ConnConfig.LogLevel = pgx.LogLevelWarn
case "error":
config.ConnConfig.LogLevel = pgx.LogLevelError
default:
config.ConnConfig.LogLevel = pgx.LogLevelNone
}
config.ConnConfig.Logger = NewSQLLogger(logger)
// if c.DB.MaxRetries != 0 {
// opt.MaxRetries = c.DB.MaxRetries
// }
if c.DB.PoolSize != 0 {
config.MaxConns = conf.DB.PoolSize
}
db, err := pgxpool.ConnectConfig(context.Background(), config)
if err != nil {
return nil, err
}
return db, nil
}
func initCompiler() {
var err error
qcompile, pcompile, err = initCompilers(conf)
if err != nil {
errlog.Fatal().Err(err).Msg("failed to initialize compilers")
}
if err := initResolvers(); err != nil {
errlog.Fatal().Err(err).Msg("failed to initialized resolvers")
}
}
func initConfOnce() {
var err error
if conf == nil {
if conf, err = initConf(); err != nil {
errlog.Fatal().Err(err).Msg("failed to read config")
}
}
}
func initAllowList(cpath string) {
var ac allow.Config
var err error
if !conf.Production {
ac = allow.Config{CreateIfNotExists: true, Persist: true}
}
allowList, err = allow.New(cpath, ac)
if err != nil {
errlog.Fatal().Err(err).Msg("failed to initialize allow list")
}
}

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"github.com/dosco/super-graph/allow"
"github.com/dosco/super-graph/qcode" "github.com/dosco/super-graph/qcode"
"github.com/jackc/pgconn" "github.com/jackc/pgconn"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
@ -23,7 +24,10 @@ var (
_preparedList map[string]*preparedItem _preparedList map[string]*preparedItem
) )
func initPreparedList() { func initPreparedList(cpath string) {
if allowList.IsPersist() {
return
}
_preparedList = make(map[string]*preparedItem) _preparedList = make(map[string]*preparedItem)
tx, err := db.Begin(context.Background()) tx, err := db.Begin(context.Background())
@ -43,30 +47,38 @@ func initPreparedList() {
success := 0 success := 0
for _, v := range _allowList.list { list, err := allowList.Load()
if len(v.gql) == 0 { if err != nil {
errlog.Fatal().Err(err).Send()
}
for _, v := range list {
if len(v.Query) == 0 {
continue continue
} }
err := prepareStmt(v.gql, v.vars) err := prepareStmt(v)
if err == nil { if err == nil {
success++ success++
continue continue
} }
if len(v.vars) == 0 { if len(v.Vars) == 0 {
logger.Warn().Err(err).Msg(v.gql) logger.Warn().Err(err).Msg(v.Query)
} else { } else {
logger.Warn().Err(err).Msgf("%s %s", v.vars, v.gql) logger.Warn().Err(err).Msgf("%s %s", v.Vars, v.Query)
} }
} }
logger.Info(). logger.Info().
Msgf("Registered %d of %d queries from allow.list as prepared statements", Msgf("Registered %d of %d queries from allow.list as prepared statements",
success, len(_allowList.list)) success, len(list))
} }
func prepareStmt(gql string, vars []byte) error { func prepareStmt(item allow.Item) error {
gql := item.Query
vars := item.Vars
qt := qcode.GetQType(gql) qt := qcode.GetQType(gql)
q := []byte(gql) q := []byte(gql)
@ -99,7 +111,7 @@ func prepareStmt(gql string, vars []byte) error {
logger.Debug().Msg("Prepared statement role: user") logger.Debug().Msg("Prepared statement role: user")
err = prepare(tx, stmts1, gqlHash(gql, vars, "user")) err = prepare(tx, stmts1, stmtHash(item.Name, "user"))
if err != nil { if err != nil {
return err return err
} }
@ -112,7 +124,7 @@ func prepareStmt(gql string, vars []byte) error {
return err return err
} }
err = prepare(tx, stmts2, gqlHash(gql, vars, "anon")) err = prepare(tx, stmts2, stmtHash(item.Name, "anon"))
if err != nil { if err != nil {
return err return err
} }
@ -127,7 +139,7 @@ func prepareStmt(gql string, vars []byte) error {
return err return err
} }
err = prepare(tx, stmts, gqlHash(gql, vars, role.Name)) err = prepare(tx, stmts, stmtHash(item.Name, role.Name))
if err != nil { if err != nil {
return err return err
} }

View File

@ -21,6 +21,10 @@ func initCompilers(c *config) (*qcode.Compiler, *psql.Compiler, error) {
return nil, nil, err return nil, nil, err
} }
if err = addTables(c, di); err != nil {
return nil, nil, err
}
if err = addForeignKeys(c, di); err != nil { if err = addForeignKeys(c, di); err != nil {
return nil, nil, err return nil, nil, err
} }

View File

@ -22,6 +22,14 @@ func mkkey(h *xxhash.Digest, k1 string, k2 string) uint64 {
return v return v
} }
// nolint: errcheck
func stmtHash(name string, role string) string {
h := sha1.New()
io.WriteString(h, strings.ToLower(name))
io.WriteString(h, role)
return hex.EncodeToString(h.Sum(nil))
}
// nolint: errcheck // nolint: errcheck
func gqlHash(b string, vars []byte, role string) string { func gqlHash(b string, vars []byte, role string) string {
b = strings.TrimSpace(b) b = strings.TrimSpace(b)
@ -108,30 +116,6 @@ func al(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
} }
func gqlName(b string) string {
state, s := 0, 0
for i := 0; i < len(b); i++ {
switch {
case state == 2 && b[i] == '{':
return b[s:i]
case state == 2 && b[i] == ' ':
return b[s:i]
case state == 1 && b[i] == '{':
return ""
case state == 1 && b[i] != ' ':
s = i
state = 2
case state == 1 && b[i] == ' ':
continue
case i != 0 && b[i] == ' ' && (b[i-1] == 'n' || b[i-1] == 'y'):
state = 1
}
}
return ""
}
func findStmt(role string, stmts []stmt) *stmt { func findStmt(role string, stmts []stmt) *stmt {
for i := range stmts { for i := range stmts {
if stmts[i].role.Name != role { if stmts[i].role.Name != role {

View File

@ -229,80 +229,3 @@ func TestGQLHashWithVars2(t *testing.T) {
t.Fatal("Hashes don't match they should") t.Fatal("Hashes don't match they should")
} }
} }
func TestGQLName1(t *testing.T) {
var q = `
query {
products(
distinct: [price]
where: { id: { and: { greater_or_equals: 20, lt: 28 } } }
) { id name } }`
name := gqlName(q)
if len(name) != 0 {
t.Fatal("Name should be empty, not ", name)
}
}
func TestGQLName2(t *testing.T) {
var q = `
query hakuna_matata {
products(
distinct: [price]
where: { id: { and: { greater_or_equals: 20, lt: 28 } } }
) {
id
name
}
}`
name := gqlName(q)
if name != "hakuna_matata" {
t.Fatal("Name should be 'hakuna_matata', not ", name)
}
}
func TestGQLName3(t *testing.T) {
var q = `
mutation means{ users { id } }`
// var v2 = ` { products( limit: 30, order_by: { price: desc }, distinct: [ price ] where: { id: { and: { greater_or_equals: 20, lt: 28 } } }) { id name price user { id email } } } `
name := gqlName(q)
if name != "means" {
t.Fatal("Name should be 'means', not ", name)
}
}
func TestGQLName4(t *testing.T) {
var q = `
query no_worries
users {
id
}
}`
name := gqlName(q)
if name != "no_worries" {
t.Fatal("Name should be 'no_worries', not ", name)
}
}
func TestGQLName5(t *testing.T) {
var q = `
{
users {
id
}
}`
name := gqlName(q)
if len(name) != 0 {
t.Fatal("Name should be empty, not ", name)
}
}