├── _config.yml ├── resources └── schema.gql ├── .gitignore ├── examples ├── testSchema.js ├── schema.gql ├── core.js ├── starWarsData.js └── albums.js ├── repl.clj ├── test └── speako │ ├── runner.cljs │ └── core.cljs ├── Makefile ├── package.json ├── project.clj ├── src └── speako │ ├── common.cljs │ ├── core.cljs │ ├── schema.cljs │ ├── backend.cljs │ └── consumer.cljs ├── README.md └── docs └── README.md /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-tactile -------------------------------------------------------------------------------- /resources/schema.gql: -------------------------------------------------------------------------------- 1 | 2 | type Color { 3 | id: ID! 4 | name: String 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | **/*.iml 3 | out/ 4 | target/ 5 | node_modules/ 6 | .lein-failures 7 | .DS_Store 8 | .nrepl-port 9 | 10 | -------------------------------------------------------------------------------- /examples/testSchema.js: -------------------------------------------------------------------------------- 1 | 2 | var speako = require('../'); 3 | var gql = require('graphql'); 4 | 5 | var schema = speako.getSchema(new Object, process.argv[2]); 6 | console.log("Loaded ", schema); 7 | 8 | -------------------------------------------------------------------------------- /repl.clj: -------------------------------------------------------------------------------- 1 | (require '[cljs.repl :as repl] 2 | '[cljs.repl.node :as node]) 3 | 4 | (repl/repl* (node/repl-env) 5 | {:output-dir "out" 6 | :optimizations :none 7 | :cache-analysis true 8 | :source-map true}) 9 | 10 | -------------------------------------------------------------------------------- /test/speako/runner.cljs: -------------------------------------------------------------------------------- 1 | ;; Copyright (c) 2015 Jonathan L. Leonard 2 | 3 | (ns speako.runner 4 | (:require [cljs.test :as test] 5 | [doo.runner :refer-macros [doo-all-tests doo-tests]] 6 | [speako.test.core])) 7 | 8 | (doo-tests 'speako.test.core) 9 | 10 | -------------------------------------------------------------------------------- /examples/schema.gql: -------------------------------------------------------------------------------- 1 | enum Episode { NEWHOPE, EMPIRE, JEDI } 2 | 3 | type Human { 4 | id: ID! 5 | name: String 6 | friends: [Character] 7 | appearsIn: [Episode] 8 | homePlanet: String 9 | } 10 | 11 | type Droid { 12 | id: ID! 13 | name: String 14 | friends: [Character] 15 | appearsIn: [Episode] 16 | primaryFunction: String 17 | } 18 | 19 | union Character = Human | Droid -------------------------------------------------------------------------------- /examples/core.js: -------------------------------------------------------------------------------- 1 | 2 | var starWarsData = require('./starWarsData.js'); 3 | var speako = require('../'); 4 | var gql = require('graphql'); 5 | 6 | var getters = {"Human": starWarsData.getHuman, "Droid": starWarsData.getDroid}; 7 | var dataResolver = {"query": function (typename, predicate) { 8 | var res = getters[typename].call(null, parseInt(predicate.split("=")[1])); 9 | return res; 10 | }}; 11 | var starWarsSchema = speako.getSchema(dataResolver, __dirname + "/schema.gql"); 12 | 13 | gql.graphql(starWarsSchema, "{ Human(id: 1000) { name }}").then(function (res) { 14 | console.log(res); 15 | }); 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all deps clean build test watch debug run 2 | 3 | all: build 4 | 5 | deps: 6 | lein deps 7 | 8 | clean: 9 | lein clean 10 | 11 | build: 12 | mkdir -p out/prod/resources 13 | cp resources/* out/prod/resources 14 | lein cljsbuild once main 15 | echo 'module.exports = speako.core;' >> out/prod/speako.js 16 | 17 | ONCE_FLAG=once 18 | test: clean 19 | mkdir -p out/test/resources 20 | cp resources/* out/test/resources 21 | lein doo node test $(ONCE_FLAG) 22 | 23 | watch: ONCE_FLAG= 24 | watch: test 25 | 26 | DEBUG_FLAG= 27 | debug: DEBUG_FLAG=--debug 28 | debug: run 29 | 30 | run: build 31 | cd out/prod/ && node speako.js $(DEBUG_FLAG) 32 | 33 | publish: clean build 34 | npm publish 35 | lein deploy clojars 36 | 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "speako", 3 | "preferGlobal": false, 4 | "version": "0.10.35", 5 | "author": "Jonathan Leonard ", 6 | "description": "A compiler (written in ClojureScript) for GraphQL Schema Language.", 7 | "contributors": [ 8 | "Jonathan Leonard " 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git://github.com/johanatan/speako.git" 13 | }, 14 | "license": "SEE LICENSE IN ", 15 | "dependencies": { 16 | "npm": ">= 1.1.2", 17 | "pg": "5.0.0", 18 | "pg-native": "1.10.0", 19 | "pluralize": "4.0.0", 20 | "graphql": "0.8.2", 21 | "source-map-support": "0.2.8", 22 | "graphql-custom-datetype": "0.4.0", 23 | "graphql-union-input-type": "0.2.2" 24 | }, 25 | "devDependencies": { 26 | "lodash": "4.17.2" 27 | }, 28 | "optionalDependencies": {}, 29 | "engines": { 30 | "node": ">= 4.1.1" 31 | }, 32 | "homepage": "https://github.com/johanatan/speako", 33 | "main": "out/prod/speako.js", 34 | "files": ["out/prod/speako.js", "README.md", "examples/albums.js"], 35 | "tonicExampleFilename": "examples/albums.js" 36 | } 37 | 38 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject speako "0.10.35" 2 | :description "GraphQL Schema Language Compiler" 3 | :url "https://github.com/johanatan/speako" 4 | :license "none" 5 | :dependencies [[org.clojure/clojure "1.8.0"] 6 | [org.clojure/clojurescript "1.9.293"] 7 | [org.clojure/core.match "0.3.0-alpha4"] 8 | [com.lucasbradstreet/cljs-uuid-utils "1.0.2"] 9 | [sqlingvo.node "0.1.0"] 10 | [aysylu/loom "1.0.0"] 11 | [funcool/promesa "1.8.0"] 12 | [camel-snake-kebab "0.4.0"] 13 | [org.clojure/core.match "0.3.0-alpha4"] 14 | [funcool/cats "2.0.0"] 15 | [instaparse "1.4.4"]] 16 | :npm {:dependencies [[source-map-support "0.2.8"] 17 | [pg "5.0.0"] 18 | [pg-native "1.10.0"] 19 | [pluralize "4.0.0"] 20 | [graphql-union-input-type "0.2.2"] 21 | [graphql-custom-datetype "0.4.0"] 22 | [graphql "0.8.2"]]} 23 | :plugins [[lein-npm "0.6.2"] 24 | [lein-doo "0.1.7"] 25 | [lein-cljsbuild "1.1.4"]] 26 | :clean-targets ["out" "target"] 27 | :cljsbuild 28 | {:builds 29 | {:main 30 | {:source-paths ["src"] 31 | :compiler 32 | {:output-to "out/prod/speako.js" 33 | :output-dir "out/prod" 34 | :optimizations :simple 35 | :target :nodejs}} 36 | :test 37 | {:source-paths ["src" "test"] 38 | :compiler 39 | {:output-to "out/test/speako.js" 40 | :output-dir "out/test" 41 | :optimizations :none 42 | :target :nodejs 43 | :main speako.runner}}}}) 44 | -------------------------------------------------------------------------------- /src/speako/common.cljs: -------------------------------------------------------------------------------- 1 | ;; Copyright (c) 2016 Jonathan L. Leonard 2 | 3 | (ns speako.common 4 | (:require [goog.string.format] 5 | [cljs.pprint :as pprint] 6 | [clojure.string :as string] 7 | [cljs.nodejs :as node] 8 | [camel-snake-kebab.core :refer [->kebab-case ->PascalCase]])) 9 | 10 | (enable-console-print!) 11 | 12 | (def fs (node/require "fs")) 13 | (def pluralizer (node/require "pluralize")) 14 | 15 | (defn format 16 | "Formats a string using goog.string.format." 17 | [fmt & args] 18 | (apply goog.string/format fmt args)) 19 | 20 | (def DEBUG (atom false)) 21 | (defn pprint-str [obj] (pprint/write obj :stream nil)) 22 | (defn dbg-print [fmt & args] (if @DEBUG 23 | (let [ppargs (map #(pprint-str %) args)] 24 | (js/console.log (apply (partial format fmt) ppargs))))) 25 | (defn dbg-banner-print [fmt & args] 26 | (let [banner (string/join (repeat 85 "="))] 27 | (dbg-print (string/join [banner "\n" fmt "\n" banner]) args))) 28 | (defn dbg-obj-print [obj] (js/console.log obj) obj) 29 | (defn dbg-obj-print-in [props obj] (js/console.log (apply (partial aget obj) props)) obj) 30 | (defn dbg-file [msg] ;; GraphQL eats console output occuring in our callbacks. 31 | (.appendFileSync fs "./debug.log" (format "%s\n" (pprint-str msg))) msg) 32 | 33 | (defn jskeys [jsobj] 34 | (.keys js/Object jsobj)) 35 | 36 | (defn single 37 | ([col] (single col (format "Error: expected single element in collection: %s" col))) 38 | ([col msg] 39 | (assert (= 1 (count col)) msg) 40 | (first col))) 41 | 42 | (defn pluralize [noun] 43 | (let [pluralized (pluralizer noun)] 44 | (if (= pluralized noun) 45 | (format "All%s" pluralized) 46 | pluralized))) 47 | 48 | (defn singularize [noun] 49 | (.singular pluralizer noun)) 50 | 51 | (defn duplicates [seq] 52 | (map key (remove (comp #{1} val) (frequencies seq)))) 53 | -------------------------------------------------------------------------------- /examples/starWarsData.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2015, Facebook, Inc. 3 | * All rights reserved. 4 | * LICENSE file in the root directory of this source tree. An additional grant 5 | * of patent rights can be found in the PATENTS file in the same directory. 6 | */ 7 | 8 | /** 9 | * This defines a basic set of data for our Star Wars Schema. 10 | * 11 | * This data is hard coded for the sake of the demo, but you could imagine 12 | * fetching this data from a backend service rather than from hardcoded 13 | * JSON objects in a more complex demo. 14 | */ 15 | 16 | var luke = { 17 | id: '1000', 18 | name: 'Luke Skywalker', 19 | friends: [ '1002', '1003', '2000', '2001' ], 20 | appearsIn: [ 4, 5, 6 ], 21 | homePlanet: 'Tatooine', 22 | }; 23 | 24 | var vader = { 25 | id: '1001', 26 | name: 'Darth Vader', 27 | friends: [ '1004' ], 28 | appearsIn: [ 4, 5, 6 ], 29 | homePlanet: 'Tatooine', 30 | }; 31 | 32 | var han = { 33 | id: '1002', 34 | name: 'Han Solo', 35 | friends: [ '1000', '1003', '2001' ], 36 | appearsIn: [ 4, 5, 6 ], 37 | }; 38 | 39 | var leia = { 40 | id: '1003', 41 | name: 'Leia Organa', 42 | friends: [ '1000', '1002', '2000', '2001' ], 43 | appearsIn: [ 4, 5, 6 ], 44 | homePlanet: 'Alderaan', 45 | }; 46 | 47 | var tarkin = { 48 | id: '1004', 49 | name: 'Wilhuff Tarkin', 50 | friends: [ '1001' ], 51 | appearsIn: [ 4 ], 52 | }; 53 | 54 | var humanData = { 55 | 1000: luke, 56 | 1001: vader, 57 | 1002: han, 58 | 1003: leia, 59 | 1004: tarkin, 60 | }; 61 | 62 | var threepio = { 63 | id: '2000', 64 | name: 'C-3PO', 65 | friends: [ '1000', '1002', '1003', '2001' ], 66 | appearsIn: [ 4, 5, 6 ], 67 | primaryFunction: 'Protocol', 68 | }; 69 | 70 | var artoo = { 71 | id: '2001', 72 | name: 'R2-D2', 73 | friends: [ '1000', '1002', '1003' ], 74 | appearsIn: [ 4, 5, 6 ], 75 | primaryFunction: 'Astromech', 76 | }; 77 | 78 | var droidData = { 79 | 2000: threepio, 80 | 2001: artoo, 81 | }; 82 | 83 | module.exports.getHero = function (episode) { 84 | if (episode === 5) { 85 | return luke; 86 | } 87 | return artoo; 88 | } 89 | 90 | module.exports.getHuman = function(id) { 91 | return humanData[id]; 92 | } 93 | 94 | module.exports.getDroid = function(id) { 95 | return droidData[id]; 96 | } 97 | -------------------------------------------------------------------------------- /test/speako/core.cljs: -------------------------------------------------------------------------------- 1 | ;; Copyright (c) 2015 Jonathan L. Leonard 2 | 3 | (ns speako.test.core 4 | (:require [cljs.test :refer-macros [deftest is async]] 5 | [cljs.nodejs :as node] 6 | [speako.common :refer [format]] 7 | [speako.core])) 8 | 9 | (def gql (node/require "graphql")) 10 | 11 | (defn- call-graphql 12 | ([cb schema query] (call-graphql cb schema query nil)) 13 | ([cb schema query params] 14 | (.then ((.-graphql gql) schema query params) (fn [res] 15 | (cb (.stringify js/JSON res nil 2)))))) 16 | 17 | (defn file-schema [resolver] (speako.core/get-schema resolver "./resources/schema.gql")) 18 | 19 | (defn str-schema 20 | ([schema-str] (str-schema {} schema-str)) 21 | ([resolver schema-str] (speako.core/get-schema resolver schema-str))) 22 | 23 | (deftest loads-schema-from-file (is (file-schema {}))) 24 | 25 | (deftest simple-select 26 | (async done 27 | (let [expected {"data" {"Colors" nil}} 28 | comparator (fn [s] (is (= expected (js->clj (.parse js/JSON s)))) (done)) 29 | resolver {:query (fn [typename predicate] 30 | (is (and (= typename "Color") 31 | (= predicate (js/JSON.stringify #js {"all" true})))) nil)}] 32 | (call-graphql comparator (file-schema resolver) "{ Colors { id name } }")))) 33 | 34 | (deftest reserved-entities-forbidden 35 | (is (thrown-with-msg? js/Error #"Timestamp is a reserved entity provided by speako\." 36 | (str-schema "type Timestamp { id: ID! }")))) 37 | 38 | (deftest simple-timestamp 39 | (async 40 | done 41 | (let [expected [(js/Date.) (js/Date.)] 42 | comparator (fn [a] 43 | (is (= (js->clj (.parse js/JSON a)) 44 | {"data" {"As" [{"ts" (map #(.toISOString %) expected)}]}})) (done)) 45 | resolver {:query (fn [typename predicate] 46 | (is (= typename "A")) 47 | (clj->js [{:id "1" :ts expected}]))}] 48 | (call-graphql 49 | comparator 50 | (str-schema resolver "type A { id: ID! ts: [Timestamp]! }") 51 | "{ As { ts }}")))) 52 | 53 | (deftest multiple-relations-of-same-type-with-reverse-link-forbidden 54 | (is (thrown-with-msg? js/Error #"Type 'A' involves duplicate \(bidirectional\) links to types: \(\"B\"\)." 55 | (str-schema "type A { id: ID! b1: B b2: B } type B { id: ID! a: A }")))) 56 | -------------------------------------------------------------------------------- /src/speako/core.cljs: -------------------------------------------------------------------------------- 1 | 2 | ;; Copyright (c) 2016 Jonathan L. Leonard 3 | 4 | (ns speako.core 5 | (:require [speako.consumer :refer [GraphQLConsumer] :as consumer] 6 | [speako.schema :as schema] 7 | [clojure.walk :as walk] 8 | [cljs.nodejs :as nodejs] 9 | [speako.common :as common])) 10 | 11 | (nodejs/enable-util-print!) 12 | 13 | (defn- bail [msg] (fn [& _] (throw (js/Error. (common/format "Not implemented: '%s'." msg))))) 14 | 15 | (defn- get-data-resolver [is-js? {:keys [query create modify delete] 16 | :or {query (bail "query") 17 | create (bail "create") 18 | modify (bail "modify") 19 | delete (bail "delete")}}] 20 | (let [to-js (if is-js? clj->js identity) 21 | stringify #(js/JSON.stringify (clj->js %))] 22 | (reify consumer/DataResolver 23 | (query [_ typename predicate] 24 | (common/dbg-print "speako: query: typename: %s, predicate: %s" typename predicate) 25 | (query typename predicate)) 26 | (create [_ typename inputs] 27 | (common/dbg-print "speako: create: typename: %s, inputs: %s" typename (stringify inputs)) 28 | (create typename (to-js inputs))) 29 | (modify [_ typename inputs] 30 | (common/dbg-print "speako: modify: typename: %s, inputs: %s" typename (stringify inputs)) 31 | (modify typename (to-js inputs))) 32 | (delete [_ typename id] 33 | (common/dbg-print "speako: delete: typename: %s, id: %s" typename (stringify id)) 34 | (delete typename id))))) 35 | 36 | (defn consume-schema [config schema-filename-or-contents] 37 | (let [is-js? (object? config) 38 | config-map (if is-js? (walk/keywordize-keys (js->clj config)) config) 39 | resolver-methods (select-keys config-map [:query :create :modify :delete]) 40 | user-consumers (or (:consumers (select-keys config-map [:consumers])) []) 41 | _ (assert (vector? user-consumers) "User supplied consumers must be of type vector.") 42 | consumer (GraphQLConsumer (get-data-resolver is-js? config-map)) 43 | consumers (into [consumer] user-consumers)] 44 | (apply (partial schema/load-schema schema-filename-or-contents) consumers))) 45 | 46 | (defn ^:export get-schema [config schema-filename-or-contents] 47 | (-> (consume-schema config schema-filename-or-contents) second first)) 48 | 49 | (defn ^:export set-debug [debug?] 50 | (assert (boolean? debug?)) 51 | (reset! common/DEBUG debug?)) 52 | 53 | (def ^:export getSchema get-schema) 54 | (def ^:export setDebug set-debug) 55 | 56 | (defn noop [] nil) 57 | (set! *main-cli-fn* noop) 58 | -------------------------------------------------------------------------------- /examples/albums.js: -------------------------------------------------------------------------------- 1 | 2 | var speako = require('../'); 3 | var gql = require('graphql'); 4 | var _ = require('lodash'); 5 | var labels = [ 6 | {'id': 1, 'name': 'Apple Records', 'founded': '1968'}, 7 | {'id': 2, 'name': 'Harvest Records', 'founded': '1969'}]; 8 | var albums = [ 9 | {'id': 1, 'name': 'Dark Side Of The Moon', 'releaseDate': 'March 1, 1973', 10 | 'artist': 'Pink Floyd', 'label': labels[1]}, 11 | {'id': 2, 'name': 'The Beatles', 'releaseDate': 'November 22, 1968', 12 | 'artist': 'The Beatles', 'label': labels[0]}, 13 | {'id': 3, 'name': 'The Wall', 'releaseDate': 'August 1, 1982', 14 | 'artist': 'Pink Floyd', 'label': labels[1]}]; 15 | var getFilters = function(pairs) { 16 | return pairs.map(function (p) { 17 | var [field, value] = p; 18 | if (typeof value === "object") { 19 | console.assert(field == "label"); 20 | return function(elem) { 21 | var innerKey = _.first(_.keys(value)); 22 | return elem[field][innerKey] == value[innerKey]; }; 23 | } 24 | return function(elem) { return elem[field] == value; }; 25 | }); 26 | }; 27 | var dataResolver = {"query": function (typename, predicate) { 28 | console.assert(typename == "Album"); 29 | var parsed = JSON.parse(predicate); 30 | if (_.isEqual(parsed, {"all": true})) return albums; 31 | else { 32 | var pairs = _.toPairs(parsed); 33 | var filters = getFilters(pairs); 34 | return albums.filter(function(elem) { return filters.every(function(f) { return f(elem); }); }); 35 | } 36 | }, "create": function (typename, inputs) { 37 | inputs.id = albums.length + 1; 38 | albums.push(inputs); 39 | return inputs; 40 | }, "delete": function (typename, inputs) { 41 | console.assert(typename == "Album"); 42 | var filters = getFilters(_.toPairs(inputs)); 43 | var [deleted, remaining] = _.partition(albums, function(elem) { return filters.every(function(f) { return f(elem); }); }); 44 | albums = remaining; 45 | return _.head(deleted); 46 | }}; 47 | var schema = 48 | speako.getSchema(dataResolver, 49 | ["type Label { id: ID! name: String founded: String album: Album } ", 50 | "type Album { id: ID! name: String releaseDate: String artist: String label: Label }"].join(" ")); 51 | var printer = function(res) { console.log(JSON.stringify(res, null, 2)); }; 52 | speako.setDebug(true); 53 | gql.graphql(schema, 54 | "{ Album(artist: \"Pink Floyd\", label: { name: \"Harvest Records\" }) { name artist releaseDate } }") .then(printer); 55 | gql.graphql(schema, "{ Album(artist: \"Pink Floyd\", name: \"The Wall\") { name artist releaseDate } }").then(printer); 56 | gql.graphql(schema, "{ Album(id: 2) { name artist releaseDate } }").then(printer); 57 | gql.graphql(schema, "{ Albums { name artist releaseDate } }").then(printer); 58 | gql.graphql(schema, 59 | "mutation m { createAlbum(name:\"The Division Bell\", releaseDate: \"March 28, 1994\", artist:\"Pink Floyd\") { id name } }") 60 | .then(printer); 61 | gql.graphql(schema, "mutation m { deleteAlbum(id: 3) { id name } }").then(printer); 62 | gql.graphql(schema, "mutation m { deleteAlbum(name: \"The Beatles\") { id releaseDate } }").then(printer); 63 | -------------------------------------------------------------------------------- /src/speako/schema.cljs: -------------------------------------------------------------------------------- 1 | ;; Copyright (c) 2016 Jonathan L. Leonard 2 | 3 | (ns speako.schema 4 | (:require [cljs.nodejs :as node] 5 | [cljs.core.match :refer-macros [match]] 6 | [speako.common :as common] 7 | [instaparse.core :as insta])) 8 | 9 | (def fs (node/require "fs")) 10 | 11 | (def ^:private grammar 12 | " = TYPE+ 13 | TYPE = (OBJECT | UNION | ENUM) 14 | = TYPE_KEYWORD IDENTIFIER <'{'> FIELD+ <'}'> 15 | TYPE_KEYWORD = 'type' 16 | RWS = #'\\s+' 17 | WS = #'\\s*' 18 | IDENTIFIER = #'[a-zA-Z0-9_]+' 19 | FIELD = IDENTIFIER <':'> (LIST | DATATYPE) [NOTNULL] 20 | LIST = <'['> DATATYPE <']'> 21 | NOTNULL = <'!'> 22 | DATATYPE = 'ID' | 'Boolean' | 'String' | 'Float' | 'Int' | IDENTIFIER 23 | = UNION_KEYWORD IDENTIFIER <'='> IDENTIFIER OR_CLAUSE+ 24 | UNION_KEYWORD = 'union' 25 | = <'|'> IDENTIFIER 26 | = ENUM_KEYWORD IDENTIFIER <'{'> ENUM_VAL COMMA_ENUM_VAL+ <'}'> 27 | ENUM_KEYWORD = 'enum' 28 | ENUM_VAL = IDENTIFIER 29 | = <','> ENUM_VAL") 30 | 31 | (node/enable-util-print!) 32 | (def ^:private type-language-parser (insta/parser grammar :output-format :enlive)) 33 | 34 | (defn- extract-content [m] (get m :content)) 35 | (defn- extract-single-content [m] (common/single (extract-content m))) 36 | 37 | (defn- extract-field-descriptors [parsed] 38 | (assert (= :FIELD (get parsed :tag))) 39 | (let [content (extract-content parsed) 40 | [field-comp type-comp & not-null-comp] content 41 | fieldname (extract-single-content field-comp) 42 | is-list? (= :LIST (get type-comp :tag)) 43 | dt-content (extract-single-content ((if is-list? extract-single-content identity) type-comp)) 44 | datatype (extract-single-content dt-content) 45 | is-not-null? (= 1 (count not-null-comp))] 46 | [fieldname datatype is-list? is-not-null?])) 47 | 48 | (defn- get-object-descriptors [parsed] 49 | (assert (= {:tag :TYPE_KEYWORD :content (list "type")} (first parsed))) 50 | (let [[_ typename-comp & field-comps] parsed 51 | typename (extract-single-content typename-comp) 52 | field-descriptors (map extract-field-descriptors field-comps)] 53 | (assert (not= typename "Timestamp") "Timestamp is a reserved entity provided by speako.") 54 | [typename field-descriptors])) 55 | 56 | (defn- get-union-descriptors [parsed] 57 | (assert (= {:tag :UNION_KEYWORD :content (list "union")} (first parsed))) 58 | (let [typename (extract-single-content (second parsed)) 59 | constituents (map #(extract-single-content %) (drop 2 parsed))] 60 | [typename constituents])) 61 | 62 | (defn- get-enum-descriptors [parsed] 63 | (assert (= {:tag :ENUM_KEYWORD, :content (list "enum")}) (first parsed)) 64 | (let [typename (extract-single-content (second parsed)) 65 | values (map-indexed (fn [i p] 66 | {(extract-single-content (extract-single-content p)) 67 | {:value (+ 1 i)}}) (drop 2 parsed))] 68 | [typename values])) 69 | 70 | (defprotocol TypeConsumer 71 | (consume-object [this typename field-descriptors]) 72 | (consume-union [this typename constituents]) 73 | (consume-enum [this typename constituents]) 74 | (finished [this])) 75 | 76 | (defmulti load-schema #(.existsSync fs %)) 77 | 78 | (defmethod load-schema true [filename & consumers] 79 | (apply load-schema (.toString (.readFileSync fs filename)) consumers)) 80 | 81 | (defmethod load-schema false [schema-str & consumers] 82 | (let [parsed (type-language-parser schema-str)] 83 | (common/dbg-banner-print "Parsed: %s" parsed) 84 | (doseq [p parsed] 85 | (assert (= :TYPE (get p :tag)) (common/format "Expected :TYPE. Actual: %s. Parsed: %s" (get p :tag) p)) 86 | (let [content (extract-content p) 87 | [impl descriptors] (match [(get (first content) :tag)] 88 | [:UNION_KEYWORD] [consume-union (get-union-descriptors content)] 89 | [:TYPE_KEYWORD] [consume-object (get-object-descriptors content)] 90 | [:ENUM_KEYWORD] [consume-enum (get-enum-descriptors content)])] 91 | (doseq [consumer consumers] 92 | (apply (partial impl consumer) descriptors)))) 93 | [schema-str (doall (map finished consumers))])) 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # speako 2 | 3 | ### A simpler interface to GraphQL 4 | 5 | #### Install 6 | 7 | * NPM - `npm install speako` 8 | 9 | #### Motivation 10 | 11 | GraphQL normally requires a `GraphQLSchema` object passed along with each query 12 | you give it to validate, interpret & execute. Typically this schema is constructed 13 | by hand-crafting some verbose & noisy JavaScript. 14 | 15 | See: [starWarsSchema.js](https://github.com/graphql/graphql-js/blob/master/src/__tests__/starWarsSchema.js). 16 | 17 | The equivalent schema in GraphQL Schema Language is much more concise: 18 | ``` 19 | enum Episode { NEWHOPE, EMPIRE, JEDI } 20 | 21 | type Human { 22 | id: ID! 23 | name: String 24 | friends: [Character] 25 | appearsIn: [Episode] 26 | homePlanet: String 27 | } 28 | 29 | type Droid { 30 | id: ID! 31 | name: String 32 | friends: [Character] 33 | appearsIn: [Episode] 34 | primaryFunction: String 35 | } 36 | 37 | union Character = Human | Droid 38 | ``` 39 | 40 | Given a specification of a data model in GraphQL Schema Language, speako automatically 41 | generates the `GraphQLSchema` instance that GraphQL requires and binds its `resolve` methods 42 | to a specified set of functions for querying (i.e., selecting) and mutating (i.e., insert, 43 | update and delete mutations). 44 | 45 | #### Example 46 | 47 | ```javascript 48 | $ node --harmony-destructuring 49 | > var speako = require('speako'); 50 | > var gql = require('graphql'); 51 | > var _ = require('lodash'); 52 | > var labels = [ 53 | ... {'id': 1, 'name': 'Apple Records', 'founded': '1968'}, 54 | ... {'id': 2, 'name': 'Harvest Records', 'founded': '1969'}]; 55 | > var albums = [ 56 | ... {'id': 1, 'name': 'Dark Side Of The Moon', 'releaseDate': 'March 1, 1973', 57 | ... 'artist': 'Pink Floyd', 'label': labels[1]}, 58 | ... {'id': 2, 'name': 'The Beatles', 'releaseDate': 'November 22, 1968', 59 | ... 'artist': 'The Beatles', 'label': labels[0]}, 60 | ... {'id': 3, 'name': 'The Wall', 'releaseDate': 'August 1, 1982', 61 | ... 'artist': 'Pink Floyd', 'label': labels[1]}]; 62 | > var dataResolver = {"query": function (typename, predicate) { 63 | ... console.assert(typename == "Album"); 64 | ... var parsed = JSON.parse(predicate); 65 | ... if (_.isEqual(parsed, {"all": true})) return albums; 66 | ... else { 67 | ... var pairs = _.toPairs(parsed); 68 | ... var filters = pairs.map(function (p) { 69 | ... var [field, value] = p; 70 | ... if (typeof value === "object") { 71 | ... console.assert(field == "label"); 72 | ... return function(elem) { 73 | ... var innerKey = _.first(_.keys(value)); 74 | ... return elem[field][innerKey] == value[innerKey]; }; 75 | ... } 76 | ... return function(elem) { return elem[field] == value; }; 77 | ... }); 78 | ... return albums.filter(function(elem) { return filters.every(function(f) { return f(elem); }); }); 79 | ... } 80 | ... }, "create": function (typename, inputs) { 81 | ... inputs.id = albums.length + 1; 82 | ... albums.push(inputs); 83 | ... return inputs; 84 | ... }}; 85 | > var schema = speako.getSchema(dataResolver, 86 | ... "type Album { id: ID! name: String releaseDate: String artist: String }"); 87 | > var schema = 88 | ... speako.getSchema(dataResolver, 89 | ... ["type Label { id: ID! name: String founded: String album: Album } ", 90 | ... "type Album { id: ID! name: String releaseDate: String artist: String label: Label }"].join(" ")); 91 | > var printer = function(res) { console.log(JSON.stringify(res, null, 2)); }; 92 | > gql.graphql(schema, 93 | ... "{ Album(artist: \"Pink Floyd\", label: { name: \"Harvest Records\" }) { name artist releaseDate } }") .then(printer); 94 | 95 | { 96 | "data": { 97 | "Album": [ 98 | { 99 | "name": "Dark Side Of The Moon", 100 | "artist": "Pink Floyd", 101 | "releaseDate": "March 1, 1973" 102 | }, 103 | { 104 | "name": "The Wall", 105 | "artist": "Pink Floyd", 106 | "releaseDate": "August 1, 1982" 107 | } 108 | ] 109 | } 110 | } 111 | 112 | > gql.graphql(schema, "{ Album(artist: \"Pink Floyd\") { name artist releaseDate } }").then(printer); 113 | 114 | { 115 | "data": { 116 | "Album": [ 117 | { 118 | "name": "Dark Side Of The Moon", 119 | "artist": "Pink Floyd", 120 | "releaseDate": "March 1, 1973" 121 | }, 122 | { 123 | "name": "The Wall", 124 | "artist": "Pink Floyd", 125 | "releaseDate": "August 1, 1982" 126 | } 127 | ] 128 | } 129 | } 130 | 131 | > gql.graphql(schema, "{ Album(artist: \"Pink Floyd\", name: \"The Wall\") { name artist releaseDate } }").then(printer); 132 | 133 | { 134 | "data": { 135 | "Album": [ 136 | { 137 | "name": "The Wall", 138 | "artist": "Pink Floyd", 139 | "releaseDate": "August 1, 1982" 140 | } 141 | ] 142 | } 143 | } 144 | 145 | > gql.graphql(schema, "{ Album(id: 2) { name artist releaseDate } }").then(printer); 146 | 147 | { 148 | "data": { 149 | "Album": [ 150 | { 151 | "name": "The Beatles", 152 | "artist": "The Beatles", 153 | "releaseDate": "November 22, 1968" 154 | } 155 | ] 156 | } 157 | } 158 | 159 | > gql.graphql(schema, "{ Albums { name artist releaseDate } }").then(printer); 160 | 161 | { 162 | "data": { 163 | "Albums": [ 164 | { 165 | "name": "Dark Side Of The Moon", 166 | "artist": "Pink Floyd", 167 | "releaseDate": "March 1, 1973" 168 | }, 169 | { 170 | "name": "The Beatles", 171 | "artist": "The Beatles", 172 | "releaseDate": "November 22, 1968" 173 | }, 174 | { 175 | "name": "The Wall", 176 | "artist": "Pink Floyd", 177 | "releaseDate": "Auguest 1, 1982" 178 | } 179 | ] 180 | } 181 | } 182 | 183 | > gql.graphql(schema, "mutation m { createAlbum(name:\"The Division Bell\", releaseDate: \"March 28, 1994\", artist:\"Pink Floyd\") { id name } }").then(printer); 184 | 185 | { 186 | "data": { 187 | "createAlbum": { 188 | "id": "4", 189 | "name": "The Division Bell" 190 | } 191 | } 192 | } 193 | 194 | ``` 195 | 196 | Copyright (c) 2015 Jonathan L. Leonard 197 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # speako 2 | 3 | ### A simpler interface to GraphQL 4 | 5 | #### Install 6 | 7 | * NPM - `npm install speako` 8 | 9 | #### Motivation 10 | 11 | GraphQL normally requires a `GraphQLSchema` object passed along with each query 12 | you give it to validate, interpret & execute. Typically this schema is constructed 13 | by hand-crafting some verbose & noisy JavaScript. 14 | 15 | See: [starWarsSchema.js](https://github.com/graphql/graphql-js/blob/master/src/__tests__/starWarsSchema.js). 16 | 17 | The equivalent schema in GraphQL Schema Language is much more concise: 18 | ``` 19 | enum Episode { NEWHOPE, EMPIRE, JEDI } 20 | 21 | type Human { 22 | id: ID! 23 | name: String 24 | friends: [Character] 25 | appearsIn: [Episode] 26 | homePlanet: String 27 | } 28 | 29 | type Droid { 30 | id: ID! 31 | name: String 32 | friends: [Character] 33 | appearsIn: [Episode] 34 | primaryFunction: String 35 | } 36 | 37 | union Character = Human | Droid 38 | ``` 39 | 40 | Given a specification of a data model in GraphQL Schema Language, speako automatically 41 | generates the `GraphQLSchema` instance that GraphQL requires and binds its `resolve` methods 42 | to a specified set of functions for querying (i.e., selecting) and mutating (i.e., insert, 43 | update and delete mutations). 44 | 45 | #### Example 46 | 47 | ```javascript 48 | $ node --harmony-destructuring 49 | > var speako = require('speako'); 50 | > var gql = require('graphql'); 51 | > var _ = require('lodash'); 52 | > var labels = [ 53 | ... {'id': 1, 'name': 'Apple Records', 'founded': '1968'}, 54 | ... {'id': 2, 'name': 'Harvest Records', 'founded': '1969'}]; 55 | > var albums = [ 56 | ... {'id': 1, 'name': 'Dark Side Of The Moon', 'releaseDate': 'March 1, 1973', 57 | ... 'artist': 'Pink Floyd', 'label': labels[1]}, 58 | ... {'id': 2, 'name': 'The Beatles', 'releaseDate': 'November 22, 1968', 59 | ... 'artist': 'The Beatles', 'label': labels[0]}, 60 | ... {'id': 3, 'name': 'The Wall', 'releaseDate': 'August 1, 1982', 61 | ... 'artist': 'Pink Floyd', 'label': labels[1]}]; 62 | > var dataResolver = {"query": function (typename, predicate) { 63 | ... console.assert(typename == "Album"); 64 | ... var parsed = JSON.parse(predicate); 65 | ... if (_.isEqual(parsed, {"all": true})) return albums; 66 | ... else { 67 | ... var pairs = _.toPairs(parsed); 68 | ... var filters = pairs.map(function (p) { 69 | ... var [field, value] = p; 70 | ... if (typeof value === "object") { 71 | ... console.assert(field == "label"); 72 | ... return function(elem) { 73 | ... var innerKey = _.first(_.keys(value)); 74 | ... return elem[field][innerKey] == value[innerKey]; }; 75 | ... } 76 | ... return function(elem) { return elem[field] == value; }; 77 | ... }); 78 | ... return albums.filter(function(elem) { return filters.every(function(f) { return f(elem); }); }); 79 | ... } 80 | ... }, "create": function (typename, inputs) { 81 | ... inputs.id = albums.length + 1; 82 | ... albums.push(inputs); 83 | ... return inputs; 84 | ... }}; 85 | > var schema = speako.getSchema(dataResolver, 86 | ... "type Album { id: ID! name: String releaseDate: String artist: String }"); 87 | > var schema = 88 | ... speako.getSchema(dataResolver, 89 | ... ["type Label { id: ID! name: String founded: String album: Album } ", 90 | ... "type Album { id: ID! name: String releaseDate: String artist: String label: Label }"].join(" ")); 91 | > var printer = function(res) { console.log(JSON.stringify(res, null, 2)); }; 92 | > gql.graphql(schema, 93 | ... "{ Album(artist: \"Pink Floyd\", label: { name: \"Harvest Records\" }) { name artist releaseDate } }") .then(printer); 94 | 95 | { 96 | "data": { 97 | "Album": [ 98 | { 99 | "name": "Dark Side Of The Moon", 100 | "artist": "Pink Floyd", 101 | "releaseDate": "March 1, 1973" 102 | }, 103 | { 104 | "name": "The Wall", 105 | "artist": "Pink Floyd", 106 | "releaseDate": "August 1, 1982" 107 | } 108 | ] 109 | } 110 | } 111 | 112 | > gql.graphql(schema, "{ Album(artist: \"Pink Floyd\") { name artist releaseDate } }").then(printer); 113 | 114 | { 115 | "data": { 116 | "Album": [ 117 | { 118 | "name": "Dark Side Of The Moon", 119 | "artist": "Pink Floyd", 120 | "releaseDate": "March 1, 1973" 121 | }, 122 | { 123 | "name": "The Wall", 124 | "artist": "Pink Floyd", 125 | "releaseDate": "August 1, 1982" 126 | } 127 | ] 128 | } 129 | } 130 | 131 | > gql.graphql(schema, "{ Album(artist: \"Pink Floyd\", name: \"The Wall\") { name artist releaseDate } }").then(printer); 132 | 133 | { 134 | "data": { 135 | "Album": [ 136 | { 137 | "name": "The Wall", 138 | "artist": "Pink Floyd", 139 | "releaseDate": "August 1, 1982" 140 | } 141 | ] 142 | } 143 | } 144 | 145 | > gql.graphql(schema, "{ Album(id: 2) { name artist releaseDate } }").then(printer); 146 | 147 | { 148 | "data": { 149 | "Album": [ 150 | { 151 | "name": "The Beatles", 152 | "artist": "The Beatles", 153 | "releaseDate": "November 22, 1968" 154 | } 155 | ] 156 | } 157 | } 158 | 159 | > gql.graphql(schema, "{ Albums { name artist releaseDate } }").then(printer); 160 | 161 | { 162 | "data": { 163 | "Albums": [ 164 | { 165 | "name": "Dark Side Of The Moon", 166 | "artist": "Pink Floyd", 167 | "releaseDate": "March 1, 1973" 168 | }, 169 | { 170 | "name": "The Beatles", 171 | "artist": "The Beatles", 172 | "releaseDate": "November 22, 1968" 173 | }, 174 | { 175 | "name": "The Wall", 176 | "artist": "Pink Floyd", 177 | "releaseDate": "Auguest 1, 1982" 178 | } 179 | ] 180 | } 181 | } 182 | 183 | > gql.graphql(schema, "mutation m { createAlbum(name:\"The Division Bell\", releaseDate: \"March 28, 1994\", artist:\"Pink Floyd\") { id name } }").then(printer); 184 | 185 | { 186 | "data": { 187 | "createAlbum": { 188 | "id": "4", 189 | "name": "The Division Bell" 190 | } 191 | } 192 | } 193 | 194 | ``` 195 | 196 | Copyright (c) 2015 Jonathan L. Leonard 197 | -------------------------------------------------------------------------------- /src/speako/backend.cljs: -------------------------------------------------------------------------------- 1 | 2 | ;; Copyright (c) 2016 Jonathan L. Leonard 3 | 4 | (ns speako.backend 5 | (:require-macros [cljs.core.async.macros :refer [go]]) 6 | (:require [speako.schema :as schema] 7 | [speako.common :refer [format single singularize pluralize]] 8 | [loom.graph :as graph] 9 | [loom.attr :as attr] 10 | [sqlingvo.core :as sql] 11 | [sqlingvo.node :as db :refer-macros [PascalCase ->camelCase ->kebab-case]] 15 | [cljs.pprint :refer [pprint]] 16 | [clojure.core.match :refer [match]] 17 | [cats.core :as m :include-macros true] 18 | [cats.builtin] 19 | [clojure.set])) 20 | 21 | (defn- consume-types [] 22 | (let [result (atom {:objects {} :unions {} :enums {}})] 23 | (reify schema/TypeConsumer 24 | (consume-object [_ typename fields] 25 | (swap! result assoc-in [:objects (keyword typename)] fields)) 26 | (consume-union [_ typename constituent-types] 27 | (swap! result assoc-in [:unions (keyword typename)] (map keyword constituent-types))) 28 | (consume-enum [_ typename constituent-types] 29 | (swap! result assoc-in [:enums (keyword typename)] constituent-types)) 30 | (finished [_] @result)))) 31 | 32 | (defn- object-edges [nodes [node fields]] 33 | (remove nil? (map (fn [[name type list? required?]] 34 | (let [typ (keyword type)] 35 | (when (nodes typ) 36 | [node typ name list? required?]))) fields))) 37 | 38 | (defn- construct-graph [parsed] 39 | (let [nodes (set (keys (parsed :objects))) 40 | unions (set (keys (parsed :unions))) 41 | object-edges (partial object-edges (clojure.set/union nodes unions)) 42 | edges (remove empty? (mapcat object-edges (parsed :objects))) 43 | g (apply graph/digraph (concat (map (partial take 2) edges) nodes)) 44 | attrs (map #(let [edge (take 2 %1)] [edge (%1 2) {:list? (%1 3) :required? (%1 4)}]) edges) 45 | with-edge-attrs (reduce #(attr/add-attr-to-edges %1 (%2 1) (%2 2) [(%2 0)]) g attrs) 46 | with-union-attrs (attr/add-attr-to-nodes with-edge-attrs :union? true unions)] 47 | with-union-attrs)) 48 | 49 | (defn- chan->promise 50 | ([channel] (chan->promise identity channel)) 51 | ([xfm channel] 52 | (p/promise 53 | (fn [resolve reject] 54 | (go 55 | (let [res (promise outer-xfm (async/map inner-xfm [(db/execute (query))])))) 63 | 64 | (defn- get-table-names [db] 65 | (promisify #(map :table_name %1) 66 | #(sql/select db [:table_name] 67 | (sql/from :information_schema.tables) 68 | (sql/where '(= :table_schema "public"))))) 69 | 70 | (defn- association-tables [table-names] 71 | (filter #(.endsWith % "Associations") table-names)) 72 | 73 | (def ^:private db-types-map 74 | {"ID" {:scalar "integer" :array "_int4"} 75 | "Boolean" {:scalar "boolean" :array "_bool"} 76 | "String" {:scalar "character varying" :array "_varchar"} 77 | "Float" {:scalar "double precision" :array "_float8"} 78 | "Timestamp" {:scalar "timestamp without time zone" :array nil} 79 | "Int" {:scalar "integer" :array "_int4"}}) 80 | 81 | (def ^:private db-scalar-types-map (m/fmap :scalar db-types-map)) 82 | (def ^:private db-array-types-map (into {} (map #(do [(%1 :array) (%1 :scalar)]) (vals db-types-map)))) 83 | (def ^:private scalar-types (set (keys db-types-map))) 84 | 85 | (defn- table->columns [table-name db] 86 | (let [xfm-type #(condp = %1 "ARRAY" (format "%s[]" (db-array-types-map %2)) %1)] 87 | (promisify 88 | #(into {} %) 89 | #(map (fn [r] [(:column_name r) {:type (xfm-type (:data_type r) (:udt_name r)) 90 | :is-nullable? (:is_nullable r)}]) %1) 91 | #(sql/select db [:column_name :data_type :is_nullable :udt_name] 92 | (sql/from :information_schema.columns) 93 | (sql/where `(= :table_name ~table-name)))))) 94 | 95 | (defn- entities [graph] 96 | (let [nodes (graph/nodes graph)] 97 | (remove #(attr/attr graph % :union?) nodes))) 98 | 99 | (defn entity->table-name [entity-kwd] 100 | (-> entity-kwd name pluralize ->camelCase)) 101 | 102 | (defn- tables-exist? [graph db] 103 | (p/alet [tables (p/await (get-table-names db)) 104 | expected (map entity->table-name (entities graph)) 105 | remaining (clojure.set/difference (set expected) (set tables))] 106 | (when (not-empty remaining) 107 | (js/console.error (format "ERROR: Backing tables missing: %s" (into [] remaining)))) 108 | (empty? remaining))) 109 | 110 | (defn- run-query [db query-fn] 111 | (p/alet [db (p/await (chan->promise (db/connect db))) 112 | res (p/await (query-fn db)) 113 | _ (db/disconnect db)] 114 | res)) 115 | 116 | (defn- pprint-query [db query-fn] 117 | (p/map pprint (run-query db query-fn))) 118 | 119 | (defn- scalar-column-exists? [columns-meta [name type list? required?]] 120 | (let [column-meta (columns-meta name) 121 | expected-type (format "%s%s" (db-scalar-types-map type) (if list? "[]" ""))] 122 | (and column-meta 123 | (= expected-type (:type column-meta)) 124 | (= (if required? "NO" "YES") (:is-nullable? column-meta))))) 125 | 126 | (defn- scalar-columns-exist? 127 | ([table-name fields columns-meta db] 128 | (let [scalar-fields (filter #(scalar-types (% 1)) fields) 129 | built-in-fields [["createdAt" "Timestamp" false true] 130 | ["updatedAt" "Timestamp" false false]] 131 | res (map #(do [%1 (scalar-column-exists? columns-meta %1)]) (concat scalar-fields built-in-fields)) 132 | failures (remove second res)] 133 | (when (not-empty failures) 134 | (js/console.error (format "ERROR: Backing columms missing or misconfigured for table %s: %s" 135 | table-name (map #(-> % first first) failures)))) 136 | (empty? failures))) 137 | ([parsed db] 138 | (p/alet [entities (:objects parsed) 139 | promises (map 140 | (fn [[entity fields]] 141 | (p/alet [table-name (entity->table-name entity) 142 | columns-meta (p/await (table->columns table-name db))] 143 | (scalar-columns-exist? table-name fields columns-meta db))) entities) 144 | res (p/await (p/all promises))] 145 | (every? identity res)))) 146 | 147 | (defn- table->foreign-keys [table-name db] 148 | (promisify 149 | #(map (fn [r] (clojure.set/rename-keys r (into {} (map (fn [k] [k (->kebab-case k)]) (keys r))))) %1) 150 | #(sql/select db [:tc.constraint_name :tc.table_name :kcu.column_name 151 | (sql/as :ccu.table_name :foreign_table_name) 152 | (sql/as :ccu.column_name :foreign_column_name)] 153 | (sql/from (sql/as :information_schema.table_constraints :tc)) 154 | (sql/join (sql/as :information_schema.key_column_usage :kcu) 155 | '(on (= :tc.constraint_name :kcu.constraint_name))) 156 | (sql/join (sql/as :information_schema.constraint_column_usage :ccu) 157 | '(on (= :ccu.constraint_name :tc.constraint_name))) 158 | (sql/where `(and (= :constraint_type "FOREIGN KEY") (= :tc.table_name ~table-name)))))) 159 | 160 | (defn- extract-relations [graph db] 161 | (p/alet [entities (entities graph) 162 | table-names (map #(do [(entity->table-name %1) %1]) entities) 163 | promises (map (fn [[t e]] (p/map #(do [e %1]) (table->foreign-keys t db))) table-names) 164 | res (p/await (p/all promises))] 165 | (into {} res))) 166 | 167 | (defrecord Multiplicity [entity field multiplicity required?]) 168 | (defrecord Cardinality [left right]) 169 | 170 | (defn- construct-cardinality [left right fwd-attrs reverse-attrs] 171 | (let [mult #(cond (nil? %1) :zero (:list? %1) :many :else :one)] 172 | (Cardinality. 173 | (Multiplicity. left (:name fwd-attrs) (mult fwd-attrs) (:required? fwd-attrs)) 174 | (Multiplicity. right (:name reverse-attrs) (mult reverse-attrs) (:required? reverse-attrs))))) 175 | 176 | (defn- field-cardinalities [graph edge] 177 | (let [fwd-attrs (attr/attrs graph edge) 178 | reverse-attrs (attr/attrs graph (vec (reverse edge))) 179 | constructor (partial construct-cardinality (edge 0) (edge 1)) 180 | attr-map #(if %1 (merge (%1 1) {:name (%1 0)}))] 181 | (cond 182 | (and (nil? fwd-attrs) (nil? reverse-attrs)) 183 | (throw (js/Error. (format "Internal error: no attrs for edge: %s" edge))) 184 | :else 185 | (map #(construct-cardinality (edge 0) (edge 1) (attr-map (%1 0)) (attr-map (%1 1))) 186 | (map vector fwd-attrs (or reverse-attrs (repeat (count fwd-attrs) nil))))))) 187 | 188 | (defn- unique-edges [graph] 189 | (map vec (distinct (map #(if (every? (partial = (first %1)) %1) %1 (set %1)) (graph/edges graph))))) 190 | 191 | (defn- cardinalities [graph] 192 | (let [edges (unique-edges graph)] 193 | (mapcat (partial field-cardinalities graph) edges))) 194 | 195 | (defn- find-single-relation 196 | ([table-name column-name foreign-table-name foreign-column-name relations] 197 | (find-single-relation table-name column-name foreign-table-name foreign-column-name relations true)) 198 | ([table-name column-name foreign-table-name foreign-column-name relations assert?] 199 | ((if assert? single first) 200 | (filter #(and (= (%1 :table-name) table-name) 201 | (= (%1 :foreign-table-name) foreign-table-name) 202 | (= (%1 :foreign-column-name) foreign-column-name) 203 | (= (%1 :column-name) column-name)) relations)))) 204 | 205 | (defn- get-relation [cardinality relations tables association-tables] 206 | (let [[lcard rcard] [(cardinality :left) (cardinality :right)] 207 | [lentity rentity] [(lcard :entity) (rcard :entity)] 208 | [ltable rtable] [(entity->table-name lentity) (entity->table-name rentity)] 209 | [lrelations rrelations] [(lentity relations) (rentity relations)] 210 | find-single #(apply find-single-relation 211 | (condp = %1 212 | :l->r [ltable (format "%sId" (singularize rtable)) rtable "id" lrelations %2] 213 | :r->l [rtable (format "%sId" (singularize ltable)) ltable "id" rrelations %2]))] 214 | (match [(lcard :multiplicity) (rcard :multiplicity)] 215 | [:one :one] (or (find-single :l->r false) (find-single :r->l true)) 216 | [:one _] (find-single :l->r true) 217 | [_ :one] (find-single :r->l true)))) 218 | -------------------------------------------------------------------------------- /src/speako/consumer.cljs: -------------------------------------------------------------------------------- 1 | ;; Copyright (c) 2016 Jonathan L. Leonard 2 | 3 | (ns speako.consumer 4 | (:require [speako.common :as common] 5 | [speako.schema :as schema] 6 | [clojure.set :refer [subset?] :as set] 7 | [clojure.walk :as walk] 8 | [clojure.string] 9 | [cljs.nodejs :as node])) 10 | 11 | (def ^:private gql (node/require "graphql")) 12 | (def ^:private UnionInputType (node/require "graphql-union-input-type")) 13 | (def ^:private CustomGraphQLDateType (node/require "graphql-custom-datetype")) 14 | 15 | (defprotocol DataResolver 16 | (query [this typename predicate]) 17 | (create [this typename inputs]) 18 | (modify [this typename inputs]) 19 | (delete [this typename id])) 20 | 21 | (def ^:private primitive-types 22 | {"ID" gql.GraphQLID 23 | "Boolean" gql.GraphQLBoolean 24 | "String" gql.GraphQLString 25 | "Float" gql.GraphQLFloat 26 | "Int" gql.GraphQLInt}) 27 | 28 | (def ^:private primitives-set (set (keys primitive-types))) 29 | 30 | (defn GraphQLConsumer [data-resolver] 31 | (let [type-map (atom primitive-types) 32 | inputs-map (atom {}) 33 | fields-map (atom {}) 34 | unions (atom #{}) 35 | enums (atom {})] 36 | (letfn [(get-by-id [typename id] 37 | (query data-resolver typename (js/JSON.stringify #js {"id" id}))) 38 | (get-by-fields [typename fields] 39 | (query data-resolver typename (js/JSON.stringify fields))) 40 | (is-enum? [typ] 41 | (contains? (set (keys @enums)) typ)) 42 | (is-primitive? [typ] 43 | (or (is-enum? typ) (primitives-set typ))) 44 | (modify-type [typ is-list? is-not-null?] 45 | (let [list-comb (if is-list? #(new gql.GraphQLList %) identity) 46 | not-null-comb (if is-not-null? #(new gql.GraphQLNonNull %) identity) 47 | composed (comp not-null-comb list-comb) 48 | res (composed typ)] res)) 49 | (get-type [typ is-list? is-not-null?] 50 | (let [type 51 | (if (= typ "Timestamp") 52 | CustomGraphQLDateType 53 | (get @type-map typ))] 54 | (modify-type type is-list? is-not-null?))) 55 | (get-input-type [typ is-list? is-not-null?] 56 | (if (contains? (set (keys @inputs-map)) typ) 57 | (modify-type (@inputs-map typ) is-list? is-not-null?) 58 | (get-type typ is-list? is-not-null?))) 59 | (get-resolver [datatype is-list? fieldname] 60 | (if (not (is-primitive? datatype)) 61 | {:resolve (fn [parent] 62 | (cond 63 | (= datatype "Timestamp") 64 | (clj->js (aget parent fieldname)) 65 | is-list? 66 | (clj->js (map (fn [id] (get-by-id datatype id)) (aget parent fieldname))) 67 | :else 68 | (get-by-id datatype (aget parent fieldname))))})) 69 | (get-field-spec [[fieldname datatype is-list? is-not-null?]] 70 | (let [typ (get-type datatype is-list? is-not-null?) 71 | resolver (get-resolver datatype is-list? fieldname) 72 | res {fieldname (merge {:type typ} resolver)}] res)) 73 | (input-object-typename [typename] (common/format "%sInput" typename)) 74 | (convert-to-input-object-field [created field] 75 | (let [wrappers (atom '()) 76 | field-type (atom nil)] 77 | (loop [ft (.-type field)] 78 | (if (.-ofType ft) 79 | (do 80 | (if (not= (type ft) gql.GraphQLNonNull) ; all input type fields are optional 81 | (swap! wrappers conj (.-constructor ft))) 82 | (recur (.-ofType ft))) 83 | (reset! field-type ft))) 84 | (cond 85 | (contains? (set (keys created)) @field-type) 86 | (reset! field-type (created @field-type)) 87 | (not (gql.isInputType @field-type)) 88 | (if (contains? @inputs-map (.-name @field-type)) 89 | (reset! field-type (@inputs-map (.-name @field-type))) 90 | (reset! field-type (create-input-object created @field-type)))) 91 | (clj->js {:type (reduce (fn [typ clas] (clas. typ)) @field-type @wrappers)}))) 92 | (create-input-object [created object-type] 93 | (let [cell (atom nil) 94 | fields #(clj->js 95 | (into {} (for [[k v] (js->clj (.getFields object-type))] 96 | [k (convert-to-input-object-field 97 | (assoc created object-type @cell) (clj->js v))]))) 98 | res (gql.GraphQLInputObjectType. 99 | (clj->js {:name (common/format "%s%s" (input-object-typename (.-name object-type)) 100 | (gensym)) 101 | :fields fields}))] 102 | (reset! cell res))) 103 | (extract-field-meta [typename fields] 104 | (first (filter (fn [[k v]] (= (v 0) typename)) fields))) 105 | (check-duplicates [typename duplicates] 106 | (let [duplicate-links 107 | (map #(do [%1 (map first (-> (extract-field-meta %1 @fields-map) second second))]) duplicates) 108 | with-reverse-link 109 | (filter #((set (second %1)) typename) duplicate-links)] 110 | (assert (empty? with-reverse-link) 111 | (common/format "Type '%s' involves duplicate (bidirectional) links to types: %s." 112 | typename (map first with-reverse-link)))))] 113 | (reify schema/TypeConsumer 114 | (consume-object [_ typename field-descriptors] 115 | (let [field-specs (map get-field-spec field-descriptors) 116 | fieldnames (map first field-descriptors) 117 | non-primitives (remove is-primitive? (map #(%1 1) field-descriptors)) 118 | duplicates (common/duplicates non-primitives) 119 | merged (delay (do (check-duplicates typename duplicates) (apply merge field-specs))) 120 | descriptors {:name typename :fields #(clj->js @merged)} 121 | res (gql.GraphQLObjectType. (clj->js descriptors))] 122 | (assert (not (contains? @type-map typename)) 123 | (common/format "Duplicate type name: %s" typename)) 124 | (assert (not (contains? @fields-map fieldnames)) 125 | (common/format "Duplicate field set: %s" (common/pprint-str fieldnames))) 126 | (assert (contains? (set fieldnames) "id") 127 | (common/format "Type must contain an 'id' field. Fields: %s" 128 | (common/pprint-str fieldnames))) 129 | (swap! type-map assoc typename res) 130 | (swap! fields-map assoc fieldnames [typename (map rest field-descriptors)]) 131 | (js/console.log (common/format "Created object type thunk: %s" typename)) 132 | (swap! inputs-map assoc typename #(create-input-object {} res)) 133 | (js/console.log (common/format "Created input object type thunk: %s for type: %s" 134 | (input-object-typename typename) typename)))) 135 | (consume-union [_ typename constituents] 136 | (let [types (map #(get @type-map %) constituents) 137 | constituent-set (set constituents) 138 | constituent-fields (fn [fields] (filter #(constituent-set ((%1 1) 0)) fields)) 139 | descriptor {:name typename :types types 140 | :resolveType 141 | (fn [value] 142 | (let [fields (set (keys (first (js->clj value)))) 143 | fld-map (map #(do [(set (%1 0)) ((%1 1) 0)]) (constituent-fields @fields-map)) 144 | counts (map #(do [(count (clojure.set/intersection fields (%1 0))) (%1 1)]) fld-map) 145 | sorted (sort-by first > counts) 146 | type (second (first sorted))] 147 | (get @type-map type)))} 148 | res (gql.GraphQLUnionType. (clj->js descriptor))] 149 | (swap! type-map assoc typename res) 150 | (swap! unions conj typename) 151 | (js/console.log (common/format "Created union type: %s: descriptor: %s" typename descriptor)) 152 | (swap! inputs-map assoc typename 153 | #(UnionInputType 154 | (clj->js {:name (input-object-typename typename) 155 | :resolveTypeFromAst 156 | (fn [ast-js] 157 | (let [ast (js->clj ast-js :keywordize-keys true) 158 | fields (set (map (comp :value :name) (:fields ast))) 159 | fld-map (map (fn [r] [(set (r 0)) ((r 1) 0)]) (constituent-fields @fields-map)) 160 | matches (filter (fn [f] (subset? fields (f 0))) fld-map) 161 | _ (assert (= 1 (count matches)) 162 | "Union input field names must uniquely identify intended target.") 163 | union-type (second (first matches))] 164 | (get-input-type union-type false false)))}))) 165 | (js/console.log (common/format "Created union input object type thunk: %s for type: %s" 166 | (input-object-typename typename) typename)) 167 | [res descriptor])) 168 | (consume-enum [_ typename constituents] 169 | (let [descriptor {:name typename :values (apply merge constituents)} 170 | res (gql.GraphQLEnumType. (clj->js descriptor))] 171 | (swap! type-map assoc typename res) 172 | (swap! enums assoc typename constituents) 173 | (js/console.log (common/format "Created enum type: %s: descriptor: %s" typename descriptor)) 174 | [res descriptor])) 175 | (finished [_] 176 | (letfn [(get-query-descriptors [typ] 177 | (let [[field-names field-meta] (extract-field-meta typ @fields-map) 178 | zipped (map vector field-names (second field-meta)) 179 | with-types (map (fn [[k v]] [k (get-input-type (nth v 0) (nth v 1) false)]) zipped) 180 | args (apply merge (map #(do {(keyword (%1 0)) {:type (%1 1)}}) with-types)) 181 | res 182 | [{typ 183 | {:type (gql.GraphQLList. (get @type-map typ)) 184 | :args args 185 | :resolve (fn [root obj] (clj->js (get-by-fields typ obj)))}} 186 | {(common/pluralize typ) 187 | {:type (gql.GraphQLList. (get @type-map typ)) 188 | :resolve (fn [root] (query data-resolver typ (js/JSON.stringify #js {"all" true})))}}]] 189 | (common/dbg-print "Query descriptors for typename: %s: %s" typ res) res)) 190 | (get-args [typ req-mod?] 191 | (letfn [(get-ref-type [typ] 192 | (cond (is-enum? typ) (get @type-map typ) 193 | :else (get-input-type typ false false))) 194 | (get-mutation-arg-type [[typ is-list? is-non-null?]] 195 | (modify-type (or (get primitive-types typ) (get-ref-type typ)) 196 | is-list? (if req-mod? is-non-null? false)))] 197 | (let [kvs (seq @fields-map) 198 | kv (common/single (filter #(= (first (second %)) typ) kvs)) 199 | pairs (partition 2 (interleave (first kv) (second (second kv)))) 200 | sans-id (remove #(= (first %) "id") pairs) 201 | res (map (fn [pair] 202 | {(first pair) {:type (get-mutation-arg-type (second pair))}}) sans-id)] res))) 203 | (get-mutations [typ] 204 | (letfn [(get-mutation [prefix resolver req-mod? get-args? transform args] 205 | {(common/format "%s%s" prefix typ) 206 | {:type (get @type-map typ) 207 | :args (into args (if get-args? (get-args typ req-mod?) {})) 208 | :resolve (fn [root obj] (resolver data-resolver typ (transform obj)))}})] 209 | (let [res [(get-mutation "create" create true true identity {}) 210 | (get-mutation "update" modify false true identity 211 | {:id {:type (gql.GraphQLNonNull. gql.GraphQLID)}}) 212 | (get-mutation "delete" delete true true identity 213 | {:id {:type gql.GraphQLID}})]] 214 | (common/dbg-print "Mutation descriptors for typename: %s: %s" typ res) res))) 215 | (create-obj-type [name fields] 216 | (let [descriptor {:name name :fields (apply merge (flatten fields))} 217 | res (gql.GraphQLObjectType. (clj->js descriptor))] 218 | (common/dbg-print "Created GraphQLObjectType: %s" descriptor) res))] 219 | (let [types (set/difference (set (keys @type-map)) primitives-set (set (keys @enums)))] 220 | (reset! inputs-map (into {} (for [[k v] @inputs-map] [k (v)]))) 221 | (gql.GraphQLSchema. 222 | (clj->js 223 | {:query (create-obj-type "RootQuery" (map get-query-descriptors types)) 224 | :mutation 225 | (create-obj-type "RootMutation" (map get-mutations (set/difference types @unions)))}))))))))) 226 | --------------------------------------------------------------------------------