Compare commits

..

18 Commits

Author SHA1 Message Date
68a378c00f Fix issue with prepared statements skipped on error 2020-03-31 01:28:39 -04:00
d96eaf14f4 Fix bugs with escape char handling 2020-03-30 10:03:47 -04:00
01e488b69d Fix for bug blocking anon queries 2020-03-21 20:11:04 -04:00
7a450b16ba Fix issue with detecting many to many relationships 2020-03-18 20:19:56 -04:00
1ad8cbf15b Fix minor parser bug 2020-03-17 23:03:41 -04:00
f69f1c67d5 Fix to remove left over debug log 2020-03-16 01:43:26 -04:00
a172193955 Fix to ensure cursor fields can be defined in the query 2020-03-16 01:40:47 -04:00
81338b6123 Fix issues blocking Apollo client 2020-03-14 01:35:42 -04:00
265b93b203 Fix for encrypted cursor in production mode bug 2020-03-06 21:38:01 +05:30
6c240e21b4 Fix bug related to 'anon' role prepared statements 2020-03-06 15:39:15 +05:30
7930719eaa Add ability to set CORS headers 2020-03-06 09:47:51 +05:30
cc687b1b2b Fix issue with Docerfile CMD 2020-03-05 09:13:52 +05:30
3033dcf1a9 Fix issue with setting PORT env var 2020-03-04 15:39:53 +05:30
0381982d19 Fix upx version issue in Dockerfile 2020-03-04 12:27:07 +05:30
2b0a798faa Add 'secrets' command to startup script 2020-03-03 19:44:14 +05:30
8b6c562ac1 Add CSV import command to seed javascript 2020-03-03 13:45:47 +05:30
a1fb89b762 Add support for SQL in variables 2020-02-29 10:35:48 +05:30
c82a7bff0d Misprint (#43) 2020-02-24 10:48:50 +05:30
35 changed files with 533 additions and 151 deletions

View File

@ -6,14 +6,19 @@ RUN yarn
RUN yarn build
# stage: 2
FROM golang:1.13.4-alpine as go-build
FROM golang:1.14-alpine as go-build
RUN apk update && \
apk add --no-cache make && \
apk add --no-cache git && \
apk add --no-cache jq && \
apk add --no-cache upx=3.95-r2
RUN GO111MODULE=off go get -u github.com/rafaelsq/wtc
ARG SOPS_VERSION=3.5.0
ADD https://github.com/mozilla/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux /usr/local/bin/sops
RUN chmod 755 /usr/local/bin/sops
WORKDIR /app
COPY . /app
@ -36,10 +41,15 @@ RUN mkdir -p /config
COPY --from=go-build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=go-build /app/config/* /config/
COPY --from=go-build /app/super-graph .
COPY --from=go-build /app/scripts/start.sh .
COPY --from=go-build /usr/local/bin/sops .
RUN chmod +x /super-graph
RUN chmod +x /start.sh
USER nobody
EXPOSE 8080
ENV GO_ENV production
CMD ./super-graph serv
ENTRYPOINT ["./start.sh"]
CMD ["./super-graph", "serv"]

View File

@ -36,6 +36,15 @@ migrations_path: ./config/migrations
# encrypting the cursor data
secret_key: supercalifajalistics
# CORS: A list of origins a cross-domain request can be executed from.
# If the special * value is present in the list, all origins will be allowed.
# An origin may contain a wildcard (*) to replace 0 or more
# characters (i.e.: http://*.domain.com).
cors_allowed_origins: ["*"]
# Debug Cross Origin Resource Sharing requests
cors_debug: true
# Postgres related environment Variables
# SG_DATABASE_HOST
# SG_DATABASE_PORT

View File

@ -292,6 +292,12 @@ for (i = 0; i < 10; i++) {
}
```
If you want to import a lot of data using a CSV file is the best and fastest option. The `import_csv` command uses the `COPY FROM` Postgres method to load massive amounts of data into tables. The first line of the CSV file must be the header with column names.
```javascript
var post_count = import_csv("posts", "posts.csv")
```
You can generate the following fake data for your seeding purposes. Below is the list of fake data functions supported by the built-in fake data library. For example `fake.image_url()` will generate a fake image url or `fake.shuffle_strings(['hello', 'world', 'cool'])` will generate a randomly shuffled version of that array of strings or `fake.rand_string(['hello', 'world', 'cool'])` will return a random string from the array provided.
```
@ -1975,7 +1981,7 @@ To use Yugabyte in your local development flow just uncomment the following line
## Developing Super Graph
If you want to build and run Super Graph from code then the below commands will build the web ui and launch Super Graph in developer mode with a watcher to rebuild on code changes. And the demo rails app is also launched to make it essier to test changes.
If you want to build and run Super Graph from code then the below commands will build the web ui and launch Super Graph in developer mode with a watcher to rebuild on code changes. And the demo rails app is also launched to make it easier to test changes.
```bash

3
go.mod
View File

@ -2,7 +2,6 @@ module github.com/dosco/super-graph
require (
github.com/GeertJohan/go.rice v1.0.0
github.com/Masterminds/semver v1.5.0
github.com/NYTimes/gziphandler v1.1.1
github.com/adjust/gorails v0.0.0-20171013043634-2786ed0c03d3
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b
@ -12,7 +11,6 @@ require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/dlclark/regexp2 v1.2.0 // indirect
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/garyburd/redigo v1.6.0
github.com/go-sourcemap/sourcemap v2.1.2+incompatible // indirect
@ -23,6 +21,7 @@ require (
github.com/magiconair/properties v1.8.1 // indirect
github.com/pelletier/go-toml v1.4.0 // indirect
github.com/pkg/errors v0.8.1
github.com/rs/cors v1.7.0
github.com/rs/zerolog v1.15.0
github.com/spf13/afero v1.2.2 // indirect
github.com/spf13/cobra v0.0.5

6
go.sum
View File

@ -5,8 +5,6 @@ github.com/GeertJohan/go.incremental v1.0.0 h1:7AH+pY1XUgQE4Y1HcXYaMqAI0m9yrFqo/
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
github.com/GeertJohan/go.rice v1.0.0 h1:KkI6O9uMaQU3VEKaj01ulavtF7o1fWT7+pk/4voiMLQ=
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
@ -54,8 +52,6 @@ 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/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/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/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/garyburd/redigo v1.6.0 h1:0VruCpn7yAIIu7pWVClQC8wxCJEcG3nyzpMSHKi1PQc=
@ -184,6 +180,8 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0 h1:uPRuwkWF4J6fGsJ2R0Gn2jB1EQiav9k3S6CSdygQJXY=

View File

@ -28,12 +28,19 @@ func Filter(w *bytes.Buffer, b []byte, keys []string) error {
var k []byte
state := expectKey
instr := false
slash := 0
for i := 0; i < len(b); i++ {
if instr && b[i] == '\\' {
slash++
continue
}
if b[i] == '"' && (slash%2 == 0) {
instr = !instr
}
if state == expectObjClose || state == expectListClose {
if b[i-1] != '\\' && b[i] == '"' {
instr = !instr
}
if !instr {
switch b[i] {
case '{', '[':
@ -70,7 +77,7 @@ func Filter(w *bytes.Buffer, b []byte, keys []string) error {
state = expectKeyClose
s = i
case state == expectKeyClose && (b[i-1] != '\\' && b[i] == '"'):
case state == expectKeyClose && (b[i] == '"' && (slash%2 == 0)):
state = expectColon
k = b[(s + 1):i]
@ -80,7 +87,7 @@ func Filter(w *bytes.Buffer, b []byte, keys []string) error {
case state == expectValue && b[i] == '"':
state = expectString
case state == expectString && (b[i-1] != '\\' && b[i] == '"'):
case state == expectString && (b[i] == '"' && (slash%2 == 0)):
e = i
case state == expectValue && b[i] == '[':

View File

@ -52,12 +52,19 @@ func Get(b []byte, keys [][]byte) []Field {
n := 0
instr := false
slash := 0
for i := 0; i < len(b); i++ {
if instr && b[i] == '\\' {
slash++
continue
}
if b[i] == '"' && (slash%2 == 0) {
instr = !instr
}
if state == expectObjClose || state == expectListClose {
if b[i-1] != '\\' && b[i] == '"' {
instr = !instr
}
if !instr {
switch b[i] {
case '{', '[':
@ -73,7 +80,7 @@ func Get(b []byte, keys [][]byte) []Field {
state = expectKeyClose
s = i
case state == expectKeyClose && (b[i-1] != '\\' && b[i] == '"'):
case state == expectKeyClose && (b[i] == '"' && (slash%2 == 0)):
state = expectColon
k = b[(s + 1):i]
@ -84,7 +91,7 @@ func Get(b []byte, keys [][]byte) []Field {
state = expectString
s = i
case state == expectString && (b[i-1] != '\\' && b[i] == '"'):
case state == expectString && (b[i] == '"' && (slash%2 == 0)):
e = i
case state == expectValue && b[i] == '[':
@ -155,6 +162,8 @@ func Get(b []byte, keys [][]byte) []Field {
state = expectKey
e = 0
}
slash = 0
}
return res[:n]

View File

@ -2,7 +2,9 @@ package jsn
import (
"bytes"
"fmt"
"io/ioutil"
"strings"
"testing"
)
@ -163,7 +165,9 @@ var (
input6 = `
{"users" : [{"id" : 1, "email" : "vicram@gmail.com", "slug" : "vikram-rangnekar", "threads" : [], "threads_cursor" : null}, {"id" : 3, "email" : "marareilly@lang.name", "slug" : "raymundo-corwin", "threads" : [{"id" : 9, "title" : "Et alias et aut porro praesentium nam in voluptatem reiciendis quisquam perspiciatis inventore eos quia et et enim qui amet."}, {"id" : 25, "title" : "Ipsam quam nemo culpa tempore amet optio sit sed eligendi autem consequatur quaerat rem velit quibusdam quibusdam optio a voluptatem."}], "threads_cursor" : 25}], "users_cursor" : 3}`
input7, _ = ioutil.ReadFile("test.json")
input7, _ = ioutil.ReadFile("test7.json")
input8, _ = ioutil.ReadFile("test8.json")
)
func TestGet(t *testing.T) {
@ -268,6 +272,23 @@ func TestGet3(t *testing.T) {
}
}
func TestGet4(t *testing.T) {
exp := `"# \n\n@@@java\npackage main\n\nimport (\n \"net/http\"\n \"strings\"\n\n \"github.com/gin-gonic/gin\"\n)\n\nfunc main() {\n r := gin.Default()\n r.LoadHTMLGlob(\"templates/*\")\n\n r.GET(\"/\", handleIndex)\n r.GET(\"/to/:name\", handleIndex)\n r.Run()\n}\n\n// Hello is page data for the template\ntype Hello struct {\n Name string\n}\n\nfunc handleIndex(c *gin.Context) {\n name := c.Param(\"name\")\n if name != \"\" {\n name = strings.TrimPrefix(c.Param(\"name\"), \"/\")\n }\n c.HTML(http.StatusOK, \"hellofly.tmpl\", gin.H{\"Name\": name})\n}\n@@@\n\n\\"`
exp = strings.ReplaceAll(exp, "@", "`")
values := Get(input8, [][]byte{[]byte("body")})
if string(values[0].Key) != "body" {
t.Fatal("unexpected key")
}
if string(values[0].Value) != exp {
fmt.Println(string(values[0].Value))
t.Fatal("unexpected value")
}
}
func TestValue(t *testing.T) {
v1 := []byte("12345")
if !bytes.Equal(Value(v1), v1) {

View File

@ -11,12 +11,19 @@ func Keys(b []byte) [][]byte {
st := NewStack()
ae := 0
instr := false
slash := 0
for i := 0; i < len(b); i++ {
if instr && b[i] == '\\' {
slash++
continue
}
if b[i] == '"' && (slash%2 == 0) {
instr = !instr
}
if state == expectObjClose || state == expectListClose {
if b[i-1] != '\\' && b[i] == '"' {
instr = !instr
}
if !instr {
switch b[i] {
case '{', '[':
@ -52,7 +59,7 @@ func Keys(b []byte) [][]byte {
state = expectKeyClose
s = i
case state == expectKeyClose && (b[i-1] != '\\' && b[i] == '"'):
case state == expectKeyClose && (b[i] == '"' && (slash%2 == 0)):
state = expectColon
k = b[(s + 1):i]
@ -63,7 +70,7 @@ func Keys(b []byte) [][]byte {
state = expectString
s = i
case state == expectString && (b[i-1] != '\\' && b[i] == '"'):
case state == expectString && (b[i] == '"' && (slash%2 == 0)):
e = i
case state == expectValue && b[i] == '{':
@ -135,6 +142,7 @@ func Keys(b []byte) [][]byte {
e = 0
}
slash = 0
}
return res

View File

@ -12,6 +12,11 @@ func Replace(w *bytes.Buffer, b []byte, from, to []Field) error {
return errors.New("'from' and 'to' must be of the same length")
}
if len(from) == 0 || len(to) == 0 {
_, err := w.Write(b)
return err
}
h := xxhash.New()
tmap := make(map[uint64]int, len(from))
@ -33,17 +38,24 @@ func Replace(w *bytes.Buffer, b []byte, from, to []Field) error {
ws, we := -1, len(b)
instr := false
slash := 0
for i := 0; i < len(b); i++ {
if instr && b[i] == '\\' {
slash++
continue
}
// skip any left padding whitespace
if ws == -1 && (b[i] == '{' || b[i] == '[') {
ws = i
}
if b[i] == '"' && (slash%2 == 0) {
instr = !instr
}
if state == expectObjClose || state == expectListClose {
if b[i-1] != '\\' && b[i] == '"' {
instr = !instr
}
if !instr {
switch b[i] {
case '{', '[':
@ -59,7 +71,7 @@ func Replace(w *bytes.Buffer, b []byte, from, to []Field) error {
state = expectKeyClose
s = i
case state == expectKeyClose && (b[i-1] != '\\' && b[i] == '"'):
case state == expectKeyClose && (b[i] == '"' && (slash%2 == 0)):
state = expectColon
if _, err := h.Write(b[(s + 1):i]); err != nil {
return err
@ -73,7 +85,7 @@ func Replace(w *bytes.Buffer, b []byte, from, to []Field) error {
state = expectString
s = i
case state == expectString && (b[i-1] != '\\' && b[i] == '"'):
case state == expectString && (b[i] == '"' && (slash%2 == 0)):
e = i
case state == expectValue && b[i] == '[':
@ -167,6 +179,8 @@ func Replace(w *bytes.Buffer, b []byte, from, to []Field) error {
e = 0
d = 0
}
slash = 0
}
if ws == -1 || (ws == 0 && we == len(b)) {

View File

@ -12,12 +12,19 @@ func Strip(b []byte, path [][]byte) []byte {
pm := false
state := expectKey
instr := false
slash := 0
for i := 0; i < len(b); i++ {
if instr && b[i] == '\\' {
slash++
continue
}
if b[i] == '"' && (slash%2 == 0) {
instr = !instr
}
if state == expectObjClose || state == expectListClose {
if b[i-1] != '\\' && b[i] == '"' {
instr = !instr
}
if !instr {
switch b[i] {
case '{', '[':
@ -33,7 +40,7 @@ func Strip(b []byte, path [][]byte) []byte {
state = expectKeyClose
s = i
case state == expectKeyClose && (b[i-1] != '\\' && b[i] == '"'):
case state == expectKeyClose && (b[i] == '"' && (slash%2 == 0)):
state = expectColon
if pi == len(path) {
pi = 0
@ -50,7 +57,7 @@ func Strip(b []byte, path [][]byte) []byte {
state = expectString
s = i
case state == expectString && (b[i-1] != '\\' && b[i] == '"'):
case state == expectString && (b[i] == '"' && (slash%2 == 0)):
e = i
case state == expectValue && b[i] == '[':
@ -107,6 +114,8 @@ func Strip(b []byte, path [][]byte) []byte {
state = expectKey
e = 0
}
slash = 0
}
return ob

7
jsn/test8.json Normal file
View File

@ -0,0 +1,7 @@
{
"data": {
"slug": "javapackage-mainimport-nethttp-strings-githubcomgi-2786",
"published": true,
"body": "# \n\n```java\npackage main\n\nimport (\n \"net/http\"\n \"strings\"\n\n \"github.com/gin-gonic/gin\"\n)\n\nfunc main() {\n r := gin.Default()\n r.LoadHTMLGlob(\"templates/*\")\n\n r.GET(\"/\", handleIndex)\n r.GET(\"/to/:name\", handleIndex)\n r.Run()\n}\n\n// Hello is page data for the template\ntype Hello struct {\n Name string\n}\n\nfunc handleIndex(c *gin.Context) {\n name := c.Param(\"name\")\n if name != \"\" {\n name = strings.TrimPrefix(c.Param(\"name\"), \"/\")\n }\n c.HTML(http.StatusOK, \"hellofly.tmpl\", gin.H{\"Name\": name})\n}\n```\n\n\\"
}
}

View File

@ -35,33 +35,37 @@ func (c *compilerContext) renderBaseColumns(
c.renderComma(i)
realColsRendered = append(realColsRendered, n)
colWithTable(c.w, ti.Name, cn)
i++
continue
}
if isSearch && !isRealCol {
} else {
switch {
case cn == "search_rank":
case isSearch && cn == "search_rank":
if err := c.renderColumnSearchRank(sel, ti, col, i); err != nil {
return nil, false, err
}
i++
case strings.HasPrefix(cn, "search_headline_"):
case isSearch && strings.HasPrefix(cn, "search_headline_"):
if err := c.renderColumnSearchHeadline(sel, ti, col, i); err != nil {
return nil, false, err
}
i++
}
} else {
if err := c.renderColumnFunction(sel, ti, col, i); err != nil {
return nil, false, err
}
isAgg = true
i++
case cn == "__typename":
if err := c.renderColumnTypename(sel, ti, col, i); err != nil {
return nil, false, err
}
case strings.HasSuffix(cn, "_cursor"):
continue
default:
if err := c.renderColumnFunction(sel, ti, col, i); err != nil {
return nil, false, err
}
isAgg = true
}
}
i++
}
if isCursorPaged {
@ -148,6 +152,20 @@ func (c *compilerContext) renderColumnSearchHeadline(sel *qcode.Select, ti *DBTa
return nil
}
func (c *compilerContext) renderColumnTypename(sel *qcode.Select, ti *DBTableInfo, col qcode.Column, columnsRendered int) error {
if isColumnBlocked(sel, col.Name) {
return nil
}
c.renderComma(columnsRendered)
io.WriteString(c.w, `(`)
squoted(c.w, ti.Name)
io.WriteString(c.w, ` :: text)`)
alias(c.w, col.Name)
return nil
}
func (c *compilerContext) renderColumnFunction(sel *qcode.Select, ti *DBTableInfo, col qcode.Column, columnsRendered int) error {
pl := funcPrefixLen(col.Name)
// if pl == 0 {
@ -168,7 +186,7 @@ func (c *compilerContext) renderColumnFunction(sel *qcode.Select, ti *DBTableInf
return nil
}
fn := cn[0 : pl-1]
fn := col.Name[:pl-1]
c.renderComma(columnsRendered)

View File

@ -17,6 +17,10 @@ const (
closeBlock = 500
)
var (
ErrAllTablesSkipped = errors.New("all tables skipped. cannot render query")
)
type Variables map[string]json.RawMessage
type Config struct {
@ -89,7 +93,7 @@ func (co *Compiler) compileQuery(qc *qcode.QCode, w io.Writer, vars Variables) (
io.WriteString(c.w, `SELECT json_build_object(`)
for _, id := range qc.Roots {
root := &qc.Selects[id]
if root.SkipRender {
if root.SkipRender || len(root.Cols) == 0 {
continue
}
@ -107,7 +111,7 @@ func (co *Compiler) compileQuery(qc *qcode.QCode, w io.Writer, vars Variables) (
io.WriteString(c.w, `) as "__root" FROM `)
if i == 0 {
return 0, errors.New("all tables skipped. cannot render query")
return 0, ErrAllTablesSkipped
}
var ignored uint32
@ -122,6 +126,10 @@ func (co *Compiler) compileQuery(qc *qcode.QCode, w io.Writer, vars Variables) (
if id < closeBlock {
sel := &c.s[id]
if len(sel.Cols) == 0 {
continue
}
ti, err := c.schema.GetTable(sel.Name)
if err != nil {
return 0, err
@ -502,22 +510,25 @@ func (c *compilerContext) renderJoinByName(table, parent string, id int32) error
func (c *compilerContext) renderColumns(sel *qcode.Select, ti *DBTableInfo, skipped uint32) error {
i := 0
var cn string
for _, col := range sel.Cols {
n := funcPrefixLen(col.Name)
if n != 0 {
if n := funcPrefixLen(col.Name); n != 0 {
if !sel.Functions {
continue
}
if len(sel.Allowed) != 0 {
if _, ok := sel.Allowed[col.Name[n:]]; !ok {
continue
}
}
cn = col.Name[n:]
} else {
if len(sel.Allowed) != 0 {
if _, ok := sel.Allowed[col.Name]; !ok {
continue
}
cn = col.Name
if strings.HasSuffix(cn, "_cursor") {
continue
}
}
if len(sel.Allowed) != 0 {
if _, ok := sel.Allowed[cn]; !ok {
continue
}
}
@ -569,9 +580,6 @@ func (c *compilerContext) renderJoinColumns(sel *qcode.Select, ti *DBTableInfo,
continue
}
childSel := &c.s[id]
if childSel.SkipRender {
continue
}
if i != 0 {
io.WriteString(c.w, ", ")
@ -579,6 +587,11 @@ func (c *compilerContext) renderJoinColumns(sel *qcode.Select, ti *DBTableInfo,
squoted(c.w, childSel.FieldName)
if childSel.SkipRender {
io.WriteString(c.w, `, NULL`)
continue
}
io.WriteString(c.w, `, "__sel_`)
int2string(c.w, childSel.ID)
io.WriteString(c.w, `"."json"`)
@ -1127,9 +1140,15 @@ func (c *compilerContext) renderVal(ex *qcode.Exp, vars map[string]string, col *
switch ex.Type {
case qcode.ValVar:
if val, ok := vars[ex.Val]; ok {
val, ok := vars[ex.Val]
switch {
case ok && strings.HasPrefix(val, "sql:"):
io.WriteString(c.w, ` (`)
io.WriteString(c.w, val[4:])
io.WriteString(c.w, `)`)
case ok:
squoted(c.w, val)
} else {
default:
io.WriteString(c.w, ` '{{`)
io.WriteString(c.w, ex.Val)
io.WriteString(c.w, `}}'`)

View File

@ -327,7 +327,7 @@ func jsonColumnAsTable(t *testing.T) {
compileGQLToPSQL(t, gql, nil, "admin")
}
func skipUserIDForAnonRole(t *testing.T) {
func nullForAuthRequiredInAnon(t *testing.T) {
gql := `query {
products {
id
@ -387,7 +387,7 @@ func TestCompileQuery(t *testing.T) {
t.Run("multiRoot", multiRoot)
t.Run("jsonColumnAsTable", jsonColumnAsTable)
t.Run("withCursor", withCursor)
t.Run("skipUserIDForAnonRole", skipUserIDForAnonRole)
t.Run("nullForAuthRequiredInAnon", nullForAuthRequiredInAnon)
t.Run("blockedQuery", blockedQuery)
t.Run("blockedFunctions", blockedFunctions)
}

View File

@ -66,7 +66,14 @@ func NewDBSchema(info *DBInfo, aliases map[string][]string) (*DBSchema, error) {
}
for i, t := range info.Tables {
err := schema.updateRelationships(t, info.Columns[i])
err := schema.firstDegreeRels(t, info.Columns[i])
if err != nil {
return nil, err
}
}
for i, t := range info.Tables {
err := schema.secondDegreeRels(t, info.Columns[i])
if err != nil {
return nil, err
}
@ -131,8 +138,7 @@ func (s *DBSchema) addTable(
return nil
}
func (s *DBSchema) updateRelationships(t DBTable, cols []DBColumn) error {
jcols := make([]DBColumn, 0, len(cols))
func (s *DBSchema) firstDegreeRels(t DBTable, cols []DBColumn) error {
ct := t.Key
cti, ok := s.t[ct]
if !ok {
@ -230,6 +236,51 @@ func (s *DBSchema) updateRelationships(t DBTable, cols []DBColumn) error {
if err := s.SetRel(ft, ct, rel2); err != nil {
return err
}
}
return nil
}
func (s *DBSchema) secondDegreeRels(t DBTable, cols []DBColumn) error {
jcols := make([]DBColumn, 0, len(cols))
ct := t.Key
cti, ok := s.t[ct]
if !ok {
return fmt.Errorf("invalid foreign key table '%s'", ct)
}
for i := range cols {
c := cols[i]
if len(c.FKeyTable) == 0 {
continue
}
// Foreign key column name
ft := strings.ToLower(c.FKeyTable)
ti, ok := s.t[ft]
if !ok {
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 {
continue
}
if len(c.FKeyColID) == 0 {
continue
}
// Foreign key column id
fcid := c.FKeyColID[0]
if _, ok := ti.ColIDMap[fcid]; !ok {
return fmt.Errorf("invalid foreign key column id '%d' for table '%s'",
fcid, ti.Name)
}
jcols = append(jcols, c)
}
@ -322,6 +373,9 @@ func (s *DBSchema) GetTable(table string) (*DBTableInfo, error) {
}
func (s *DBSchema) SetRel(child, parent string, rel *DBRel) error {
sp := strings.ToLower(flect.Singularize(parent))
pp := strings.ToLower(flect.Pluralize(parent))
sc := strings.ToLower(flect.Singularize(child))
pc := strings.ToLower(flect.Pluralize(child))
@ -333,9 +387,6 @@ func (s *DBSchema) SetRel(child, parent string, rel *DBRel) error {
s.rm[pc] = make(map[string]*DBRel)
}
sp := strings.ToLower(flect.Singularize(parent))
pp := strings.ToLower(flect.Pluralize(parent))
if _, ok := s.rm[sc][sp]; !ok {
s.rm[sc][sp] = rel
}

View File

@ -19,6 +19,10 @@ func (rt RelType) String() string {
}
func (re *DBRel) String() string {
if re.Type == RelOneToManyThrough {
return fmt.Sprintf("'%s.%s' --(Through: %s)--> '%s.%s'",
re.Left.Table, re.Left.Col, re.Through, re.Right.Table, re.Right.Col)
}
return fmt.Sprintf("'%s.%s' --(%s)--> '%s.%s'",
re.Left.Table, re.Left.Col, re.Type, re.Right.Table, re.Right.Col)
}

View File

@ -92,7 +92,14 @@ func getTestSchema() *DBSchema {
}
for i, t := range tables {
err := schema.updateRelationships(t, columns[i])
err := schema.firstDegreeRels(t, columns[i])
if err != nil {
log.Fatal(err)
}
}
for i, t := range tables {
err := schema.secondDegreeRels(t, columns[i])
if err != nil {
log.Fatal(err)
}

View File

@ -69,13 +69,13 @@ SELECT json_build_object('products', "__sel_0"."json") as "__root" FROM (SELECT
=== RUN TestCompileQuery/manyToManyReverse
SELECT json_build_object('customers', "__sel_0"."json") as "__root" FROM (SELECT coalesce(json_agg("__sel_0"."json"), '[]') as "json" FROM (SELECT json_build_object('email', "customers_0"."email", 'full_name', "customers_0"."full_name", 'products', "__sel_1"."json") AS "json" FROM (SELECT "customers"."email", "customers"."full_name", "customers"."id" FROM "customers" LIMIT ('20') :: integer) AS "customers_0" LEFT OUTER JOIN LATERAL (SELECT coalesce(json_agg("__sel_1"."json"), '[]') as "json" FROM (SELECT json_build_object('name', "products_1"."name") AS "json" FROM (SELECT "products"."name" FROM "products" LEFT OUTER JOIN "purchases" ON (("purchases"."customer_id") = ("customers_0"."id")) WHERE ((("products"."id") = ("purchases"."product_id")) AND ((("products"."price") > '0' :: numeric(7,2)) AND (("products"."price") < '8' :: numeric(7,2)))) LIMIT ('20') :: integer) AS "products_1") AS "__sel_1") AS "__sel_1" ON ('true')) AS "__sel_0") AS "__sel_0"
=== RUN TestCompileQuery/aggFunction
SELECT json_build_object('products', "__sel_0"."json") as "__root" FROM (SELECT coalesce(json_agg("__sel_0"."json"), '[]') as "json" FROM (SELECT json_build_object('name', "products_0"."name", 'count_price', "products_0"."count_price") AS "json" FROM (SELECT "products"."name", price("products"."price") AS "count_price" FROM "products" WHERE (((("products"."price") > '0' :: numeric(7,2)) AND (("products"."price") < '8' :: numeric(7,2)))) GROUP BY "products"."name" LIMIT ('20') :: integer) AS "products_0") AS "__sel_0") AS "__sel_0"
SELECT json_build_object('products', "__sel_0"."json") as "__root" FROM (SELECT coalesce(json_agg("__sel_0"."json"), '[]') as "json" FROM (SELECT json_build_object('name', "products_0"."name", 'count_price', "products_0"."count_price") AS "json" FROM (SELECT "products"."name", count("products"."price") AS "count_price" FROM "products" WHERE (((("products"."price") > '0' :: numeric(7,2)) AND (("products"."price") < '8' :: numeric(7,2)))) GROUP BY "products"."name" LIMIT ('20') :: integer) AS "products_0") AS "__sel_0") AS "__sel_0"
=== RUN TestCompileQuery/aggFunctionBlockedByCol
SELECT json_build_object('products', "__sel_0"."json") as "__root" FROM (SELECT coalesce(json_agg("__sel_0"."json"), '[]') as "json" FROM (SELECT json_build_object('name', "products_0"."name") AS "json" FROM (SELECT "products"."name" FROM "products" GROUP BY "products"."name" LIMIT ('20') :: integer) AS "products_0") AS "__sel_0") AS "__sel_0"
=== RUN TestCompileQuery/aggFunctionDisabled
SELECT json_build_object('products', "__sel_0"."json") as "__root" FROM (SELECT coalesce(json_agg("__sel_0"."json"), '[]') as "json" FROM (SELECT json_build_object('name', "products_0"."name") AS "json" FROM (SELECT "products"."name" FROM "products" GROUP BY "products"."name" LIMIT ('20') :: integer) AS "products_0") AS "__sel_0") AS "__sel_0"
=== RUN TestCompileQuery/aggFunctionWithFilter
SELECT json_build_object('products', "__sel_0"."json") as "__root" FROM (SELECT coalesce(json_agg("__sel_0"."json"), '[]') as "json" FROM (SELECT json_build_object('id', "products_0"."id", 'max_price', "products_0"."max_price") AS "json" FROM (SELECT "products"."id", pri("products"."price") AS "max_price" FROM "products" WHERE ((((("products"."price") > '0' :: numeric(7,2)) AND (("products"."price") < '8' :: numeric(7,2))) AND (("products"."id") > '10' :: bigint))) GROUP BY "products"."id" LIMIT ('20') :: integer) AS "products_0") AS "__sel_0") AS "__sel_0"
SELECT json_build_object('products', "__sel_0"."json") as "__root" FROM (SELECT coalesce(json_agg("__sel_0"."json"), '[]') as "json" FROM (SELECT json_build_object('id', "products_0"."id", 'max_price', "products_0"."max_price") AS "json" FROM (SELECT "products"."id", max("products"."price") AS "max_price" FROM "products" WHERE ((((("products"."price") > '0' :: numeric(7,2)) AND (("products"."price") < '8' :: numeric(7,2))) AND (("products"."id") > '10' :: bigint))) GROUP BY "products"."id" LIMIT ('20') :: integer) AS "products_0") AS "__sel_0") AS "__sel_0"
=== RUN TestCompileQuery/syntheticTables
SELECT json_build_object('me', "__sel_0"."json") as "__root" FROM (SELECT json_build_object() AS "json" FROM (SELECT "users"."email" FROM "users" WHERE ((("users"."id") = '{{user_id}}' :: bigint)) LIMIT ('1') :: integer) AS "users_0") AS "__sel_0"
=== RUN TestCompileQuery/queryWithVariables
@ -88,13 +88,13 @@ SELECT json_build_object('customer', "__sel_0"."json", 'user', "__sel_1"."json",
SELECT json_build_object('products', "__sel_0"."json") as "__root" FROM (SELECT coalesce(json_agg("__sel_0"."json"), '[]') as "json" FROM (SELECT json_build_object('id', "products_0"."id", 'name', "products_0"."name", 'tag_count', "__sel_1"."json") AS "json" FROM (SELECT "products"."id", "products"."name" FROM "products" LIMIT ('20') :: integer) AS "products_0" LEFT OUTER JOIN LATERAL (SELECT json_build_object('count', "tag_count_1"."count", 'tags', "__sel_2"."json") AS "json" 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("__sel_2"."json"), '[]') as "json" FROM (SELECT json_build_object('name', "tags_2"."name") AS "json" FROM (SELECT "tags"."name" FROM "tags" WHERE ((("tags"."id") = ("tag_count_1"."tag_id"))) LIMIT ('20') :: integer) AS "tags_2") AS "__sel_2") AS "__sel_2" ON ('true')) AS "__sel_1" ON ('true')) AS "__sel_0") AS "__sel_0"
=== RUN TestCompileQuery/withCursor
SELECT json_build_object('products', "__sel_0"."json", 'products_cursor', "__sel_0"."cursor") as "__root" FROM (SELECT coalesce(json_agg("__sel_0"."json"), '[]') as "json", CONCAT_WS(',', max("__cur_0"), max("__cur_1")) as "cursor" FROM (SELECT json_build_object('name', "products_0"."name") AS "json", LAST_VALUE("products_0"."price") OVER() AS "__cur_0", LAST_VALUE("products_0"."id") OVER() AS "__cur_1" FROM (WITH "__cur" AS (SELECT a[1] as "price", a[2] as "id" FROM string_to_array('{{cursor}}', ',') as a) SELECT "products"."name", "products"."id", "products"."price" FROM "products", "__cur" WHERE (((("products"."price") < "__cur"."price" :: numeric(7,2)) OR ((("products"."price") = "__cur"."price" :: numeric(7,2)) AND (("products"."id") > "__cur"."id" :: bigint)))) ORDER BY "products"."price" DESC, "products"."id" ASC LIMIT ('20') :: integer) AS "products_0") AS "__sel_0") AS "__sel_0"
=== RUN TestCompileQuery/skipUserIDForAnonRole
SELECT json_build_object('products', "__sel_0"."json") as "__root" FROM (SELECT coalesce(json_agg("__sel_0"."json"), '[]') as "json" FROM (SELECT json_build_object('id', "products_0"."id", 'name', "products_0"."name") AS "json" FROM (SELECT "products"."id", "products"."name", "products"."user_id" FROM "products" LIMIT ('20') :: integer) AS "products_0") AS "__sel_0") AS "__sel_0"
=== RUN TestCompileQuery/nullForAuthRequiredInAnon
SELECT json_build_object('products', "__sel_0"."json") as "__root" FROM (SELECT coalesce(json_agg("__sel_0"."json"), '[]') as "json" FROM (SELECT json_build_object('id', "products_0"."id", 'name', "products_0"."name", 'user', NULL) AS "json" FROM (SELECT "products"."id", "products"."name", "products"."user_id" FROM "products" LIMIT ('20') :: integer) AS "products_0") AS "__sel_0") AS "__sel_0"
=== RUN TestCompileQuery/blockedQuery
SELECT json_build_object('user', "__sel_0"."json") as "__root" FROM (SELECT json_build_object('id', "users_0"."id", 'full_name', "users_0"."full_name", 'email', "users_0"."email") AS "json" FROM (SELECT "users"."id", "users"."full_name", "users"."email" FROM "users" WHERE (false) LIMIT ('1') :: integer) AS "users_0") AS "__sel_0"
=== RUN TestCompileQuery/blockedFunctions
SELECT json_build_object('users', "__sel_0"."json") as "__root" FROM (SELECT coalesce(json_agg("__sel_0"."json"), '[]') as "json" FROM (SELECT json_build_object('email', "users_0"."email") AS "json" FROM (SELECT , "users"."email" FROM "users" WHERE (false) GROUP BY "users"."email" LIMIT ('20') :: integer) AS "users_0") AS "__sel_0") AS "__sel_0"
--- PASS: TestCompileQuery (0.02s)
--- PASS: TestCompileQuery (0.03s)
--- PASS: TestCompileQuery/withComplexArgs (0.00s)
--- PASS: TestCompileQuery/withWhereAndList (0.00s)
--- PASS: TestCompileQuery/withWhereIsNull (0.00s)
@ -116,7 +116,7 @@ SELECT json_build_object('users', "__sel_0"."json") as "__root" FROM (SELECT coa
--- PASS: TestCompileQuery/multiRoot (0.00s)
--- PASS: TestCompileQuery/jsonColumnAsTable (0.00s)
--- PASS: TestCompileQuery/withCursor (0.00s)
--- PASS: TestCompileQuery/skipUserIDForAnonRole (0.00s)
--- PASS: TestCompileQuery/nullForAuthRequiredInAnon (0.00s)
--- PASS: TestCompileQuery/blockedQuery (0.00s)
--- PASS: TestCompileQuery/blockedFunctions (0.00s)
=== RUN TestCompileUpdate
@ -125,8 +125,8 @@ WITH "_sg_input" AS (SELECT '{{update}}' :: json AS j), "products" AS (UPDATE "p
=== RUN TestCompileUpdate/simpleUpdateWithPresets
WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "products" AS (UPDATE "products" SET ("name", "price", "updated_at") = (SELECT "t"."name", "t"."price", 'now' :: timestamp without time zone FROM "_sg_input" i, json_populate_record(NULL::products, i.j) t) WHERE (("products"."user_id") = '{{user_id}}' :: bigint) RETURNING "products".*) SELECT json_build_object('product', "__sel_0"."json") as "__root" FROM (SELECT json_build_object('id', "products_0"."id") AS "json" FROM (SELECT "products"."id" FROM "products" LIMIT ('1') :: integer) AS "products_0") AS "__sel_0"
=== RUN TestCompileUpdate/nestedUpdateManyToMany
WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "purchases" AS (UPDATE "purchases" SET ("sale_type", "quantity", "due_date") = (SELECT "t"."sale_type", "t"."quantity", "t"."due_date" FROM "_sg_input" i, json_populate_record(NULL::purchases, i.j) t) WHERE (("purchases"."id") = '{{id}}' :: bigint) RETURNING "purchases".*), "customers" AS (UPDATE "customers" SET ("full_name", "email") = (SELECT "t"."full_name", "t"."email" FROM "_sg_input" i, json_populate_record(NULL::customers, i.j->'customer') t) FROM "purchases" WHERE (("customers"."id") = ("purchases"."customer_id")) RETURNING "customers".*), "products" AS (UPDATE "products" SET ("name", "price") = (SELECT "t"."name", "t"."price" FROM "_sg_input" i, json_populate_record(NULL::products, i.j->'product') t) FROM "purchases" WHERE (("products"."id") = ("purchases"."product_id")) RETURNING "products".*) SELECT json_build_object('purchase', "__sel_0"."json") as "__root" FROM (SELECT json_build_object('sale_type', "purchases_0"."sale_type", 'quantity', "purchases_0"."quantity", 'due_date', "purchases_0"."due_date", 'product', "__sel_1"."json", 'customer', "__sel_2"."json") AS "json" FROM (SELECT "purchases"."sale_type", "purchases"."quantity", "purchases"."due_date", "purchases"."product_id", "purchases"."customer_id" FROM "purchases" LIMIT ('1') :: integer) AS "purchases_0" LEFT OUTER JOIN LATERAL (SELECT json_build_object('id', "customers_2"."id", 'full_name', "customers_2"."full_name", 'email', "customers_2"."email") AS "json" FROM (SELECT "customers"."id", "customers"."full_name", "customers"."email" FROM "customers" WHERE ((("customers"."id") = ("purchases_0"."customer_id"))) LIMIT ('1') :: integer) AS "customers_2") AS "__sel_2" ON ('true') LEFT OUTER JOIN LATERAL (SELECT json_build_object('id', "products_1"."id", 'name', "products_1"."name", 'price', "products_1"."price") AS "json" FROM (SELECT "products"."id", "products"."name", "products"."price" FROM "products" WHERE ((("products"."id") = ("purchases_0"."product_id"))) LIMIT ('1') :: integer) AS "products_1") AS "__sel_1" ON ('true')) AS "__sel_0"
WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "purchases" AS (UPDATE "purchases" SET ("sale_type", "quantity", "due_date") = (SELECT "t"."sale_type", "t"."quantity", "t"."due_date" FROM "_sg_input" i, json_populate_record(NULL::purchases, i.j) t) WHERE (("purchases"."id") = '{{id}}' :: bigint) RETURNING "purchases".*), "products" AS (UPDATE "products" SET ("name", "price") = (SELECT "t"."name", "t"."price" FROM "_sg_input" i, json_populate_record(NULL::products, i.j->'product') t) FROM "purchases" WHERE (("products"."id") = ("purchases"."product_id")) RETURNING "products".*), "customers" AS (UPDATE "customers" SET ("full_name", "email") = (SELECT "t"."full_name", "t"."email" FROM "_sg_input" i, json_populate_record(NULL::customers, i.j->'customer') t) FROM "purchases" WHERE (("customers"."id") = ("purchases"."customer_id")) RETURNING "customers".*) SELECT json_build_object('purchase', "__sel_0"."json") as "__root" FROM (SELECT json_build_object('sale_type', "purchases_0"."sale_type", 'quantity', "purchases_0"."quantity", 'due_date', "purchases_0"."due_date", 'product', "__sel_1"."json", 'customer', "__sel_2"."json") AS "json" FROM (SELECT "purchases"."sale_type", "purchases"."quantity", "purchases"."due_date", "purchases"."product_id", "purchases"."customer_id" FROM "purchases" LIMIT ('1') :: integer) AS "purchases_0" LEFT OUTER JOIN LATERAL (SELECT json_build_object('id', "customers_2"."id", 'full_name', "customers_2"."full_name", 'email', "customers_2"."email") AS "json" FROM (SELECT "customers"."id", "customers"."full_name", "customers"."email" FROM "customers" WHERE ((("customers"."id") = ("purchases_0"."customer_id"))) LIMIT ('1') :: integer) AS "customers_2") AS "__sel_2" ON ('true') LEFT OUTER JOIN LATERAL (SELECT json_build_object('id', "products_1"."id", 'name', "products_1"."name", 'price', "products_1"."price") AS "json" FROM (SELECT "products"."id", "products"."name", "products"."price" FROM "products" WHERE ((("products"."id") = ("purchases_0"."product_id"))) LIMIT ('1') :: integer) AS "products_1") AS "__sel_1" ON ('true')) AS "__sel_0"
WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "purchases" AS (UPDATE "purchases" SET ("sale_type", "quantity", "due_date") = (SELECT "t"."sale_type", "t"."quantity", "t"."due_date" FROM "_sg_input" i, json_populate_record(NULL::purchases, i.j) t) WHERE (("purchases"."id") = '{{id}}' :: bigint) RETURNING "purchases".*), "customers" AS (UPDATE "customers" SET ("full_name", "email") = (SELECT "t"."full_name", "t"."email" FROM "_sg_input" i, json_populate_record(NULL::customers, i.j->'customer') t) FROM "purchases" WHERE (("customers"."id") = ("purchases"."customer_id")) RETURNING "customers".*), "products" AS (UPDATE "products" SET ("name", "price") = (SELECT "t"."name", "t"."price" FROM "_sg_input" i, json_populate_record(NULL::products, i.j->'product') t) FROM "purchases" WHERE (("products"."id") = ("purchases"."product_id")) RETURNING "products".*) SELECT json_build_object('purchase', "__sel_0"."json") as "__root" FROM (SELECT json_build_object('sale_type', "purchases_0"."sale_type", 'quantity', "purchases_0"."quantity", 'due_date', "purchases_0"."due_date", 'product', "__sel_1"."json", 'customer', "__sel_2"."json") AS "json" FROM (SELECT "purchases"."sale_type", "purchases"."quantity", "purchases"."due_date", "purchases"."product_id", "purchases"."customer_id" FROM "purchases" LIMIT ('1') :: integer) AS "purchases_0" LEFT OUTER JOIN LATERAL (SELECT json_build_object('id', "customers_2"."id", 'full_name', "customers_2"."full_name", 'email', "customers_2"."email") AS "json" FROM (SELECT "customers"."id", "customers"."full_name", "customers"."email" FROM "customers" WHERE ((("customers"."id") = ("purchases_0"."customer_id"))) LIMIT ('1') :: integer) AS "customers_2") AS "__sel_2" ON ('true') LEFT OUTER JOIN LATERAL (SELECT json_build_object('id', "products_1"."id", 'name', "products_1"."name", 'price', "products_1"."price") AS "json" FROM (SELECT "products"."id", "products"."name", "products"."price" FROM "products" WHERE ((("products"."id") = ("purchases_0"."product_id"))) LIMIT ('1') :: integer) AS "products_1") AS "__sel_1" ON ('true')) AS "__sel_0"
=== RUN TestCompileUpdate/nestedUpdateOneToMany
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") = '8' :: bigint) 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_build_object('user', "__sel_0"."json") as "__root" FROM (SELECT json_build_object('id', "users_0"."id", 'full_name', "users_0"."full_name", 'email', "users_0"."email", 'product', "__sel_1"."json") AS "json" FROM (SELECT "users"."id", "users"."full_name", "users"."email" FROM "users" LIMIT ('1') :: integer) AS "users_0" LEFT OUTER JOIN LATERAL (SELECT json_build_object('id', "products_1"."id", 'name', "products_1"."name", 'price', "products_1"."price") AS "json" FROM (SELECT "products"."id", "products"."name", "products"."price" FROM "products" WHERE ((("products"."user_id") = ("users_0"."id"))) LIMIT ('1') :: integer) AS "products_1") AS "__sel_1" ON ('true')) AS "__sel_0"
=== RUN TestCompileUpdate/nestedUpdateOneToOne
@ -148,4 +148,4 @@ WITH "_sg_input" AS (SELECT '{{data}}' :: json AS j), "_x_users" AS (SELECT * FR
--- PASS: TestCompileUpdate/nestedUpdateOneToOneWithConnect (0.00s)
--- PASS: TestCompileUpdate/nestedUpdateOneToOneWithDisconnect (0.00s)
PASS
ok github.com/dosco/super-graph/psql 0.716s
ok github.com/dosco/super-graph/psql (cached)

View File

@ -222,6 +222,10 @@ func (c *compilerContext) renderDelete(qc *qcode.QCode, w io.Writer,
quoted(c.w, ti.Name)
io.WriteString(c.w, ` WHERE `)
if root.Where == nil {
return 0, errors.New("'where' clause missing in delete mutation")
}
if err := c.renderWhere(root, ti); err != nil {
return 0, err
}

View File

@ -17,7 +17,7 @@ type parserType int32
const (
maxFields = 100
maxArgs = 10
maxArgs = 25
)
const (
@ -242,7 +242,8 @@ func (p *Parser) parseOp() (*Operation, error) {
if p.peek(itemArgsOpen) {
p.ignore()
op.Args, err = p.parseArgs(op.Args)
op.Args, err = p.parseOpParams(op.Args)
if err != nil {
return nil, err
}
@ -338,6 +339,13 @@ func (p *Parser) parseFields(fields []Field) ([]Field, error) {
if p.peek(itemObjOpen) {
p.ignore()
st.Push(f.ID)
} else if p.peek(itemObjClose) {
if st.Len() == 0 {
break
} else {
continue
}
}
}
@ -371,6 +379,22 @@ func (p *Parser) parseField(f *Field) error {
return nil
}
func (p *Parser) parseOpParams(args []Arg) ([]Arg, error) {
for {
if len(args) >= maxArgs {
return nil, fmt.Errorf("too many args (max %d)", maxArgs)
}
if p.peek(itemArgsClose) {
p.ignore()
break
}
p.next()
}
return args, nil
}
func (p *Parser) parseArgs(args []Arg) ([]Arg, error) {
var err error
@ -383,6 +407,7 @@ func (p *Parser) parseArgs(args []Arg) ([]Arg, error) {
p.ignore()
break
}
if !p.peek(itemName) {
return nil, errors.New("expecting an argument name")
}

View File

@ -4,8 +4,9 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"github.com/Masterminds/semver"
"github.com/adjust/gorails/marshal"
)
@ -37,17 +38,20 @@ func NewAuth(version, secret string) (*Auth, error) {
AuthSalt: authSalt,
}
ver, err := semver.NewVersion(version)
if err != nil {
return nil, fmt.Errorf("rails auth: %s", err)
var v1, v2 int
var err error
sv := strings.Split(version, ".")
if len(sv) >= 2 {
if v1, err = strconv.Atoi(sv[0]); err != nil {
return nil, err
}
if v2, err = strconv.Atoi(sv[1]); err != nil {
return nil, err
}
}
gt52, err := semver.NewConstraint(">= 5.2")
if err != nil {
return nil, fmt.Errorf("rails auth: %s", err)
}
if gt52.Check(ver) {
if v1 >= 5 && v2 >= 2 {
ra.Cipher = railsCipher52
} else {
ra.Cipher = railsCipher

13
scripts/start.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/sh
if [ $1 = "secrets" ]
then
./sops --config ./config "${@:2}"
exit 0
fi
if test -f "./config/$SECRETS_FILE"
then
./sops --config ./config exec-env "./config/$SECRETS_FILE" "$*"
else
$@
fi

View File

@ -57,8 +57,6 @@ func argMap(ctx context.Context, vars []byte) func(w io.Writer, tag string) (int
return w.Write(v1)
}
fmt.Println(">>>", tag, string(v))
return w.Write(escQuote(fields[0].Value))
}
}

View File

@ -3,6 +3,7 @@ package serv
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"fmt"
"io"
@ -10,9 +11,12 @@ import (
"math/rand"
"os"
"path"
"strconv"
"strings"
"github.com/brianvoe/gofakeit"
"github.com/dop251/goja"
"github.com/jackc/pgx/v4"
"github.com/spf13/cobra"
"github.com/valyala/fasttemplate"
)
@ -42,6 +46,7 @@ func cmdDBSeed(cmd *cobra.Command, args []string) {
vm := goja.New()
vm.Set("graphql", graphQLFunc)
vm.Set("import_csv", importCSV)
console := vm.NewObject()
console.Set("log", logFunc) //nolint: errcheck
@ -129,6 +134,106 @@ func graphQLFunc(query string, data interface{}, opt map[string]string) map[stri
return val
}
type csvSource struct {
rows [][]string
i int
}
func NewCSVSource(filename string) (*csvSource, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
r := csv.NewReader(f)
rows, err := r.ReadAll()
if err != nil {
return nil, err
}
return &csvSource{rows: rows}, nil
}
func (c *csvSource) Next() bool {
return c.i < len(c.rows)
}
func (c *csvSource) Values() ([]interface{}, error) {
var vals []interface{}
var err error
for _, v := range c.rows[c.i] {
switch {
case len(v) == 0:
vals = append(vals, "")
case isDigit(v):
var n int
if n, err = strconv.Atoi(v); err == nil {
vals = append(vals, n)
}
case strings.EqualFold(v, "true") || strings.EqualFold(v, "false"):
var b bool
if b, err = strconv.ParseBool(v); err == nil {
vals = append(vals, b)
}
default:
vals = append(vals, v)
}
if err != nil {
return nil, fmt.Errorf("%w (line no %d)", err, c.i)
}
}
c.i++
return vals, nil
}
func isDigit(v string) bool {
for i := range v {
if v[i] < '0' || v[i] > '9' {
return false
}
}
return true
}
func (c *csvSource) Err() error {
return nil
}
func importCSV(table, filename string) int64 {
if filename[0] != '/' {
filename = path.Join(confPath, filename)
}
s, err := NewCSVSource(filename)
if err != nil {
errlog.Fatal().Err(err).Send()
}
var cols []string
colval, _ := s.Values()
for _, c := range colval {
cols = append(cols, c.(string))
}
n, err := db.CopyFrom(
context.Background(),
pgx.Identifier{table},
cols,
s)
if err != nil {
err = fmt.Errorf("%w (line no %d)", err, s.i)
errlog.Fatal().Err(err).Send()
}
return n
}
//nolint: errcheck
func logFunc(args ...interface{}) {
for _, arg := range args {

View File

@ -26,11 +26,13 @@ type config struct {
EnableTracing bool `mapstructure:"enable_tracing"`
UseAllowList bool `mapstructure:"use_allow_list"`
Production bool
WatchAndReload bool `mapstructure:"reload_on_config_change"`
AuthFailBlock bool `mapstructure:"auth_fail_block"`
SeedFile string `mapstructure:"seed_file"`
MigrationsPath string `mapstructure:"migrations_path"`
SecretKey string `mapstructure:"secret_key"`
WatchAndReload bool `mapstructure:"reload_on_config_change"`
AuthFailBlock bool `mapstructure:"auth_fail_block"`
SeedFile string `mapstructure:"seed_file"`
MigrationsPath string `mapstructure:"migrations_path"`
SecretKey string `mapstructure:"secret_key"`
AllowedOrigins []string `mapstructure:"cors_allowed_origins"`
DebugCORS bool `mapstructure:"cors_debug"`
Inflections map[string]string
@ -205,8 +207,8 @@ func newConfig(name string) *viper.Viper {
vi.SetDefault("env", "development")
vi.BindEnv("env", "GO_ENV") //nolint: errcheck
vi.BindEnv("HOST", "HOST") //nolint: errcheck
vi.BindEnv("PORT", "PORT") //nolint: errcheck
vi.BindEnv("host", "HOST") //nolint: errcheck
vi.BindEnv("port", "PORT") //nolint: errcheck
vi.SetDefault("auth.rails.max_idle", 80)
vi.SetDefault("auth.rails.max_active", 12000)

View File

@ -152,6 +152,10 @@ func (c *coreContext) resolvePreparedSQL() ([]byte, *stmt, error) {
}
}
if root, err = encryptCursor(ps.st.qc, root); err != nil {
return nil, nil, err
}
return root, &ps.st, nil
}

View File

@ -59,12 +59,6 @@ func buildRoleStmt(gql, vars []byte, role string) ([]stmt, error) {
return nil, err
}
// For the 'anon' role in production only compile
// queries for tables defined in the config file.
if conf.Production && ro.Name == "anon" && !hasTablesWithConfig(qc, ro) {
return nil, errors.New("query contains tables with no 'anon' role config")
}
stmts := []stmt{stmt{role: ro, qc: qc}}
w := &bytes.Buffer{}
@ -90,7 +84,7 @@ func buildMultiStmt(gql, vars []byte) ([]stmt, error) {
}
if len(conf.RolesQuery) == 0 {
return buildRoleStmt(gql, vars, "user")
return nil, errors.New("roles_query not defined")
}
stmts := make([]stmt, 0, len(conf.Roles))
@ -99,6 +93,7 @@ func buildMultiStmt(gql, vars []byte) ([]stmt, error) {
for i := 0; i < len(conf.Roles); i++ {
role := &conf.Roles[i]
// skip anon as it's not included in the combined multi-statement
if role.Name == "anon" {
continue
}

View File

@ -8,6 +8,8 @@ import (
"net/http"
"strings"
"time"
"github.com/rs/cors"
)
const (
@ -61,6 +63,20 @@ type resolver struct {
Duration time.Duration `json:"duration"`
}
func apiV1Handler() http.Handler {
h := withAuth(http.HandlerFunc(apiV1), conf.Auth)
if len(conf.AllowedOrigins) != 0 {
c := cors.New(cors.Options{
AllowedOrigins: conf.AllowedOrigins,
AllowCredentials: true,
Debug: conf.DebugCORS,
})
h = c.Handler(h)
}
return h
}
func apiV1(w http.ResponseWriter, r *http.Request) {
ctx := &coreContext{Context: r.Context()}
@ -101,7 +117,7 @@ func apiV1(w http.ResponseWriter, r *http.Request) {
}
if err != nil {
errlog.Error().Err(err).Msg("failed to handle request")
errlog.Error().Err(err).Msg(ctx.req.Query)
errorResp(w, err)
return
}

View File

@ -7,6 +7,7 @@ import (
"io"
"github.com/dosco/super-graph/allow"
"github.com/dosco/super-graph/psql"
"github.com/dosco/super-graph/qcode"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v4"
@ -82,12 +83,6 @@ func prepareStmt(item allow.Item) error {
qt := qcode.GetQType(gql)
q := []byte(gql)
if len(vars) == 0 {
logger.Debug().Msgf("Prepared statement:\n%s\n", gql)
} else {
logger.Debug().Msgf("Prepared statement:\n%s\n%s\n", vars, gql)
}
tx, err := db.Begin(context.Background())
if err != nil {
return err
@ -109,7 +104,7 @@ func prepareStmt(item allow.Item) error {
return err
}
logger.Debug().Msg("Prepared statement role: user")
logger.Debug().Msgf("Prepared statement 'query %s' (user)", item.Name)
err = prepare(tx, stmts1, stmtHash(item.Name, "user"))
if err != nil {
@ -117,9 +112,12 @@ func prepareStmt(item allow.Item) error {
}
if conf.isAnonRoleDefined() {
logger.Debug().Msg("Prepared statement for role: anon")
logger.Debug().Msgf("Prepared statement 'query %s' (anon)", item.Name)
stmts2, err := buildRoleStmt(q, vars, "anon")
if err == psql.ErrAllTablesSkipped {
return nil
}
if err != nil {
return err
}
@ -132,11 +130,17 @@ func prepareStmt(item allow.Item) error {
case qcode.QTMutation:
for _, role := range conf.Roles {
logger.Debug().Msgf("Prepared statement for role: %s", role.Name)
logger.Debug().Msgf("Prepared statement 'mutation %s' (%s)", item.Name, role.Name)
stmts, err := buildRoleStmt(q, vars, role.Name)
if err != nil {
return err
if len(item.Vars) == 0 {
logger.Warn().Err(err).Msg(item.Query)
} else {
logger.Warn().Err(err).Msgf("%s %s", item.Vars, item.Query)
}
continue
}
err = prepare(tx, stmts, stmtHash(item.Name, role.Name))

View File

@ -108,7 +108,11 @@ func Do(log func(string, ...interface{}), additional ...dir) error {
// Ensure that we use the correct events, as they are not uniform across
// platforms. See https://github.com/fsnotify/fsnotify/issues/74
if conf != nil && !conf.Production && strings.HasSuffix(event.Name, "/allow.list") {
if conf != nil && strings.HasSuffix(event.Name, "/allow.list") {
continue
}
if conf.Production {
continue
}

View File

@ -154,7 +154,7 @@ func routeHandler() (http.Handler, error) {
routes := map[string]http.Handler{
"/health": http.HandlerFunc(health),
"/api/v1/graphql": withAuth(http.HandlerFunc(apiV1), conf.Auth),
"/api/v1/graphql": apiV1Handler(),
}
if err := setActionRoutes(routes); err != nil {

View File

@ -36,6 +36,15 @@ migrations_path: ./config/migrations
# encrypting the cursor data
secret_key: supercalifajalistics
# CORS: A list of origins a cross-domain request can be executed from.
# If the special * value is present in the list, all origins will be allowed.
# An origin may contain a wildcard (*) to replace 0 or more
# characters (i.e.: http://*.domain.com).
cors_allowed_origins: ["*"]
# Debug Cross Origin Resource Sharing requests
cors_debug: false
# Postgres related environment Variables
# SG_DATABASE_HOST
# SG_DATABASE_PORT
@ -125,7 +134,9 @@ database:
# Define additional variables here to be used with filters
variables:
admin_account_id: "5"
#admin_account_id: "5"
admin_account_id: "sql:select id from users where admin = true limit 1"
# Field and table names that you wish to block
blocklist:
@ -168,26 +179,14 @@ tables:
table: users
roles_query: "SELECT * FROM users WHERE id = $user_id"
#roles_query: "SELECT * FROM users WHERE id = $user_id"
roles:
- name: anon
tables:
- name: products
limit: 10
- name: users
query:
columns: ["id", "name", "description" ]
aggregation: false
insert:
block: false
update:
block: false
delete:
block: false
limit: 10
- name: user
tables:
@ -215,8 +214,8 @@ roles:
delete:
block: true
- name: admin
match: id = 1000
tables:
- name: users
filters: []
# - name: admin
# match: id = 1000
# tables:
# - name: users
# filters: []

View File

@ -24,7 +24,11 @@ auth_fail_block: true
# Latency tracing for database queries and remote joins
# the resulting latency information is returned with the
# response
enable_tracing: true
enable_tracing: false
# Watch the config folder and reload Super Graph
# with the new configs when a change is detected
reload_on_config_change: false
# File that points to the database seeding script
# seed_file: seed.js
@ -36,6 +40,15 @@ enable_tracing: true
# encrypting the cursor data
# secret_key: supercalifajalistics
# CORS: A list of origins a cross-domain request can be executed from.
# If the special * value is present in the list, all origins will be allowed.
# An origin may contain a wildcard (*) to replace 0 or more
# characters (i.e.: http://*.domain.com).
# cors_allowed_origins: ["*"]
# Debug Cross Origin Resource Sharing requests
# cors_debug: false
# Postgres related environment Variables
# SG_DATABASE_HOST
# SG_DATABASE_PORT
@ -52,7 +65,7 @@ database:
type: postgres
host: db
port: 5432
dbname: {% app_name_slug %}_development
dbname: {% app_name_slug %}_production
user: postgres
password: postgres
#pool_size: 10