├── .github └── workflows │ └── test.yml ├── .gitignore ├── README.md ├── docs ├── config.md ├── development.md ├── images │ ├── fk-transform.drawio │ └── fk-transform.png ├── interceptor.md ├── mechanism.md ├── performance.md └── sql_feature.md ├── project.clj ├── src └── phrag │ ├── context.clj │ ├── core.clj │ ├── db │ ├── adapter.clj │ ├── core.clj │ ├── postgres.clj │ └── sqlite.clj │ ├── field.clj │ ├── logging.clj │ ├── resolver.clj │ ├── route.clj │ └── table.clj └── test └── phrag ├── core_test.clj ├── graphql_test.clj └── table_test.clj /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | on: [push] 3 | 4 | jobs: 5 | run-test: 6 | strategy: 7 | matrix: 8 | # os: [ubuntu-latest, macOS-latest, windows-latest] 9 | java: ['8', '11'] 10 | runs-on: ubuntu-latest 11 | 12 | services: 13 | postgres: 14 | image: postgres 15 | env: 16 | POSTGRES_PASSWORD: postgres 17 | options: >- 18 | --health-cmd pg_isready 19 | --health-interval 10s 20 | --health-timeout 5s 21 | --health-retries 5 22 | ports: 23 | - 5432:5432 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v2 28 | 29 | - name: Prepare java 30 | uses: actions/setup-java@v2 31 | with: 32 | distribution: 'zulu' 33 | java-version: ${{ matrix.java }} 34 | 35 | - name: Install clojure tools 36 | uses: DeLaGuardo/setup-clojure@3.5 37 | with: 38 | lein: 'latest' 39 | 40 | - name: Run tests 41 | run: lein cloverage -n 'phrag.*' 42 | env: 43 | DB_NAME: postgres 44 | DB_HOST: localhost 45 | DB_USER: postgres 46 | DB_PASS: postgres 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /logs 3 | /classes 4 | /checkouts 5 | pom.xml 6 | pom.xml.asc 7 | *.jar 8 | *.class 9 | /.lein-* 10 | /.nrepl-port 11 | /.dir-locals.el 12 | /profiles.clj 13 | /dev/resources/local.edn 14 | /dev/src/local.clj 15 | /db/dev.sqlite 16 | *.DS_Store 17 | *.clj~ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phrag 2 | 3 | **GraphQL API from an RDBMS Connection** 4 | 5 | Phrag implements its [approach](docs/mechanism.md) to creating GraphQL from an RDBMS connection for instant, flexible and customizable CRUD operations. 6 | 7 | ![main](https://github.com/ykskb/phrag/actions/workflows/test.yml/badge.svg) [![Clojars Project](https://img.shields.io/clojars/v/com.github.ykskb/phrag.svg)](https://clojars.org/com.github.ykskb/phrag) [![cljdoc badge](https://cljdoc.org/badge/com.github.ykskb/phrag)](https://cljdoc.org/d/com.github.ykskb/phrag) 8 | 9 | ## Overview 10 | 11 | - **Instantly Operational:** Phrag creates a GraphQL simply from a RDBMS connection, retrieving schema data of tables, columns, primary keys and foreign keys. It can run as part of a Clojure project or [stand-alone releases](#stand-alone-releases). 12 | 13 | - **CRUD Features:** tables and/or views become queryable as root objects including nested objects of [n-ary relationships](docs/mechanism.md#relationships) with [aggregation](docs/sql_feature.md#aggregation), [filter](docs/sql_feature.md#filtering), [sorting](docs/sql_feature.md#sorting) and [pagination](docs/sql_feature.md#pagination) supported. [Mutations](docs/mechanism.md#mutations) (`create`, `update` and `delete`) are also created per table. 14 | 15 | - **Customization:** Phrag comes with an [interceptor capability](docs/interceptor.md) to customize behaviors of GraphQL. Custom functions can be configured per table & operation type and at pre/post DB operations. It can make a GraphQL service more practical with access controls, event firing and more. 16 | 17 | - **Performance in Mind:** Phrag's query resolver translates a nested object query into a single SQL query, leveraging correlated subqueries and JSON functions. [Load tests](docs/performance.md) have also been performed to verify it scales linear with resources without obvious bottlenecks. 18 | 19 | - **Practicality in Mind:** Phrag was developed side by side with a [POC project](#poc-project) to verify its concept and validate the practicality. 20 | 21 | ## Requirements 22 | 23 | Phrag only requires an RDBMS to create its GraphQL API. Here's a quick view of database constructs that are important for Phrag. Detailed mechanism is explained [here](docs/mechanism.md). 24 | 25 | - **Primary keys:** Phrag uses primary keys as identifiers of GraphQL mutations. Composite primary key is supported. 26 | 27 | - **Foreign keys:** Phrag translates foreign keys to nested properties in GraphQL query objects. 28 | 29 | - **Indices on foreign key columns:** Phrag queries a database by both origin and destination columns of foreign keys for nested objects. It should be noted that creating a foreign key does not always index those columns (especially origin column). 30 | 31 | > **Notes:** 32 | > 33 | > - Supported databases are SQLite and PostgreSQL. 34 | > 35 | > - If PostgreSQL is used, Phrag queries usage tables such as `key_column_usage` and `constraint_column_usage` to retrieve PK / FK info, therefore a database user provided to Phrag needs to be identical to the one that created those keys. 36 | > 37 | > - Not all database column types are mapped to Phrag's GraphQL fields yet. Any help would be appreciated through issues and PRs. 38 | 39 | ## Usage 40 | 41 | ### Clojure Project 42 | 43 | Phrag's GraphQL can be created with `phrag.core/schema` function and invoked through `phrag.core/exec` function: 44 | 45 | ```clojure 46 | (let [config {:db (hikari/make-datasource my-spec)} 47 | schema (phrag/schema config)] 48 | (phrag/exec config schema query vars req)) 49 | ``` 50 | 51 | There is also a support for creating Phrag's GraphQL as a route for [reitit](https://github.com/metosin/reitit) or [Bidi](https://github.com/juxt/bidi): 52 | 53 | ```clojure 54 | ;; Add a route (path & handler) into a ring router: 55 | (ring/router (phrag.route/reitit {:db my-datasource}) 56 | 57 | ;; Also callable as an Integrant config map key 58 | {:phrag.route/reitit {:db (ig/ref :my/datasource)}} 59 | ``` 60 | 61 | > **Notes:** 62 | > 63 | > Database (`:db`) is the only required parameter in `config`, but there are many more configurable options. Please refer to [configuration doc](docs/config.md) for details. 64 | 65 | ### Stand-alone Releases 66 | 67 | There is a stand-alone version of Phrag which is runnable as a Docker container or Java process with a single command. It's suitable if Phrag's GraphQL is desired without any custom logic or if one wants to play around with it. [Here](https://github.com/ykskb/phrag-standalone) is the repository of those artifacts for more details. 68 | 69 | Try it out as a Docker container with a [self-contained DB](https://github.com/ykskb/phrag-standalone/blob/main/db/meetup_project.sql): 70 | 71 | ```sh 72 | docker run -it -p 3000:3000 ykskb/phrag-standalone:latest 73 | # visit http://localhost:3000/graphiql/index.html 74 | ``` 75 | 76 | Run as a Docker container with your SQLite: 77 | 78 | ```sh 79 | docker run -it -p 3000:3000 \ 80 | -v /host/db/dir:/database \ # mount a directory of your database 81 | -e JDBC_URL=jdbc:sqlite:/database/db.sqlite \ # specify DB URL 82 | ykskb/phrag-standalone:latest 83 | # visit http://localhost:3000/graphiql/index.html 84 | ``` 85 | 86 | ## Documentation 87 | 88 | - [Mechanism](docs/mechanism.md) 89 | 90 | - [Configuration](docs/config.md) 91 | 92 | - [Interceptors](docs/interceptor.md) 93 | 94 | - [SQL Features](docs/sql_feature.md) 95 | 96 | - [Performance](docs/performance.md) 97 | 98 | - [Development](docs/development.md) 99 | 100 | ### POC Project: 101 | 102 | - [SNS](https://github.com/ykskb/situated-sns-backend): a situated project of Twitter mock to verify Phrag's concept and practicality. It has authentication, access control and custom logics through Phrag's interceptors, leveraging Phrag's GraphQL for queries with many nests and conditions. 103 | 104 | ## Contribution to Phrag 105 | 106 | Please feel free to open Github issues to send suggestions, report bugs or discuss features. PRs are also welcomed. 107 | 108 | Copyright © 2021 Yohei Kusakabe 109 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Though there are multiple options for customization, the only config parameter required for Phrag is a database connection. 4 | 5 | ### Parameters 6 | 7 | | Key | description | Required | Default Value | 8 | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------- | 9 | | `:db` | Database connection (`{:connection object}`) or data source object (`{:datasource object}`). [Hikari-CP datasource](https://github.com/tomekw/hikari-cp) is much more performant than a JDBC connection especially under loads. | Yes | | 10 | | `:tables` | List of custom table definitions. Plz check [Schema Data](#schema-data) for details. | No | | 11 | | `:signals` | Map of singal functions per table, operation and timing. Plz check [Interceptor Signals](interceptor.md) for details. | No | | 12 | | `:signal-ctx` | Additional context to be passed into signal functions. Plz check [Interceptor Signals](interceptor.md) for details. | No | | 13 | | `:default-limit` | Default number for SQL `LIMIT` value to be applied when there's no `:limit` argument is specified in a query. | No | `nil` | 14 | | `:max-nest-level` | Maximum nest level allowed. This is to avoid infinite nesting. Errors will be returned when nests in requests exceed the value. | No | `nil` | 15 | | `:use-aggregation` | `true` if aggregation is desired on root entity queries and has-many relationships. | No | `true` | 16 | | `:scan-tables` | `true` if DB schema scan is desired for tables in GraphQL. | No | `true` | 17 | | `:scan-views` | `true` if DB schema scan is desired for views in GraphQL. | No | `true` | 18 | | `:graphql-path` | Path for Phrag's GraphQL when a route is desired through `phrag.route` functions. | No | `/graphql` | 19 | 20 | #### Schema Data 21 | 22 | By default, Phrag retrieves DB schema data from a DB connection and it is sufficient to construct GraphQL. Yet it is also possible to provide custom schema data, which can be useful to exclude certain tables, columns and/or relationships from specific tables. Custom schema data can be specified as a list of tables under `:tables` key in the config map. 23 | 24 | ```edn 25 | {:tables [ 26 | {:name "users" 27 | :columns [{:name "id" 28 | :type "int" 29 | :notnull 0 30 | :dflt_value nil} 31 | {:name "image_id" 32 | :type "int" 33 | :notnull 1 34 | :dflt_value 1} 35 | ;; ... more columns 36 | ] 37 | :fks [{:table "images" :from "image_id" :to "id"}] 38 | :pks [{:name "id" :type "int"}]} 39 | ;; ... more tables 40 | ]} 41 | ``` 42 | 43 | #### Table Data Details: 44 | 45 | | Key | Description | 46 | | ---------- | ------------------------------------------------------------------------------------------------ | 47 | | `:name` | Table name. | 48 | | `:columns` | List of columns. A column can contain `:name`, `:type`, `:notnull` and `:dflt_value` parameters. | 49 | | `:fks` | List of foreign keys. A foreign key can contain `:table`, `:from` and `:to` parameters. | 50 | | `:pks` | List of primary keys. A primary key can contain `:name` and `:type` parameters. | 51 | 52 | > Notes: 53 | > 54 | > - When `:scan-schema` is `false`, Phrag will construct GraphQL from the provided table data only. 55 | > - When `:scan-schema` is `true`, provided table data will override scanned table data per table property: `:name`, `:columns`, `:fks` and `:pks`. 56 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ### Test 4 | 5 | Run tests with coverage: 6 | 7 | ```sh 8 | lein cloverage -n 'phrag.*' 9 | ``` 10 | 11 | Tests run on in-memory SQLite DB by default. If environment variables for PostgreSQL DB are provided, GraphQL tests run on both SQlite and PostgreSQL DBs. 12 | 13 | Example: 14 | 15 | ```sh 16 | DB_NAME=my_db DB_HOST=localhost DB_USER=postgres DB_PASS=my_pass lein cloverage -n 'phrag.*' 17 | ``` 18 | -------------------------------------------------------------------------------- /docs/images/fk-transform.drawio: -------------------------------------------------------------------------------- 1 | 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 -------------------------------------------------------------------------------- /docs/images/fk-transform.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ykskb/phrag/8204cfb4d30350627c6bb1ce83a10266f6fc497c/docs/images/fk-transform.png -------------------------------------------------------------------------------- /docs/interceptor.md: -------------------------------------------------------------------------------- 1 | # Interceptor Signals 2 | 3 | Phrag can signal configured functions per table & operation type at pre/post DB operation time. This is where things like access controls or custom business logics can be configured. Signal functions are called with different parameters as below: 4 | 5 | #### Pre-operation Interceptor Function 6 | 7 | | Type | Signal function receives (as first parameter): | Returned value will be: | 8 | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | 9 | | `query` | Lacinia's [selection map](https://walmartlabs.github.io/apidocs/lacinia/com.walmartlabs.lacinia.selection.html) including fields such as `:arguments` and `:selections`. | Passed to subsequent query operation. | 10 | | `create` | Submitted mutation parameters | Passed to subsequent create operation. | 11 | | `update` | Submitted mutation parameters | Passed to subsequent update operation. | 12 | | `delete` | Submitted mutation parameters | Passed to subsequent delete operation. | 13 | 14 | > Notes: 15 | > 16 | > - `query` signal functions for matching table will be called in nested queries (relations) as well. 17 | 18 | #### Post-operation Interceptor Function 19 | 20 | | Type | Signal function receives (as a first parameter): | Returned value will be: | 21 | | -------- | -------------------------------------------------- | ------------------------ | 22 | | `query` | Result values returned from `query` operation. | Passed to response body. | 23 | | `create` | Primary key object of created item: e.g. `{:id 3}` | Passed to response body. | 24 | | `update` | Result object: `{:result true}` | Passed to response body. | 25 | | `delete` | Result object: `{:result true}` | Passed to response body. | 26 | 27 | #### Signal Context Map 28 | 29 | All receiver functions will have a context map as its second argument. It'd contain a signal context specified in a Phrag config (`:signal-ctx`) together with a DB connection (`:db`) and an incoming HTTP request (`:req`). 30 | 31 | ### Examples 32 | 33 | ```clojure 34 | (defn- end-user-access 35 | "Users can query only his/her own user info" 36 | [selection ctx] 37 | (let [user (user-info (:req ctx))] 38 | (if (admin-user? user)) 39 | selection 40 | (update-in selection [:arguments :where :user_id] {:eq (:id user)}))) 41 | 42 | (defn- hide-internal-id 43 | "Removes internal-id for non-admin users" 44 | [result ctx] 45 | (let [user (user-info (:req ctx))] 46 | (if (admin-user? user)) 47 | result 48 | (update result :internal-id nil))) 49 | 50 | (defn- update-owner 51 | "Updates created_by with accessing user's id" 52 | [args ctx] 53 | (let [user (user-info (:req ctx))] 54 | (if (end-user? user) 55 | (assoc args :created_by (:id user)) 56 | args))) 57 | 58 | ;; Multiple signal function can be specified as a vector. 59 | 60 | (def example-config 61 | {:signals {:all [check-user-auth check-user-role] 62 | :users {:query {:pre end-user-access 63 | :post hide-internal-id} 64 | :create {:pre update-owner} 65 | :update {:pre update-owner}}}}) 66 | ``` 67 | 68 | > Notes: 69 | > 70 | > - `:all` can be used at each level of signal map to run signal functions across all tables, all operations for a table, or both timing for a specific operation. 71 | -------------------------------------------------------------------------------- /docs/mechanism.md: -------------------------------------------------------------------------------- 1 | # Mechanism 2 | 3 | There are several projects out there for GraphQL automation on RDBMS. Among them, Phrag focuses on keeping itself simple while providing full CRUD capabilities from a DB provided. 4 | 5 | ## Database 6 | 7 | Phrag creates its GraphQL API from an existing RDBMS. It does not deal with DB management such as model definitions or migrations. 8 | 9 | ## Queries 10 | 11 | All or selected tables / views become queryable as root objects including nested objects of n-ary relationships in Phrag. This is for flexible data accesses without being constrained to certain query structures defined in GraphQL schema. Data can be accessed at the root level or as a nested object together with parent objects through the relationships. 12 | 13 | In terms of query format, Phrag does not use a [cursor connection](https://relay.dev/graphql/connections.htm). This is an intentional design decision since Phrag features a universal argument format across root level and nested objects for filtering, aggregation and pagination. 14 | 15 | ### Relationships 16 | 17 | Phrag transforms a foreign key constraint into nested query objects of GraphQL as illustrated in the diagram below. This is a fundamental concept for Phrag to support multiple types of n-ary relationships: 18 | 19 | 20 | 21 | Also Phrag does not treat `many-to-many` relationships specially by skipping bridge table or in any other way. This is to keep Phrag's GraphQL simple by following what a database represents. 22 | 23 | ### SQL Queries 24 | 25 | N+1 problem is an anti-pattern where a relationship query is executed for every one of retrieved records. Phrag's query resolver translates nested query objects into a single SQL query, leveraging lateral join / correlated subqueries with JSON functions. 26 | 27 | ## Mutations 28 | 29 | `Create`, `update` and `delete` mutations are created for each table. Primary keys work as an identitier of each record for mutations: 30 | 31 | 1. Phrag registers PK(s) of a table as a GraphQL object. 32 | 33 | 2. `Create` mutation returns a PK object with generated values as a successful response. 34 | 35 | 3. `Update` or `delete` mutation requires the PK object as a parameter to identify the record for the operations. 36 | 37 | ## Security 38 | 39 | - **Infinite nests:** nested objects created for both origin and destination columns of foreign keys actually mean possible infinite nests, and it is possibly an attack surface when a user queries millions of nests. Phrag has a [config](config.md) value, `max-nest-level` for this, and an error response will be returned when a query exceed the nest level specified. 40 | 41 | - **Default limit:** querying millions of records can be resource-intensive and we don't want it to happen accidentally. [Config](config.md) value of `default-limit` can be used to apply default limit value when there's no `limit` parameter specified in a query. 42 | -------------------------------------------------------------------------------- /docs/performance.md: -------------------------------------------------------------------------------- 1 | # Performance 2 | 3 | ## Load Test 4 | 5 | Load test repository: [phrag-perf](https://github.com/ykskb/phrag-perf) 6 | 7 | > Note: 8 | > This documentation page is mainly about Phrag's GraphQL `query` since there are several varying factors for its performnace measurement. On the other hand, Phrag's `mutations` are atomic operations and simpler to be estimated, so its result is put as a [reference data](#reference-data) below. 9 | 10 | ### Objectives 11 | 12 | Load tests were performed to: 13 | 14 | - Get some benchmarks for simple resource setups. 15 | (Stringent VM/GC/OS/DB tuning was not in the scope.) 16 | 17 | - Verify there's no obvious bottleneck and performance improves more or less linear with additional resources. 18 | 19 | - Compare performance of different resolver models: subquery model vs query-per-nest model vs bucket queue model. 20 | 21 | ### Measurement 22 | 23 | While each user is constantly sending a request every `2s`, how many users can Phrag serve within `500ms` ? 24 | 25 | - Duration: `60s` with 2 stages: 26 | - `30s` of ramping up to a target number of users. 27 | - `30s` of staying at a target number of users. 28 | - Metrics used is `http_req_duration` for `p95`. 29 | - HTTP error & response error rate must be less than `1%`. 30 | 31 | ### Tests 32 | 33 | - 3 GraphQL queries with different nest levels were tested to see performance difference of additional data retrieval. 34 | 35 | - All tables had roughly `100,000` records created beforehand. 36 | 37 | - All queries had `limit`, `offset` and `where` (filter on `id`) for testing practical query planning. 38 | 39 | - Each test was performed with `limit: 50` and `limit: 100` to see performance difference of serialization workload. 40 | 41 | - Parameters of `$offset` and `$id_gt` for pagination and filter were randomized between `0` to `100,000` for each request. 42 | 43 | Query with no nest: 44 | 45 | ```graphql 46 | query queryVenues($limit: Int!, $offset: Int!, $id_gt: Int!) { 47 | venues(limit: $limit, offset: $offset, where: { id: { gt: $id_gt } }) { 48 | id 49 | name 50 | postal_code 51 | } 52 | } 53 | ``` 54 | 55 | Query with 1 nest of `has-many`: 56 | 57 | ```graphql 58 | query queryVenueMeetups($limit: Int!, $offset: Int!, $id_gt: Int!) { 59 | venues(limit: $limit, offset: $offset, where: { id: { gt: $id_gt } }) { 60 | id 61 | name 62 | postal_code 63 | meetups(limit: $limit, sort: { id: desc }) { 64 | id 65 | title 66 | } 67 | } 68 | } 69 | ``` 70 | 71 | Query with 2 nests of `has-many` and `has-one` (often referred as `many-to-many` relationship): 72 | 73 | ```graphql 74 | query queryMeetupsWithMembers($limit: Int!, $offset: Int!, $id_gt: Int!) { 75 | meetups(limit: $limit, offset: $offset, where: { id: { gt: $id_gt } }) { 76 | id 77 | title 78 | meetups_members(limit: $limit) { 79 | member { 80 | id 81 | email 82 | } 83 | } 84 | } 85 | } 86 | ``` 87 | 88 | ### Setup 89 | 90 | #### Application 91 | 92 | - Server: Jetty (Ring) 93 | 94 | - Router: reitit 95 | 96 | #### Computation / storage resources 97 | 98 | - Platform: AWS ECS Single Task Container 99 | - `1vCPU + 4GB RAM` 100 | - `2vCPU + 8GB RAM` 101 | - Database: AWS RDS PostgreSQL 102 | - `2vCPU + 1GB RAM` (Free-tier of `db.t3.micro`) 103 | 104 | \*Both resources were set up in a single availability zone. 105 | 106 | #### Request Client 107 | 108 | Home computer setup with Mac Studio was used to send requests from the same region as server setups. 109 | 110 | ### Results 111 | 112 | #### `1vCPU + 4GB RAM` 113 | 114 | Limit: `50` 115 | 116 | | Query | MAX VUs | Reqs/s | p(95) | Min | Max | Reqs | 117 | | ------- | ------- | ------ | ----- | --- | ----- | ----- | 118 | | No nest | 1300 | 442 | 427ms | 7ms | 845ms | 27510 | 119 | | 1 nest | 800 | 273 | 406ms | 7ms | 889ms | 17003 | 120 | | 2 nests | 700 | 240 | 427ms | 9ms | 991ms | 15068 | 121 | 122 | Limit: `100` 123 | 124 | | Query | MAX VUs | Reqs/s | p(95) | Min | Max | Reqs | 125 | | ------- | ------- | ------ | ----- | --- | ----- | ----- | 126 | | No nest | 900 | 316 | 308ms | 7ms | 781ms | 19643 | 127 | | 1 nest | 400 | 144 | 204ms | 8ms | 474ms | 8918 | 128 | | 2 nests | 400 | 143 | 219ms | 9ms | 685ms | 8908 | 129 | 130 | #### `2vCPU + 8GB RAM` 131 | 132 | Limit: `50` 133 | 134 | | Query | MAX VUs | Reqs/s | p(95) | Min | Max | Reqs | 135 | | ------- | ------- | ------ | ----- | --- | ----- | ----- | 136 | | No nest | 2500 | 824 | 454ms | 6ms | 843ms | 51352 | 137 | | 1 nest | 1400 | 490 | 355ms | 7ms | 837ms | 30427 | 138 | | 2 nests | 1300 | 451 | 354ms | 9ms | 926ms | 28053 | 139 | 140 | Limit: `100` 141 | 142 | | Query | MAX VUs | Reqs/s | p(95) | Min | Max | Reqs | 143 | | ------- | ------- | ------ | ----- | --- | ----- | ----- | 144 | | No nest | 1800 | 600 | 444ms | 6ms | 943ms | 37364 | 145 | | 1 nest | 1000 | 331 | 499ms | 8ms | 893ms | 20610 | 146 | | 2 nests | 700 | 240 | 383ms | 9ms | 874ms | 14919 | 147 | 148 | ### Observations 149 | 150 | #### Resource allocation 151 | 152 | Performance seems to have improved roughly linear with the additional resource allocation overall. 153 | 154 | #### Nest levels 155 | 156 | Considering additional subqueries and serialization required, `30%` to `40%` less performance per a nest level seems sensible. It was also observed that querying nested objects for `has-many` relationship affected performance more than `has-one` relationship, which possibly indicates serialization and validation of retrieved records is the factor for more latency. 157 | 158 | #### Resolver Models 159 | 160 | As explained in [mechanism](./mechanism.md), Phrag translates nested GraphQL queries into a single SQL, leveraging correlated subqueries and JSON functions. This model was compared against other possible models as below: 161 | 162 | - **SQL-per-nest Model**: a model of issueing a DB query per a nest level in this [branch](https://github.com/ykskb/phrag/tree/sql-per-nest-version) was actually an original idea for Phrag's resolver. Though this model fires DB queries more frequently, it was observed nearly as performant as subquery model with slightly less load on DB's CPU. Yet subquery model was chosen over this model since it performed slightly better and SQL-per-nest model is more suceptible to DB connection overhead, depending on environment setups. Performance measured for SQL-per-nest model can be found in [reference data](#sql-per-nest-model) below. 163 | 164 | - **Bucket Queue Model**: bucket queue model with [Superlifter](https://github.com/oliyh/superlifter) in this [branch](https://github.com/ykskb/phrag/tree/superlifter-version) was tested for comparison. The idea is to use buckets for firing batched SQL queries per a nest level. Though results are not included in this page, a model of resolving nested data by directly going through a query graph was more performant. Adding queues and resolving them through Promise (CompletableFuture) seemed to have some overhead. 165 | 166 | ### Reference Data 167 | 168 | #### Mutations 169 | 170 | `Create` mutation was measured as below: 171 | 172 | ##### `1vCPU + 4GB RAM` 173 | 174 | | MAX VUs | Reqs/s | p(95) | Min | Max | Reqs | 175 | | ------- | ------ | ----- | --- | ----- | ----- | 176 | | 1500 | 926 | 394ms | 7ms | 667ms | 56689 | 177 | 178 | #### SQL-per-nest Model 179 | 180 | ##### `1vCPU + 4GB RAM` 181 | 182 | Limit: `50` 183 | 184 | | Query | MAX VUs | Reqs/s | p(95) | Min | Max | Reqs | 185 | | ------- | ------- | ------ | ----- | --- | ----- | ----- | 186 | | No nest | 1300 | 442 | 427ms | 7ms | 845ms | 27510 | 187 | | 1 nest | 700 | 237 | 461ms | 9ms | 860ms | 14750 | 188 | | 2 nests | 500 | 175 | 326ms | 8ms | 758ms | 10919 | 189 | 190 | Limit: `100` 191 | 192 | | Query | MAX VUs | Reqs/s | p(95) | Min | Max | Reqs | 193 | | ------- | ------- | ------ | ----- | ---- | ----- | ----- | 194 | | No nest | 900 | 313 | 395ms | 8ms | 895ms | 19515 | 195 | | 1 nest | 400 | 143 | 257ms | 10ms | 703ms | 8872 | 196 | | 2 nests | 300 | 106 | 274ms | 10ms | 599ms | 6602 | 197 | 198 | ##### `2vCPU + 8GB RAM` 199 | 200 | Limit: `50` 201 | 202 | | Query | MAX VUs | Reqs/s | p(95) | Min | Max | Reqs | 203 | | ------- | ------- | ------ | ----- | --- | ------ | ----- | 204 | | No nest | 1900 | 662 | 353ms | 6ms | 697ms | 41174 | 205 | | 1 nest | 1400 | 477 | 365ms | 7ms | 721ms | 29668 | 206 | | 2 nests | 1200 | 402 | 447ms | 8ms | 1069ms | 25088 | 207 | 208 | Limit: `100` 209 | 210 | | Query | MAX VUs | Reqs/s | p(95) | Min | Max | Reqs | 211 | | ------- | ------- | ------ | ----- | ---- | ----- | ----- | 212 | | No nest | 2000 | 669 | 489ms | 7ms | 846ms | 41693 | 213 | | 1 nest | 900 | 316 | 291 | 10ms | 663ms | 19695 | 214 | | 2 nests | 700 | 246 | 294ms | 9ms | 774ms | 14919 | 215 | -------------------------------------------------------------------------------- /docs/sql_feature.md: -------------------------------------------------------------------------------- 1 | # SQL Features 2 | 3 | ## Aggregation 4 | 5 | `avg`, `count`, `max`, `min` and `sum` are supported and it can also be [filtered](#filtering). 6 | 7 | ##### Example: 8 | 9 | _Select `count` of `cart_items` together with `max`, `min` `sum` and `avg` of `price` where `cart_id` is `1`._ 10 | 11 | ``` 12 | cart_items_aggregate (where: {cart_id: {eq: 1}}) {count max {price} min {price} avg {price} sum {price}} 13 | ``` 14 | 15 | ## Filtering 16 | 17 | Parameters should be placed under query arguments as below: 18 | 19 | ``` 20 | where: {column-a: {operator: value} column-b: {operator: value}} 21 | ``` 22 | 23 | `AND` / `OR` group can be created as clause lists in `and` / `or` parameter under `where`. It is possible to nest them to create sub group of conditions such as `((a=1 AND b=2) OR (a=3 AND b=4))`. 24 | 25 | > - Supported operators are `eq`, `ne`, `gt`, `lt`, `gte`, `lte`, `in` and `like`. 26 | > - Multiple filters are applied with `AND` operator. 27 | 28 | ##### Example: 29 | 30 | _`Users` where `name` is `like` `ken` `AND` `age` is `20` `OR` `21`._ 31 | 32 | ``` 33 | {users (where: {name: {like: "%ken%"} or: [{age: {eq: 20}}, {age: {eq: 21}}]})} 34 | ``` 35 | 36 | ## Sorting 37 | 38 | Parameters should be placed under query arguments as below: 39 | 40 | ``` 41 | sort: {[column]: [asc or desc]} 42 | ``` 43 | 44 | ##### Example: 45 | 46 | _Sort by `id` column in ascending order._ 47 | 48 | ``` 49 | sort: {id: asc} 50 | ``` 51 | 52 | ## Pagination 53 | 54 | Parameters should be placed under query arguments as below: 55 | 56 | ``` 57 | limit: [count] 58 | offset: [count] 59 | ``` 60 | 61 | > - `limit` and `offset` can be used independently. 62 | > - Using `offset` can return different results when new entries are created while items are sorted by newest first. So using `limit` with `id` filter or `created_at` filter is often considered more consistent. 63 | 64 | ##### Example: 65 | 66 | _25 items after/greater than `id`:`20`_ 67 | 68 | ``` 69 | (where: {id: {gt: 20}} limit: 25) 70 | ``` 71 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject com.github.ykskb/phrag "0.4.6" 2 | :description "GraphQL from a DB connection" 3 | :url "https://github.com/ykskb/phrag" 4 | :min-lein-version "2.0.0" 5 | :dependencies [[org.clojure/clojure "1.10.3"] 6 | [org.clojure/java.jdbc "0.7.12"] 7 | [org.clojure/tools.logging "0.3.1"] 8 | [org.postgresql/postgresql "42.3.0"] 9 | [org.xerial/sqlite-jdbc "3.34.0"] 10 | [com.github.seancorfield/honeysql "2.0.0-rc3"] 11 | [com.walmartlabs/lacinia "0.39-alpha-9"] 12 | [metosin/jsonista "0.3.6"] 13 | [ring/ring-core "1.9.3"] 14 | [camel-snake-kebab "0.4.2"] 15 | [environ "1.2.0"] 16 | [hikari-cp "2.14.0"] 17 | [inflections "0.13.2"] 18 | [integrant "0.8.0"]] 19 | :plugins [[lein-eftest "0.5.9"] 20 | [lein-cloverage "1.2.2"]] 21 | :eftest {:report eftest.report.pretty/report 22 | :report-to-file "target/junit.xml"} 23 | :profiles 24 | {:1.8 {:dependencies [[org.clojure/clojure "1.8.0"]]} 25 | :1.9 {:dependencies [[org.clojure/clojure "1.9.0"]]} 26 | :1.11 {:dependencies [[org.clojure/clojure "1.11.1"]]}}) 27 | 28 | -------------------------------------------------------------------------------- /src/phrag/context.clj: -------------------------------------------------------------------------------- 1 | (ns phrag.context 2 | "Context from DB schema data to construct Phrag's GraphQL." 3 | (:require [camel-snake-kebab.core :as csk] 4 | [clojure.string :as s] 5 | [clojure.pprint :as pp] 6 | [inflections.core :as inf] 7 | [phrag.db.adapter :as db-adapter] 8 | [phrag.table :as tbl] 9 | [phrag.field :as fld])) 10 | 11 | ;;; Relation Context (field names & columns) 12 | 13 | (defn- has-many-field 14 | "Checks if a given table is a bridge table of cicular many-to-many or not, 15 | and if it is, adds FK column name to the field key of nested object." 16 | [table fk] 17 | (let [tbl-name (:name table) 18 | rscs (inf/plural tbl-name) 19 | fk-from (:from fk)] 20 | (if (tbl/circular-m2m-fk? table fk-from) 21 | (str rscs "_on_" fk-from) 22 | rscs))) 23 | 24 | (defn- has-one-field 25 | "If a FK column has `_id` naming, nested objects get field keys with trailing 26 | `_id` removed. If not, FK destination is added to FK origin column. 27 | Example: `user_id` => `user` / `created_by` => `created_by_user`" 28 | [fk] 29 | (let [from (:from fk)] 30 | (if (s/ends-with? from "_id") 31 | (s/replace from #"_id" "") 32 | (str from "_" (inf/singular (:table fk)))))) 33 | 34 | (defn- nest-fk-map 35 | "Fk map of both directions for resolving nested queries." 36 | [rel-type table-key fk] 37 | (-> (reduce-kv (fn [m k v] (assoc m k (keyword v))) {} fk) 38 | (assoc :from-table table-key) 39 | (assoc :type rel-type))) 40 | 41 | (defn- assoc-has-one-maps 42 | "assoc has-one on FK origin table" 43 | [m table-key fks] 44 | (reduce (fn [m fk] 45 | (let [has-1-fld (keyword (has-one-field fk))] 46 | (assoc-in m [:nest-fks table-key has-1-fld] 47 | (nest-fk-map :has-one table-key fk)))) 48 | m 49 | fks)) 50 | 51 | (defn- assoc-has-many-maps 52 | "assoc has-many inverse relation on FK destination tables" 53 | [m table-key table fks] 54 | (reduce 55 | (fn [m fk] 56 | (let [has-many-key (keyword (has-many-field table fk)) 57 | has-many-aggr-key (keyword (str (name has-many-key) "_aggregate")) 58 | to-tbl-key (keyword (:table fk)) 59 | n-fk (nest-fk-map :has-many table-key fk) 60 | n-aggr-fk (nest-fk-map :has-many-aggr table-key fk)] 61 | {:nest-fks (-> (:nest-fks m) 62 | (assoc-in [to-tbl-key has-many-key] n-fk) 63 | (assoc-in [to-tbl-key has-many-aggr-key] n-aggr-fk))})) 64 | m 65 | fks)) 66 | 67 | (defn- relation-ctx-per-table [table] 68 | (let [fks (:fks table) 69 | tbl-key (keyword (:name table)) 70 | has-one-mapped (assoc-has-one-maps {:nest-fks {tbl-key {}}} tbl-key fks)] 71 | (assoc-has-many-maps has-one-mapped tbl-key table fks))) 72 | 73 | (defn- relation-context [tables] 74 | (reduce (fn [m table] 75 | (let [rel-ctx (relation-ctx-per-table table)] 76 | {:nest-fks (merge-with merge (:nest-fks m) (:nest-fks rel-ctx))})) 77 | {:fields {} :columns {} :nest-fks {}} 78 | tables)) 79 | 80 | ;; FK Context 81 | 82 | (defn- fk-field-keys [fk table to-table-name] 83 | (let [has-many-fld (has-many-field table fk) 84 | to-rsc-name (csk/->PascalCase (inf/singular to-table-name))] 85 | {:to (keyword to-rsc-name) 86 | :has-many (keyword has-many-fld) 87 | :has-many-aggr (keyword (str has-many-fld "_aggregate")) 88 | :has-one (keyword (has-one-field fk))})) 89 | 90 | (defn- fk-context [table] 91 | (let [fks (:fks table) 92 | fk-map (zipmap (map #(keyword (:from %)) fks) fks)] 93 | (reduce-kv (fn [m from-key fk] 94 | (assoc m from-key 95 | {:field-keys (fk-field-keys fk table (:table fk))})) 96 | {} fk-map))) 97 | 98 | ;;; Signal functions 99 | 100 | (defn- conj-items [v] 101 | (reduce (fn [v fns] 102 | (if (coll? fns) 103 | (into v fns) 104 | (conj v fns))) 105 | [] v)) 106 | 107 | (defn- signal-per-type 108 | "Signal functions per resource and operation." 109 | [signal-map table-key op] 110 | (let [all-tbl-fns (:all signal-map) 111 | all-op-fns (get-in signal-map [table-key :all]) 112 | all-timing-fns (get-in signal-map [table-key op :all]) 113 | pre-fns (get-in signal-map [table-key op :pre]) 114 | post-fns (get-in signal-map [table-key op :post])] 115 | {:pre (filter fn? (conj-items [all-tbl-fns all-op-fns 116 | all-timing-fns pre-fns])) 117 | :post (filter fn? (conj-items [all-tbl-fns all-op-fns 118 | all-timing-fns post-fns]))})) 119 | 120 | ;;; Lacinia Schema Context from Table Data 121 | 122 | (defn- table-context 123 | "Compiles resource names, Lacinia fields and relationships from table data." 124 | [tables signals] 125 | (let [table-map (zipmap (map #(keyword (:name %)) tables) tables)] 126 | (reduce-kv 127 | (fn [m k table] 128 | (let [table-name (:name table) 129 | obj-keys (fld/lcn-obj-keys table-name) 130 | pk-keys (tbl/pk-keys table)] 131 | (assoc 132 | m k 133 | (-> m 134 | (assoc :col-keys (tbl/col-key-set table)) 135 | (assoc :fks (fk-context table)) 136 | (assoc :pk-keys pk-keys) 137 | (assoc :lcn-obj-keys obj-keys) 138 | (assoc :lcn-qry-keys (fld/lcn-qry-keys table-name)) 139 | (assoc :lcn-mut-keys (fld/lcn-mut-keys table-name)) 140 | (assoc :lcn-descs (fld/lcn-descs table-name)) 141 | (assoc :lcn-fields (fld/lcn-fields table obj-keys pk-keys)) 142 | (assoc :signals {:query (signal-per-type signals k :query) 143 | :create (signal-per-type signals k :create) 144 | :delete (signal-per-type signals k :delete) 145 | :update (signal-per-type signals k :update)}))))) 146 | {} table-map))) 147 | 148 | (defn- view-context [views signals] 149 | (let [view-map (zipmap (map #(keyword (:name %)) views) views)] 150 | (reduce-kv 151 | (fn [m k view] 152 | (let [view-name (:name view) 153 | obj-keys (fld/lcn-obj-keys view-name)] 154 | (assoc m k 155 | (-> m 156 | (assoc :lcn-obj-keys obj-keys) 157 | (assoc :lcn-qry-keys (fld/lcn-qry-keys view-name)) 158 | (assoc :lcn-descs (fld/lcn-descs view-name)) 159 | (assoc :lcn-fields (fld/lcn-fields view obj-keys nil)) 160 | (assoc :signals 161 | {:query (signal-per-type signals k :query)}))))) 162 | {} view-map))) 163 | 164 | (defn options->config 165 | "Creates a config map from user-provided options." 166 | [options] 167 | (let [signals (:signals options) 168 | config {:router (:router options) 169 | :db (:db options) 170 | :db-adapter (db-adapter/db->adapter (:db options)) 171 | :tables (:tables options) 172 | :signal-ctx (:signal-ctx options) 173 | :middleware (:middleware options) 174 | :scan-tables (:scan-tables options true) 175 | :scan-views (:scan-views options true) 176 | :default-limit (:default-limit options) 177 | :max-nest-level (:max-nest-level options) 178 | :use-aggregation (:use-aggregation options true)} 179 | db-scm (tbl/db-schema config)] 180 | (-> config 181 | (assoc :relation-ctx (relation-context (:tables db-scm))) 182 | (assoc :tables (table-context (:tables db-scm) signals)) 183 | (assoc :views (view-context (:views db-scm) signals))))) 184 | 185 | (def ^:no-doc init-schema {:enums fld/sort-op-enum 186 | :input-objects fld/filter-input-objects 187 | :objects fld/result-object 188 | :queries {}}) 189 | 190 | -------------------------------------------------------------------------------- /src/phrag/core.clj: -------------------------------------------------------------------------------- 1 | (ns phrag.core 2 | "Creation and execution of Phrag's GraphQL schema through Lacinia." 3 | (:require [phrag.logging :refer [log]] 4 | [phrag.resolver :as rslv] 5 | [phrag.context :as ctx] 6 | [com.walmartlabs.lacinia :as lcn] 7 | [com.walmartlabs.lacinia.tracing :as trc] 8 | [com.walmartlabs.lacinia.schema :as schema] 9 | [clojure.pprint :as pp])) 10 | 11 | ;;; Queries 12 | 13 | (defn- assoc-object [scm table obj-type obj-key] 14 | (assoc-in scm [obj-type (get-in table [:lcn-obj-keys obj-key])] 15 | {:description (get-in table [:lcn-descs obj-key]) 16 | :fields (get-in table [:lcn-fields obj-key])})) 17 | 18 | (defn- assoc-queries [schema table-key table] 19 | (let [{{:keys [queries]} :lcn-qry-keys 20 | {:keys [rsc clauses sort]} :lcn-obj-keys 21 | {:keys [query]} :lcn-descs} table] 22 | (assoc-in schema [:queries queries] 23 | {:type `(~'list ~rsc) 24 | :description query 25 | :args {:where {:type clauses} 26 | :sort {:type sort} 27 | :limit {:type 'Int} 28 | :offset {:type 'Int}} 29 | :resolve (partial rslv/resolve-query table-key)}))) 30 | 31 | (defn- assoc-aggregation [schema table-key table] 32 | (let [{{:keys [aggregate clauses]} :lcn-obj-keys} table] 33 | (assoc-in schema [:queries (get-in table [:lcn-qry-keys :aggregate])] 34 | {:type aggregate 35 | :description (get-in table [:lcn-descs :aggregate]) 36 | :args {:where {:type clauses}} 37 | :resolve (partial rslv/aggregate-root table-key)}))) 38 | 39 | (defn- assoc-query-objects [schema table-key table config] 40 | (let [entity-schema (-> schema 41 | (assoc-object table :objects :rsc) 42 | (assoc-object table :input-objects :clauses) 43 | (assoc-object table :input-objects :sort) 44 | (assoc-queries table-key table))] 45 | (if (:use-aggregation config) 46 | (-> entity-schema 47 | (assoc-object table :objects :fields) 48 | (assoc-object table :objects :aggregate) 49 | (assoc-aggregation table-key table)) 50 | entity-schema))) 51 | 52 | ;;; Mutations 53 | 54 | (defn- assoc-create-mutation [schema table-key table] 55 | (let [{{:keys [create]} :lcn-mut-keys 56 | {:keys [pks]} :lcn-obj-keys 57 | {:keys [rsc]} :lcn-fields} table] 58 | (assoc-in schema [:mutations create] 59 | {:type pks 60 | :args rsc 61 | :resolve (partial rslv/create-root table-key table)}))) 62 | 63 | (defn- assoc-update-mutation [schema table-key table] 64 | (let [{{:keys [update]} :lcn-fields 65 | {:keys [pk-input]} :lcn-obj-keys} table] 66 | (assoc-in schema [:mutations (get-in table [:lcn-mut-keys :update])] 67 | {:type :Result 68 | :args (assoc update :pk_columns {:type `(~'non-null ~pk-input)}) 69 | :resolve (partial rslv/update-root table-key table)}))) 70 | 71 | (defn- assoc-delete-mutation [schema table-key table] 72 | (let [{{:keys [delete]} :lcn-mut-keys 73 | {:keys [pk-input]} :lcn-obj-keys} table] 74 | (assoc-in schema [:mutations delete] 75 | {:type :Result 76 | :args {:pk_columns {:type `(~'non-null ~pk-input)}} 77 | :resolve (partial rslv/delete-root table-key)}))) 78 | 79 | (defn- assoc-mutation-objects [schema table-key table] 80 | (-> schema 81 | (assoc-object table :objects :pks) 82 | (assoc-object table :input-objects :pk-input) 83 | (assoc-create-mutation table-key table) 84 | (assoc-update-mutation table-key table) 85 | (assoc-delete-mutation table-key table))) 86 | 87 | ;;; Relationships 88 | 89 | (defn- assoc-has-one 90 | "Updates fk-origin object with a has-one object field." 91 | [schema table fk] 92 | (let [{{:keys [has-one]} :field-keys} fk 93 | {{:keys [rsc]} :lcn-obj-keys} table] 94 | (assoc-in schema [:objects rsc :fields has-one] 95 | {:type (get-in fk [:field-keys :to])}))) 96 | 97 | (defn- assoc-has-many 98 | "Updates fk-destination object with a has-many resource field." 99 | [schema table fk] 100 | (let [{{:keys [to has-many]} :field-keys} fk 101 | {{:keys [rsc clauses sort]} :lcn-obj-keys} table] 102 | (assoc-in schema [:objects to :fields has-many] 103 | {:type `(~'list ~rsc) 104 | :args {:where {:type clauses} 105 | :sort {:type sort} 106 | :limit {:type 'Int} 107 | :offset {:type 'Int}}}))) 108 | 109 | (defn- assoc-has-many-aggregate 110 | "Updates fk-destination object with a has-many aggregation field." 111 | [schema table fk] 112 | (let [{{:keys [to has-many-aggr]} :field-keys} fk 113 | {{:keys [aggregate clauses]} :lcn-obj-keys} table] 114 | (assoc-in schema [:objects to :fields has-many-aggr] 115 | {:type aggregate 116 | :args {:where {:type clauses}}}))) 117 | 118 | ;;; GraphQL schema 119 | 120 | (defn- root-schema [config] 121 | (reduce-kv (fn [m table-key table] 122 | (-> m 123 | (assoc-query-objects table-key table config) 124 | (assoc-mutation-objects table-key table))) 125 | ctx/init-schema 126 | (:tables config))) 127 | 128 | (defn- assoc-fks [schema table config] 129 | (reduce-kv (fn [m _from-key fk] 130 | (cond-> m 131 | true (assoc-has-one table fk) 132 | true (assoc-has-many table fk) 133 | (:use-aggregation config) (assoc-has-many-aggregate table fk))) 134 | schema (:fks table))) 135 | 136 | (defn- update-relationships [schema config] 137 | (reduce-kv (fn [m _table-key table] 138 | (assoc-fks m table config)) 139 | schema 140 | (:tables config))) 141 | 142 | (defn- update-views [schema config] 143 | (reduce-kv (fn [m view-key view] 144 | (assoc-query-objects m view-key view config)) 145 | schema 146 | (:views config))) 147 | 148 | (defn schema 149 | "Creates Phrag's GraphQL schema in Lacinia format." 150 | [config] 151 | (let [scm-map (-> (root-schema config) 152 | (update-relationships config) 153 | (update-views config))] 154 | (log :info "Generated queries: " (sort (keys (:queries scm-map)))) 155 | (log :info "Generated mutations: " (sort (keys (:mutations scm-map)))) 156 | (schema/compile scm-map))) 157 | 158 | ;;; Execution 159 | 160 | (defn exec 161 | "Executes Phrag's GraphQL." 162 | [config schema query vars req] 163 | (let [ctx (-> (:signal-ctx config {}) 164 | (assoc :req req) 165 | (assoc :db (:db config)) 166 | (assoc :db-adapter (:db-adapter config)) 167 | (assoc :default-limit (:default-limit config)) 168 | (assoc :max-nest-level (:max-nest-level config)) 169 | (assoc :relation-ctx (:relation-ctx config)) 170 | (assoc :tables (:tables config)))] 171 | (lcn/execute schema query vars ctx))) 172 | -------------------------------------------------------------------------------- /src/phrag/db/adapter.clj: -------------------------------------------------------------------------------- 1 | (ns phrag.db.adapter 2 | "DB adapter initialization." 3 | (:require [clojure.string :as s] 4 | [phrag.db.sqlite :as sqlite] 5 | [phrag.db.postgres :as postgres])) 6 | 7 | (defn- connection-type [db] 8 | (let [db-info (s/lower-case (-> (.getMetaData (:connection db)) 9 | (.getDatabaseProductName)))] 10 | (cond 11 | (s/includes? db-info "sqlite") :sqlite 12 | (s/includes? db-info "postgres") :postgres 13 | :else nil))) 14 | 15 | (defn- data-src-type [db] 16 | (let [data-src (:datasource db) 17 | db-info (s/lower-case (or (.getJdbcUrl data-src) 18 | (.getDataSourceClassName data-src)))] 19 | (cond 20 | (s/includes? db-info "sqlite") :sqlite 21 | (s/includes? db-info "postgres") :postgres 22 | :else nil))) 23 | 24 | (defn- db-type [db] 25 | (cond 26 | (:connection db) (connection-type db) 27 | (:datasource db) (data-src-type db) 28 | :else nil)) 29 | 30 | (defn db->adapter [db] 31 | (let [type-key (db-type db)] 32 | (case type-key 33 | :sqlite (sqlite/->SqliteAdapter db) 34 | :postgres (postgres/->PostgresAdapter db)))) 35 | 36 | -------------------------------------------------------------------------------- /src/phrag/db/core.clj: -------------------------------------------------------------------------------- 1 | (ns phrag.db.core 2 | "DB operations to create and resolve GraphQL." 3 | (:require [clojure.java.jdbc :as jdbc] 4 | [jsonista.core :as j] 5 | [honey.sql :as sql] 6 | [honey.sql.helpers :as h]) 7 | (:import [org.postgresql.util PGobject])) 8 | 9 | ;; Postgres Object Handler 10 | 11 | (defmulti read-pgobject 12 | "Convert returned PGobject to Clojure value." 13 | #(keyword (when (some? %) (.getType ^PGobject %)))) 14 | 15 | (defmethod read-pgobject :json 16 | [^PGobject x] 17 | (when-let [val (.getValue x)] 18 | (j/read-value val j/keyword-keys-object-mapper))) 19 | 20 | (defmethod read-pgobject :jsonb 21 | [^PGobject x] 22 | (when-let [val (.getValue x)] 23 | (j/read-value val j/keyword-keys-object-mapper))) 24 | 25 | (defmethod read-pgobject :default 26 | [^PGobject x] 27 | (.getValue x)) 28 | 29 | (extend-protocol jdbc/IResultSetReadColumn 30 | PGobject 31 | (result-set-read-column [val _ _] 32 | (read-pgobject val))) 33 | 34 | ;; Utilities 35 | 36 | (def ^:no-doc aggr-keys #{:count :avg :max :min :sum}) 37 | 38 | (defn ^:no-doc column-path [table-key column-key] 39 | (str (name table-key) "." (name column-key))) 40 | 41 | (defn ^:no-doc column-path-key [table-key column-key] 42 | (keyword (column-path table-key column-key))) 43 | 44 | ;; Argument Handler 45 | 46 | (def ^:private where-ops 47 | {:eq := 48 | :gt :> 49 | :lt :< 50 | :gte :>= 51 | :lte :<= 52 | :ne :!= 53 | :in :in 54 | :like :like}) 55 | 56 | (defn- format-where [table-key where-map] 57 | (reduce (fn [v [col entry]] 58 | (conj v (cond 59 | (= col :and) 60 | (into [:and] (map #(format-where table-key %) entry)) 61 | (= col :or) 62 | (into [:or] (map #(format-where table-key %) entry)) 63 | :else 64 | (let [entry (first entry) ;; to MapEntry 65 | op ((key entry) where-ops) 66 | col-path (column-path-key table-key col)] 67 | [op col-path (val entry)])))) 68 | nil 69 | where-map)) 70 | 71 | (defn- apply-where [q table-key whr] 72 | (apply h/where q (format-where table-key whr))) 73 | 74 | (defn- apply-sort [q arg] 75 | (apply h/order-by q (reduce-kv (fn [vec col direc] 76 | (conj vec [col direc])) 77 | nil arg))) 78 | 79 | (defn apply-args 80 | "Applies filter, sort and pagination arguments." 81 | [q table-key args ctx] 82 | (let [def-lmt (:default-limit ctx) 83 | lmt (or (:limit args) (and (integer? def-lmt) def-lmt))] 84 | (cond-> (apply-where q table-key (:where args)) 85 | (:sort args) (apply-sort (:sort args)) 86 | lmt (h/limit lmt) 87 | (integer? (:offset args)) (h/offset (:offset args))))) 88 | 89 | ;; Interceptor signals 90 | 91 | (defn signal 92 | "Calls all interceptor functions applicable." 93 | [args table-key op pre-post ctx] 94 | (reduce (fn [args sgnl-fn] 95 | (sgnl-fn args ctx)) 96 | args 97 | (get-in ctx [:tables table-key :signals op pre-post]))) 98 | 99 | ;; Query handling 100 | 101 | (defn ^:no-doc exec-query [db q] 102 | (jdbc/with-db-connection [conn db] 103 | (jdbc/query conn q))) 104 | 105 | (defn create! 106 | "Executes create statement with parameter map." 107 | [db rsc raw-map opts] 108 | ;; (prn rsc raw-map) 109 | (jdbc/with-db-connection [conn db] 110 | (jdbc/insert! conn rsc raw-map opts))) 111 | 112 | (defn update! 113 | "Executes update statement with primary key map and parameter map." 114 | [db table pk-map raw-map] 115 | (let [whr (map (fn [[k v]] [:= k v]) pk-map) 116 | q (-> (h/update table) 117 | (h/set raw-map))] 118 | ;; (prn (sql/format (apply h/where q whr))) 119 | (jdbc/with-db-connection [conn db] 120 | (->> (apply h/where q whr) 121 | sql/format 122 | (jdbc/execute! conn))))) 123 | 124 | (defn delete! 125 | "Executes delete statement with primary key map." 126 | [db table pk-map] 127 | (let [whr (map (fn [[k v]] [:= k v]) pk-map) 128 | q (apply h/where (h/delete-from table) whr)] 129 | ;; (prn (sql/format q)) 130 | (jdbc/with-db-connection [conn db] 131 | (->> (sql/format q) 132 | (jdbc/execute! conn))))) 133 | 134 | ;; DB adapter protocol 135 | 136 | (defprotocol DbAdapter 137 | "Protocol for executing DB-specific operations." 138 | (table-names [adpt] "Retrieves a list of table names.") 139 | (view-names [adpt] "Retrieves a list of view names.") 140 | (column-info [adpt table-name] "Retrieves a list of column maps.") 141 | (foreign-keys [adpt table-name] "Retrieves a list of foreign key maps.") 142 | (primary-keys [adpt table-name] "Retrieves a list of primary key maps.") 143 | (resolve-query [adpt table-key selection ctx] 144 | "Resolves a GraphQL query which possibly has nested query objects.") 145 | (resolve-aggregation [adpt table-key selection ctx] 146 | "Resolves a root-level aggregation query.")) 147 | -------------------------------------------------------------------------------- /src/phrag/db/postgres.clj: -------------------------------------------------------------------------------- 1 | (ns phrag.db.postgres 2 | "Implementation of DB adapter for PostgreSQL." 3 | (:require [clojure.string :as s] 4 | [phrag.db.core :as core] 5 | [honey.sql :as sql] 6 | [honey.sql.helpers :as h])) 7 | 8 | (defn- current-schema [db] 9 | (cond 10 | (:connection db) (.getSchema (:connection db)) 11 | (:datasource db) (-> (.getDataSourceProperties (:datasource db)) 12 | (.getProperty "currentSchema")) 13 | :else "public")) 14 | 15 | (defn- aggr-param [op table-key selection] 16 | (map (fn [slct] 17 | (let [col (name (:field-name slct))] 18 | (format "'%s', %s(%s.%s)" col op (name table-key) col))) 19 | (:selections selection))) 20 | 21 | (defn- aggr-params [table-key selection] 22 | (reduce (fn [v slct] 23 | (let [field-key (get-in slct [:field-definition :field-name])] 24 | (cond 25 | (= :count field-key) (conj v "'count', count(*)") 26 | (contains? core/aggr-keys field-key) 27 | (let [op (name field-key) 28 | params (aggr-param op table-key slct)] 29 | (conj v (format "'%s', JSON_BUILD_OBJECT(%s)" op 30 | (s/join ", " params)))) 31 | :else v))) 32 | nil 33 | (:selections selection))) 34 | 35 | (defn- compile-aggr [table-key selection] 36 | (format "JSON_BUILD_OBJECT(%s)" (s/join ", " (aggr-params table-key selection)))) 37 | 38 | (defn- compile-query [nest-level table-key selection ctx] 39 | (let [nest-fks (get-in ctx [:relation-ctx :nest-fks table-key]) 40 | max-nest (:max-nest-level ctx) 41 | selection (core/signal selection table-key :query :pre ctx)] 42 | (reduce 43 | (fn [q slct] 44 | (let [field-key (get-in slct [:field-definition :field-name])] 45 | (if (:leaf? slct) 46 | (h/select q (core/column-path-key table-key field-key)) 47 | (if (and (number? max-nest) (> nest-level max-nest)) 48 | (throw (Exception. "Exceeded maximum nest level.")) 49 | (let [nest-fk (field-key nest-fks) 50 | nest-type (:type nest-fk) 51 | {:keys [to from from-table table]} nest-fk] 52 | (case nest-type 53 | :has-one 54 | (let [sym (gensym) 55 | c (compile-query (+ nest-level 1) table slct ctx) 56 | on-clause [:= (core/column-path-key from-table from) 57 | (core/column-path-key table to)]] 58 | (-> q 59 | (h/select [[:raw (format "ROW_TO_JSON(%s)" sym)] field-key]) 60 | (h/left-join [[:lateral (h/where c on-clause)] 61 | (keyword sym)] 62 | true))) 63 | :has-many 64 | (let [sym (gensym) 65 | sub-select (format "COALESCE(JSON_AGG(%s.*), '[]')" sym) 66 | c (compile-query (+ nest-level 1) from-table slct ctx) 67 | on-clause [:= (core/column-path-key from-table from) 68 | (core/column-path-key table to)]] 69 | (h/select q [(-> (h/select [[:raw sub-select]]) 70 | (h/from [(h/where c on-clause) (keyword sym)])) 71 | field-key])) 72 | :has-many-aggr 73 | (let [on-clause [:= (core/column-path-key from-table from) 74 | (core/column-path-key table to)]] 75 | (h/select q [(-> (h/select 76 | [[:raw (compile-aggr from-table slct)]]) 77 | (h/from from-table) 78 | (h/where on-clause) 79 | (core/apply-args from-table (:arguments slct) 80 | ctx)) 81 | field-key])))))))) 82 | (-> (h/from table-key) 83 | (core/apply-args table-key (:arguments selection) ctx)) 84 | (:selections selection)))) 85 | 86 | (defn- json-array-cast [q] 87 | (-> (h/select [[:raw "COALESCE(JSON_AGG(res), '[]')"] :result]) 88 | (h/from [q :res]))) 89 | 90 | (defn- compile-aggregation [table-key selection ctx] 91 | (-> (h/select [[:raw (compile-aggr table-key selection)] :result]) 92 | (h/from table-key) 93 | (core/apply-args table-key (:arguments selection) ctx))) 94 | 95 | (defrecord PostgresAdapter [db] 96 | core/DbAdapter 97 | 98 | (table-names [adpt] 99 | (let [schema-name (current-schema db)] 100 | (core/exec-query (:db adpt) (str "SELECT table_name AS name " 101 | "FROM information_schema.tables " 102 | "WHERE table_schema='" schema-name "' " 103 | "AND table_type='BASE TABLE' " 104 | "AND table_name not like '%migration%';")))) 105 | 106 | (view-names [adpt] 107 | (let [schema-name (current-schema db)] 108 | (core/exec-query (:db adpt) (str "SELECT table_name AS name " 109 | "FROM information_schema.tables " 110 | "WHERE table_schema='" schema-name "' " 111 | "AND table_type='VIEW';")))) 112 | 113 | (column-info [adpt table-name] 114 | (core/exec-query (:db adpt) 115 | (str "SELECT column_name AS name, data_type AS type, " 116 | "(is_nullable = 'NO') AS notnull, " 117 | "column_default AS dflt_value " 118 | "FROM information_schema.columns " 119 | "WHERE table_name = '" table-name "';"))) 120 | 121 | (foreign-keys [adpt table-name] 122 | (core/exec-query (:db adpt) 123 | (str "SELECT kcu.column_name AS from, " 124 | "ccu.table_name AS table, " 125 | "ccu.column_name AS to " 126 | "FROM information_schema.table_constraints as tc " 127 | "JOIN information_schema.key_column_usage AS kcu " 128 | "ON tc.constraint_name = kcu.constraint_name " 129 | "AND tc.table_schema = kcu.table_schema " 130 | "JOIN information_schema.constraint_column_usage AS ccu " 131 | "ON ccu.constraint_name = tc.constraint_name " 132 | "AND ccu.table_schema = tc.table_schema " 133 | "WHERE tc.constraint_type = 'FOREIGN KEY' " 134 | "AND tc.table_name='" table-name "';"))) 135 | 136 | (primary-keys [adpt table-name] 137 | (core/exec-query (:db adpt) 138 | (str "SELECT c.column_name AS name, c.data_type AS type " 139 | "FROM information_schema.table_constraints tc " 140 | "JOIN information_schema.constraint_column_usage AS ccu " 141 | "USING (constraint_schema, constraint_name) " 142 | "JOIN information_schema.columns AS c " 143 | "ON c.table_schema = tc.constraint_schema " 144 | "AND tc.table_name = c.table_name " 145 | "AND ccu.column_name = c.column_name " 146 | "WHERE constraint_type = 'PRIMARY KEY' " 147 | "AND tc.table_name = '" table-name "';"))) 148 | 149 | (resolve-query [adpt table-key selection ctx] 150 | (let [query (json-array-cast (compile-query 1 table-key selection ctx)) 151 | res (core/exec-query (:db adpt) (sql/format query))] 152 | (:result (first res)))) 153 | 154 | (resolve-aggregation [adpt table-key selection ctx] 155 | (let [query (compile-aggregation table-key selection ctx) 156 | res (core/exec-query (:db adpt) (sql/format query))] 157 | (:result (first res))))) 158 | -------------------------------------------------------------------------------- /src/phrag/db/sqlite.clj: -------------------------------------------------------------------------------- 1 | (ns phrag.db.sqlite 2 | "Implementation of DB adapter for SQLite." 3 | (:require [jsonista.core :as j] 4 | [clojure.string :as s] 5 | [honey.sql :as sql] 6 | [honey.sql.helpers :as h] 7 | [phrag.db.core :as core])) 8 | 9 | (defn- aggr-param [op table-key selection] 10 | (map (fn [slct] 11 | (let [col (name (:field-name slct))] 12 | (format "'%s', %s(%s.%s)" col op (name table-key) col))) 13 | (:selections selection))) 14 | 15 | (defn- aggr-params [table-key selection] 16 | (reduce (fn [v slct] 17 | (let [field-key (get-in slct [:field-definition :field-name])] 18 | (cond 19 | (= :count field-key) (conj v "'count', count(*)") 20 | (contains? core/aggr-keys field-key) 21 | (let [op (name field-key) 22 | params (aggr-param op table-key slct)] 23 | (conj v (format "'%s', JSON_OBJECT(%s)" op (s/join ", " params)))) 24 | :else v))) 25 | nil 26 | (:selections selection))) 27 | 28 | (defn- compile-aggr [table-key selection] 29 | (format "JSON_OBJECT(%s)" (s/join ", " (aggr-params table-key selection)))) 30 | 31 | (defn- json-params [args] 32 | (reduce (fn [m [select field-key]] 33 | (let [fmt "'%s', %s"] 34 | (cond 35 | (string? select) 36 | (update m :sql conj (format fmt (name field-key) select)) 37 | (keyword? select) 38 | (update m :sql conj (format fmt (name field-key) (name select))) 39 | (map? select) 40 | (let [[sql & params] (sql/format select)] 41 | (-> m 42 | (update :sql conj (format "'%s', (%s)" (name field-key) sql)) 43 | (update :params into params))) 44 | :else m))) 45 | {} 46 | args)) 47 | 48 | (defn- format-json-select [_f args] 49 | (let [params (json-params args) 50 | sql (format "SELECT JSON_OBJECT(%s) as data" (s/join ", " (:sql params)))] 51 | (into [sql] (:params params)))) 52 | 53 | (sql/register-clause! :json-select-sqlite format-json-select :from) 54 | 55 | (defn- json-select [query field-key select] 56 | (if (:json-select-sqlite query) 57 | (update query :json-select-sqlite conj [select field-key]) 58 | (assoc query :json-select-sqlite [[select field-key]]))) 59 | 60 | (defn- compile-query [nest-level table-key selection ctx] 61 | (let [nest-fks (get-in ctx [:relation-ctx :nest-fks table-key]) 62 | max-nest (:max-nest-level ctx) 63 | selection (core/signal selection table-key :query :pre ctx)] 64 | (reduce 65 | (fn [q slct] 66 | (let [field-key (get-in slct [:field-definition :field-name])] 67 | (if (:leaf? slct) 68 | (let [table-column (core/column-path-key table-key field-key)] 69 | (json-select q field-key table-column)) 70 | (if (and (number? max-nest) (> nest-level max-nest)) 71 | (throw (Exception. "Exceeded maximum nest level.")) 72 | (let [nest-fk (field-key nest-fks) 73 | nest-type (:type nest-fk) 74 | {:keys [to from from-table table]} nest-fk] 75 | (case nest-type 76 | :has-one 77 | (let [c (compile-query (+ nest-level 1) table slct ctx) 78 | on-clause [:= (core/column-path-key from-table from) 79 | (core/column-path-key table to)]] 80 | (json-select q field-key (h/where c on-clause))) 81 | :has-many 82 | (let [sym (gensym) 83 | sub-select (format "JSON_GROUP_ARRAY(JSON(%s.data))" sym) 84 | c (compile-query (+ nest-level 1) from-table slct ctx) 85 | on-clause [:= (core/column-path-key from-table from) 86 | (core/column-path-key table to)]] 87 | (json-select q field-key 88 | (-> (h/select [[:raw sub-select]]) 89 | (h/from [(h/where c on-clause) 90 | (keyword sym)])))) 91 | :has-many-aggr 92 | (let [on-clause [:= (core/column-path-key from-table from) 93 | (core/column-path-key table to)]] 94 | (json-select q field-key 95 | (-> (h/select 96 | [[:raw (compile-aggr from-table slct)]]) 97 | (h/from from-table) 98 | (h/where on-clause) 99 | (core/apply-args from-table (:arguments slct) 100 | ctx)))))))))) 101 | (-> (h/from table-key) 102 | (core/apply-args table-key (:arguments selection) ctx)) 103 | (:selections selection)))) 104 | 105 | (defn- json-array-cast [q] 106 | (-> (h/select [[:raw "JSON_GROUP_ARRAY(JSON(res.data))"] :result]) 107 | (h/from [q :res]))) 108 | 109 | (defn- compile-aggregation [table-key selection ctx] 110 | (-> (h/select [[:raw "JSON(aggr.data)"] :result]) 111 | (h/from [(->(h/select [[:raw (compile-aggr table-key selection)] :data]) 112 | (h/from table-key) 113 | (core/apply-args table-key (:arguments selection) ctx)) :aggr]))) 114 | 115 | (defrecord SqliteAdapter [db] 116 | core/DbAdapter 117 | 118 | (table-names [adpt] 119 | (core/exec-query (:db adpt) (str "SELECT name FROM sqlite_master " 120 | "WHERE type = 'table' " 121 | "AND name NOT LIKE 'sqlite%' " 122 | "AND name NOT LIKE '%migration%';"))) 123 | 124 | (view-names [adpt] 125 | (core/exec-query (:db adpt) (str "SELECT name FROM sqlite_master " 126 | "WHERE type = 'view';"))) 127 | 128 | (column-info [adpt table-name] 129 | (core/exec-query (:db adpt) (format "pragma table_info(%s);" table-name))) 130 | 131 | (foreign-keys [adpt table-name] 132 | (core/exec-query (:db adpt) 133 | (format "pragma foreign_key_list(%s);" table-name))) 134 | 135 | (primary-keys [adpt table-name] 136 | (reduce (fn [v col] 137 | (if (> (:pk col) 0) (conj v col) v)) 138 | [] (core/column-info adpt table-name))) 139 | 140 | (resolve-query [adpt table-key selection ctx] 141 | (let [query (compile-query 1 table-key selection ctx) 142 | res (core/exec-query (:db adpt) (sql/format (json-array-cast query)))] 143 | (-> (first res) 144 | :result 145 | (j/read-value j/keyword-keys-object-mapper)))) 146 | 147 | (resolve-aggregation [adpt table-key selection ctx] 148 | (let [query (compile-aggregation table-key selection ctx) 149 | res (core/exec-query (:db adpt) (sql/format query))] 150 | (-> (first res) 151 | :result 152 | (j/read-value j/keyword-keys-object-mapper))))) 153 | -------------------------------------------------------------------------------- /src/phrag/field.clj: -------------------------------------------------------------------------------- 1 | (ns phrag.field 2 | "Lacinia fields for a context of Phrag's schema." 3 | (:require [clojure.string :as s] 4 | [camel-snake-kebab.core :as csk] 5 | [inflections.core :as inf])) 6 | 7 | ;;; Descriptions 8 | 9 | (def ^:private clause-desc 10 | (str "Format for where clauses is {column: {operator: value}}. " 11 | "Multiple parameters are applied with `AND` operators. " 12 | "`AND`/`OR` conditions can be created as a list of clauses. " 13 | "It is possible to nest them to create sub groups of conditions.")) 14 | 15 | (def ^:private sort-desc 16 | (str "Sort format is {column: \"asc\" or \"desc\"}.")) 17 | 18 | (def ^:private pk-desc "Primary key fields.") 19 | 20 | (defn lcn-descs 21 | "Returns Lacinia descriptions for a table." 22 | [table-name] 23 | (let [rsc-name (csk/->PascalCase (inf/plural table-name))] 24 | {:rsc rsc-name 25 | :query (str "Query " rsc-name ".") 26 | :clauses clause-desc 27 | :sort sort-desc 28 | :fields (str rsc-name "fields for aggregation.") 29 | :aggregate (str "Aggrecate " rsc-name ".") 30 | :pks pk-desc 31 | :pk-input pk-desc})) 32 | 33 | ;;; Objects 34 | 35 | (def ^:no-doc filter-input-objects 36 | {:StrWhere {:fields {:in {:type '(list String)} 37 | :eq {:type 'String} 38 | :like {:type 'String}}} 39 | :FloatWhere {:fields {:in {:type '(list Float)} 40 | :eq {:type 'Float} 41 | :gt {:type 'Float} 42 | :lt {:type 'Float} 43 | :gte {:type 'Float} 44 | :lte {:type 'Float}}} 45 | :IntWhere {:fields {:in {:type '(list Int)} 46 | :eq {:type 'Int} 47 | :gt {:type 'Int} 48 | :lt {:type 'Int} 49 | :gte {:type 'Int} 50 | :lte {:type 'Int}}} 51 | :BoolWhere {:fields {:eq {:type 'Boolean}}}}) 52 | 53 | (def ^:no-doc result-object {:Result {:fields {:result {:type 'Boolean}}}}) 54 | 55 | (def ^:no-doc result-true-object {:result true}) 56 | 57 | ;;; Resource Object Fields 58 | 59 | (def ^:private field-types 60 | {"int" 'Int 61 | "integer" 'Int 62 | "smallint" 'Int 63 | "bigint" 'Int 64 | "smallserial" 'Int 65 | "serial" 'Int 66 | "bigserial" 'Int 67 | "decimal" 'Float 68 | "numeric" 'Float 69 | "real" 'Float 70 | "double precision" 'Float 71 | "text" 'String 72 | "timestamp" 'String 73 | "character varying" 'String 74 | "varchar" 'String 75 | "character" 'String 76 | "char" 'String 77 | "timestamp without time zone" 'String 78 | "timestamp with time zone" 'String 79 | "interval" 'String 80 | "date" 'String 81 | "time without time zone" 'String 82 | "time with time zone" 'String 83 | "boolean" 'Boolean}) 84 | 85 | (defn- needs-non-null? [col] 86 | (and (= 1 (:notnull col)) (nil? (:dflt_value col)))) 87 | 88 | (defn- rsc-fields [table] 89 | (reduce (fn [m col] 90 | (let [col-name (:name col) 91 | col-key (keyword col-name) 92 | col-type (get field-types (s/lower-case (:type col))) 93 | field (if (needs-non-null? col) 94 | {:type `(~'non-null ~col-type)} 95 | {:type col-type})] 96 | (assoc m col-key field))) 97 | {} (:columns table))) 98 | 99 | ;;; Input Object Fields 100 | 101 | (defn- aggr-fields [rsc-obj-key] 102 | {:count {:type 'Int} 103 | :sum {:type rsc-obj-key} 104 | :avg {:type rsc-obj-key} 105 | :max {:type rsc-obj-key} 106 | :min {:type rsc-obj-key}}) 107 | 108 | ;; Clause Fields 109 | 110 | (def ^:private flt-input-types 111 | {"int" :IntWhere 112 | "integer" :IntWhere 113 | "smallint" :IntWhere 114 | "bigint" :IntWhere 115 | "text" :StrWhere 116 | "smallserial" :IntWhere 117 | "serial" :IntWhere 118 | "bigserial" :IntWhere 119 | "decimal" :FloatWhere 120 | "numeric" :FloatWhere 121 | "real" :FloatWhere 122 | "double precision" :FloatWhere 123 | "timestamp" :StrWhere 124 | "character varying" :StrWhere 125 | "varchar" :StrWhere 126 | "character" :StrWhere 127 | "char" :StrWhere 128 | "timestamp without time zone" :StrWhere 129 | "timestamp with time zone" :StrWhere 130 | "interval" :StrWhere 131 | "date" :StrWhere 132 | "time without time zone" :StrWhere 133 | "time with time zone" :StrWhere 134 | "boolean" :BoolWhere}) 135 | 136 | (defn- clause-fields [table clause-key] 137 | (reduce (fn [m col] 138 | (let [col-name (:name col) 139 | col-key (keyword col-name) 140 | input-type (get flt-input-types (s/lower-case (:type col))) 141 | field {:type input-type}] 142 | (assoc m col-key field))) 143 | {:and {:type `(~'list ~clause-key)} 144 | :or {:type `(~'list ~clause-key)}} 145 | (:columns table))) 146 | 147 | ;; Sort Fields 148 | 149 | (def ^:no-doc sort-op-enum 150 | {:SortOperator {:values [:asc :desc]}}) 151 | 152 | (defn- sort-fields [table] 153 | (reduce (fn [m col] 154 | (let [col-name (:name col) 155 | col-key (keyword col-name) 156 | field {:type :SortOperator}] 157 | (assoc m col-key field))) 158 | {} (:columns table))) 159 | 160 | ;; Primary Key Fields for mutations 161 | 162 | (defn- pk-fields [pk-keys obj-fields] 163 | (reduce (fn [m k] 164 | (let [t (get-in obj-fields [k :type])] 165 | (assoc m k {:type `(~'non-null ~t)}))) 166 | {} pk-keys)) 167 | 168 | (defn- update-fields [pk-keys obj-fields] 169 | (reduce (fn [m k] (dissoc m k)) obj-fields pk-keys)) 170 | 171 | (defn lcn-fields 172 | "Returns Lacinia fields for a table." 173 | [table lcn-keys pk-keys] 174 | (let [rsc-fields (rsc-fields table) 175 | pk-fields (pk-fields pk-keys rsc-fields)] 176 | {:rsc rsc-fields 177 | :clauses (clause-fields table (:clauses lcn-keys)) 178 | :sort (sort-fields table) 179 | :fields rsc-fields 180 | :aggregate (aggr-fields (:fields lcn-keys)) 181 | :pks pk-fields 182 | :pk-input pk-fields 183 | :update (update-fields pk-keys rsc-fields)})) 184 | 185 | ;;; Object/Query/Mutation Keys 186 | 187 | (defn- lcn-obj-key [rsc-name obj-name] 188 | (keyword (str rsc-name obj-name))) 189 | 190 | (defn lcn-obj-keys 191 | "Returns Lacinia object keys for a table." 192 | [table-name] 193 | (let [sgl-pascal (csk/->PascalCase (inf/singular table-name))] 194 | {:rsc (keyword sgl-pascal) 195 | :clauses (lcn-obj-key sgl-pascal "Clauses") 196 | :where (lcn-obj-key sgl-pascal "Where") 197 | :sort (lcn-obj-key sgl-pascal "Sort") 198 | :fields (lcn-obj-key sgl-pascal "Fields") 199 | :aggregate (lcn-obj-key sgl-pascal "Aggregate") 200 | :pks (lcn-obj-key sgl-pascal "Pks") 201 | :pk-input (lcn-obj-key sgl-pascal "PkColumns")})) 202 | 203 | (defn lcn-qry-keys 204 | "Returns Lacinia query keys for a table." 205 | [table-name] 206 | (let [plr-bare (csk/->snake_case (inf/plural table-name))] 207 | {:queries (keyword plr-bare) 208 | :aggregate (keyword (str plr-bare "_aggregate"))})) 209 | 210 | (defn- lcn-mut-key [rsc-name verb] 211 | (keyword (str verb rsc-name))) 212 | 213 | (defn lcn-mut-keys 214 | "Returns Lacinia mutation keys for a table." 215 | [table-name] 216 | (let [sgl-pascal (csk/->PascalCase (inf/singular table-name))] 217 | {:create (lcn-mut-key sgl-pascal "create") 218 | :update (lcn-mut-key sgl-pascal "update") 219 | :delete (lcn-mut-key sgl-pascal "delete")})) 220 | 221 | -------------------------------------------------------------------------------- /src/phrag/logging.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc phrag.logging 2 | (:require [clojure.tools.logging])) 3 | 4 | (defmacro log [level & args] 5 | `(~(condp = level 6 | :debug 'clojure.tools.logging/debug 7 | :info 'clojure.tools.logging/info 8 | :warn 'clojure.tools.logging/warn 9 | :error 'clojure.tools.logging/error) 10 | ~@args)) 11 | -------------------------------------------------------------------------------- /src/phrag/resolver.clj: -------------------------------------------------------------------------------- 1 | (ns phrag.resolver 2 | "Resolvers for Phrag's GraphQL schema." 3 | (:require [clojure.pprint :as pp] 4 | [clojure.walk :as w] 5 | [clojure.set :as clj-set] 6 | [phrag.logging :refer [log]] 7 | [phrag.field :as fld] 8 | [phrag.db.core :as db] 9 | [com.walmartlabs.lacinia.resolve :as resolve])) 10 | 11 | ;;; Resolvers 12 | 13 | (defmacro resolve-error [body] 14 | `(try ~body 15 | (catch Throwable e# 16 | (log :error e#) 17 | (resolve/resolve-as nil {:message (ex-message e#)})))) 18 | 19 | ;; Queries 20 | 21 | (defn resolve-query 22 | "Resolves query recursively for nests if there's any." 23 | [table-key ctx _args _val] 24 | (resolve-error 25 | (let [selection (:com.walmartlabs.lacinia/selection ctx)] 26 | (-> (db/resolve-query (:db-adapter ctx) table-key selection ctx) 27 | (db/signal table-key :query :post ctx))))) 28 | 29 | ;; Aggregates 30 | 31 | (defn aggregate-root 32 | "Resolves aggregation query at root level." 33 | [table-key ctx _args _val] 34 | (resolve-error 35 | (let [selection (:com.walmartlabs.lacinia/selection ctx)] 36 | (db/resolve-aggregation (:db-adapter ctx) table-key selection ctx)))) 37 | 38 | ;; Mutations 39 | 40 | (def ^:private sqlite-last-id 41 | (keyword "last_insert_rowid()")) 42 | 43 | (defn- update-sqlite-pk [res-map pks] 44 | (if (= (count pks) 1) ; only update single pk 45 | (assoc res-map (first pks) (sqlite-last-id res-map)) 46 | res-map)) 47 | 48 | (defn create-root 49 | "Creates root object and attempts to return primary keys. In case of SQLite, 50 | `last_insert_rowid` is checked and replaced with a primary key." 51 | [table-key table ctx args _val] 52 | (resolve-error 53 | (let [{:keys [pk-keys col-keys]} table 54 | params (-> (select-keys args col-keys) 55 | (db/signal table-key :create :pre ctx) 56 | (w/stringify-keys)) 57 | opts {:return-keys pk-keys} 58 | sql-res (first (db/create! (:db ctx) table-key params opts)) 59 | id-res (if (contains? sql-res sqlite-last-id) 60 | (update-sqlite-pk sql-res pk-keys) 61 | sql-res) 62 | res (merge (w/keywordize-keys params) id-res)] 63 | (db/signal res table-key :create :post ctx)))) 64 | 65 | (defn update-root 66 | "Resolves update mutation. Takes `pk_columns` parameter as a record identifier." 67 | [table-key table ctx args _val] 68 | (resolve-error 69 | (let [{:keys [col-keys]} table 70 | sql-args (-> (select-keys args col-keys) 71 | (assoc :pk_columns (:pk_columns args)) 72 | (db/signal table-key :update :pre ctx)) 73 | params (-> (dissoc sql-args :pk_columns) 74 | (w/stringify-keys))] 75 | (db/update! (:db ctx) table-key (:pk_columns sql-args) params) 76 | (db/signal fld/result-true-object table-key :update :post ctx)))) 77 | 78 | (defn delete-root 79 | "Resolves delete mutation. Takes `pk_columns` parameter as a record identifier." 80 | [table-key ctx args _val] 81 | (resolve-error 82 | (let [sql-args (db/signal args table-key :delete :pre ctx)] 83 | (db/delete! (:db ctx) table-key (:pk_columns sql-args)) 84 | (db/signal fld/result-true-object table-key :delete :post ctx)))) 85 | -------------------------------------------------------------------------------- /src/phrag/route.clj: -------------------------------------------------------------------------------- 1 | (ns phrag.route 2 | "Routes + handlders for reitit and bidi." 3 | (:require [clojure.walk :as w] 4 | [integrant.core :as ig] 5 | [ring.util.response :as ring-res] 6 | [phrag.core :as core] 7 | [phrag.context :as ctx])) 8 | 9 | ;;; Reitit 10 | 11 | (defn- rtt-gql-handler [config] 12 | (let [schema (core/schema config)] 13 | (fn [req] 14 | (let [params (:body-params req) 15 | query (:query params) 16 | vars (:variables params)] 17 | {:status 200 18 | :body (core/exec config schema query vars req)})))) 19 | 20 | (defn reitit 21 | "Returns a route setup for reitit at specified path or `/graphql`. 22 | Format: `[\"path\" {:post handler}]`" 23 | [options] 24 | (let [config (ctx/options->config options)] 25 | [(:graphql-path config "/graphql") {:post {:handler (rtt-gql-handler config)} 26 | :middleware (:middleware config)}])) 27 | 28 | (defmethod ig/init-key ::reitit [_ options] 29 | (reitit options)) 30 | 31 | ;;; Bidi 32 | 33 | (defn- bd-gql-handler [config] 34 | (let [schema (core/schema config)] 35 | (fn [req] 36 | (let [params (:params req) 37 | query (get params "query") 38 | vars (w/keywordize-keys (get params "variables"))] 39 | (ring-res/response (core/exec config schema query vars)))))) 40 | 41 | (defn bidi 42 | "Returns a route setup for Bidi at specified path or `/graphql`. 43 | Format: `[\"/\" {\"path\" {:post handler}}]`" 44 | [options] 45 | (let [config (ctx/options->config options)] 46 | ["/" {(:graphql-path config "graphql") {:post (bd-gql-handler config)}}])) 47 | 48 | (defmethod ig/init-key ::bidi [_ options] 49 | (bidi options)) 50 | -------------------------------------------------------------------------------- /src/phrag/table.clj: -------------------------------------------------------------------------------- 1 | (ns phrag.table 2 | "Table data handling for Phrag's GraphQL." 3 | (:require [clojure.string :as s] 4 | [clojure.pprint :as pp] 5 | [phrag.db.core :as db] 6 | [phrag.logging :refer [log]] 7 | [inflections.core :as inf])) 8 | 9 | ;; Table utils 10 | 11 | (defn col-key-set 12 | "Returns a set of column keywords from a table map." 13 | [table] 14 | (set (map #(keyword (:name %)) (:columns table)))) 15 | 16 | (defn pk-keys 17 | "Returns a list of PK keywords from a table map" 18 | [table] 19 | (let [pk-names (map :name (:pks table))] 20 | (map keyword pk-names))) 21 | 22 | (defn circular-m2m-fk? 23 | "Bridge tables of circular many-to-many have 2 columns linked to the 24 | same table. Example: `user_follow` table where following and the followed 25 | are both linked to `users` table." 26 | [table fk-from] 27 | (let [fk-tbls (map :table (:fks table)) 28 | cycl-linked-tbls (set (for [[tbl freq] (frequencies fk-tbls) 29 | :when (> freq 1)] tbl)) 30 | cycl-link-fks (filter #(contains? cycl-linked-tbls (:table %)) 31 | (:fks table))] 32 | (contains? (set (map :from cycl-link-fks)) fk-from))) 33 | 34 | ;;; Table schema map from config 35 | 36 | (defn- table-schema 37 | "Queries table schema including primary keys and foreign keys." 38 | [adapter] 39 | (map (fn [table-name] 40 | {:name table-name 41 | :columns (db/column-info adapter table-name) 42 | :fks (db/foreign-keys adapter table-name) 43 | :pks (db/primary-keys adapter table-name)}) 44 | (map :name (db/table-names adapter)))) 45 | 46 | (defn- view-schema 47 | "Queries views with columns." 48 | [adapter] 49 | (map (fn [view-name] 50 | {:name view-name 51 | :columns (db/column-info adapter view-name)}) 52 | (map :name (db/view-names adapter)))) 53 | 54 | (defn- merge-config-tables [tables config] 55 | (let [cfg-tables (:tables config) 56 | cfg-tbl-names (map :name cfg-tables) 57 | tbl-names (map :name tables) 58 | tbl-name-set (set tbl-names) 59 | cfg-tbl-map (zipmap cfg-tbl-names cfg-tables) 60 | merged (map (fn [table] 61 | (merge table (get cfg-tbl-map (:name table)))) 62 | tables) 63 | cfg-tbl-diff (filter (fn [table] 64 | (not (contains? tbl-name-set (:name table)))) 65 | cfg-tables)] 66 | (concat merged cfg-tbl-diff))) 67 | 68 | (defn- validate-tables [tables] 69 | (reduce (fn [v table] 70 | (if (or (< (count (:columns table)) 1) 71 | (< (count (:pks table)) 1)) 72 | (do (log :warn "No column or primary key for table:" (:name table)) 73 | v) 74 | (conj v table))) 75 | [] tables)) 76 | 77 | (defn db-schema 78 | "Conditionally retrieves DB schema data from a DB connection and merge table 79 | data provided into config if there's any." 80 | [config] 81 | (let [tables (cond-> (if (:scan-tables config) 82 | (table-schema (:db-adapter config)) 83 | (:tables config)) 84 | (:scan-tables config) (merge-config-tables config) 85 | true (validate-tables)) 86 | views (if (:scan-views config) 87 | (view-schema (:db-adapter config)) 88 | nil)] 89 | (log :debug "Origin DB table schema:\n" 90 | (with-out-str (pp/pprint tables))) 91 | (log :debug "Origin DB view schema:\n" 92 | (with-out-str (pp/pprint views))) 93 | {:tables tables 94 | :views views})) 95 | -------------------------------------------------------------------------------- /test/phrag/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns phrag.core-test 2 | (:require [clojure.java.jdbc :as jdbc] 3 | [environ.core :refer [env]] 4 | [clojure.test :refer :all] 5 | [hikari-cp.core :as hkr])) 6 | 7 | (def ^:private pg-members-table 8 | (str "CREATE TABLE IF NOT EXISTS members (" 9 | "id bigserial primary key," 10 | "first_name varchar(128)," 11 | "last_name varchar(128)," 12 | "email varchar(128));")) 13 | 14 | (def ^:private pg-groups-table 15 | (str "CREATE TABLE IF NOT EXISTS groups (" 16 | "id bigserial primary key," 17 | "name varchar(128)," 18 | "created_at timestamp);")) 19 | 20 | (def ^:private pg-venues-table 21 | (str "CREATE TABLE IF NOT EXISTS venues (" 22 | "vid bigserial primary key," 23 | "name varchar(128)," 24 | "postal_code varchar(128));")) 25 | 26 | (def ^:private pg-meetups-table 27 | (str "CREATE TABLE IF NOT EXISTS meetups (" 28 | "id bigserial primary key," 29 | "title varchar(128) not null, " 30 | "start_at timestamp," 31 | "venue_id integer," 32 | "group_id integer," 33 | "foreign key(venue_id) references venues(vid), " 34 | "foreign key(group_id) references groups(id));")) 35 | 36 | (def ^:private pg-member-follow-table 37 | (str "CREATE TABLE IF NOT EXISTS member_follow (" 38 | "created_by integer, " 39 | "member_id integer, " 40 | "foreign key(created_by) references members(id), " 41 | "foreign key(member_id) references members(id), " 42 | "primary key (created_by, member_id));")) 43 | 44 | (def ^:private pg-meetups-members-table 45 | (str "CREATE TABLE IF NOT EXISTS meetups_members (" 46 | "meetup_id integer," 47 | "member_id integer," 48 | "foreign key(meetup_id) references meetups(id), " 49 | "foreign key(member_id) references members(id), " 50 | "primary key (meetup_id, member_id));")) 51 | 52 | (def ^:private pg-groups-members-table 53 | (str "CREATE TABLE IF NOT EXISTS groups_members (" 54 | "group_id integer," 55 | "member_id integer," 56 | "foreign key(group_id) references groups(id), " 57 | "foreign key(member_id) references members(id), " 58 | "primary key (group_id, member_id));")) 59 | 60 | (def ^:private pg-meetups-with-venue-name 61 | (str "CREATE OR REPLACE VIEW meetup_with_venue AS " 62 | "SELECT m.id, " 63 | "m.title, " 64 | "v.vid AS venue_id, " 65 | "v.name AS venue_name " 66 | "FROM meetups AS m " 67 | "JOIN venues AS v ON m.venue_id = v.vid;")) 68 | 69 | (def ^:private pg-clean-up 70 | (str "DELETE FROM meetups_members;" 71 | "DELETE FROM groups_members;" 72 | "DELETE FROM member_follow;" 73 | "DELETE FROM meetups;" 74 | "ALTER SEQUENCE meetups_id_seq RESTART WITH 1;" 75 | "DELETE FROM venues;" 76 | "ALTER SEQUENCE venues_vid_seq RESTART WITH 1;" 77 | "DELETE FROM groups;" 78 | "ALTER SEQUENCE groups_id_seq RESTART WITH 1;" 79 | "DELETE FROM members;" 80 | "ALTER SEQUENCE members_id_seq RESTART WITH 1;")) 81 | 82 | (defn postgres-testable? [] 83 | (and (env :db-name) 84 | (env :db-host) 85 | (env :db-user) 86 | (env :db-pass))) 87 | 88 | (defn postgres-conn [] 89 | (doto {:connection (jdbc/get-connection {:dbtype "postgresql" 90 | :dbname (env :db-name) 91 | :host (env :db-host) 92 | :port (env :db-port 5432) 93 | :user (env :db-user) 94 | :password (env :db-pass) 95 | :stringtype "unspecified"})} 96 | (jdbc/execute! pg-members-table) 97 | (jdbc/execute! pg-groups-table) 98 | (jdbc/execute! pg-venues-table) 99 | (jdbc/execute! pg-meetups-table) 100 | (jdbc/execute! pg-member-follow-table) 101 | (jdbc/execute! pg-meetups-members-table) 102 | (jdbc/execute! pg-groups-members-table) 103 | (jdbc/execute! pg-meetups-with-venue-name) 104 | (jdbc/execute! pg-clean-up))) 105 | 106 | (defn postgres-data-src [] 107 | (let [data-src (delay (hkr/make-datasource {:adapter "postgresql" 108 | :username (env :db-user) 109 | :password (env :db-pass) 110 | :database-name (env :db-name) 111 | :server-name (env :db-host) 112 | :port-number (env :db-port 5432) 113 | ;; :stringtype "unspecified" 114 | :current-schema "public"})) 115 | db {:datasource @data-src}] 116 | (doto db 117 | (jdbc/execute! pg-members-table) 118 | (jdbc/execute! pg-groups-table) 119 | (jdbc/execute! pg-venues-table) 120 | (jdbc/execute! pg-meetups-table) 121 | (jdbc/execute! pg-member-follow-table) 122 | (jdbc/execute! pg-meetups-members-table) 123 | (jdbc/execute! pg-groups-members-table)))) 124 | 125 | (def ^:private sqlite-members-table 126 | (str "CREATE TABLE members (" 127 | ;; Column types in capital 128 | "id INTEGER PRIMARY KEY, " 129 | "first_name TEXT, " 130 | "last_name TEXT, " 131 | "email TEXT);")) 132 | 133 | (def ^:private sqlite-groups-table 134 | (str "CREATE TABLE groups (" 135 | "id integer primary key, " 136 | "name text, " 137 | "created_at timestamp);")) 138 | 139 | (def ^:private sqlite-venues-table 140 | (str "CREATE TABLE venues (" 141 | ;; testing non-"id" naming 142 | "vid integer primary key, " 143 | "name text, " 144 | "postal_code text);")) 145 | 146 | (def ^:private sqlite-meetups-table 147 | (str "CREATE TABLE meetups (" 148 | "id integer primary key, " 149 | "title text not null, " 150 | "start_at timestamp, " 151 | "venue_id int, " 152 | "group_id int, " 153 | "FOREIGN KEY(venue_id) REFERENCES venues(vid), " 154 | "FOREIGN KEY(group_id) REFERENCES groups(id));")) 155 | 156 | (def ^:private sqlite-member-follow-table 157 | (str "CREATE TABLE member_follow (" 158 | "created_by int, " 159 | "member_id int, " 160 | "FOREIGN KEY(created_by) REFERENCES members(id), " 161 | "FOREIGN KEY(member_id) REFERENCES members(id), " 162 | "PRIMARY KEY (created_by, member_id));")) 163 | 164 | (def ^:private sqlite-meetups-members-table 165 | (str "CREATE TABLE meetups_members (" 166 | "meetup_id int, " 167 | "member_id int, " 168 | "FOREIGN KEY(meetup_id) REFERENCES meetups(id), " 169 | "FOREIGN KEY(member_id) REFERENCES members(id), " 170 | "PRIMARY KEY (meetup_id, member_id));")) 171 | 172 | (def ^:private sqlite-groups-members-table 173 | (str "CREATE TABLE groups_members (" 174 | "group_id int, " 175 | "member_id int, " 176 | "FOREIGN KEY(group_id) REFERENCES groups(id), " 177 | "FOREIGN KEY(member_id) REFERENCES members(id), " 178 | "PRIMARY KEY (group_id, member_id));")) 179 | 180 | (def ^:private sqlite-meetups-with-venue-name 181 | (str "CREATE VIEW meetup_with_venue AS " 182 | "SELECT m.id, " 183 | "m.title, " 184 | "v.vid AS venue_id, " 185 | "v.name AS venue_name " 186 | "FROM meetups AS m " 187 | "JOIN venues AS v ON m.venue_id = v.vid;")) 188 | 189 | (defn sqlite-conn [] 190 | (doto {:connection (jdbc/get-connection {:connection-uri "jdbc:sqlite:"})} 191 | (jdbc/execute! sqlite-members-table) 192 | (jdbc/execute! sqlite-groups-table) 193 | (jdbc/execute! sqlite-venues-table) 194 | (jdbc/execute! sqlite-meetups-table) 195 | (jdbc/execute! sqlite-member-follow-table) 196 | (jdbc/execute! sqlite-meetups-members-table) 197 | (jdbc/execute! sqlite-groups-members-table) 198 | (jdbc/execute! sqlite-meetups-with-venue-name))) 199 | 200 | (defn sqlite-data-src [] 201 | (let [data-src (delay (hkr/make-datasource 202 | {:jdbc-url "jdbc:sqlite:"})) 203 | db {:datasource @data-src}] 204 | (doto db 205 | (jdbc/execute! sqlite-members-table) 206 | (jdbc/execute! sqlite-groups-table) 207 | (jdbc/execute! sqlite-venues-table) 208 | (jdbc/execute! sqlite-meetups-table) 209 | (jdbc/execute! sqlite-member-follow-table) 210 | (jdbc/execute! sqlite-meetups-members-table) 211 | (jdbc/execute! sqlite-groups-members-table) 212 | (jdbc/execute! sqlite-meetups-with-venue-name)))) 213 | 214 | -------------------------------------------------------------------------------- /test/phrag/graphql_test.clj: -------------------------------------------------------------------------------- 1 | (ns phrag.graphql-test 2 | (:require [clojure.test :refer :all] 3 | [clojure.java.jdbc :as jdbc] 4 | [environ.core :refer [env]] 5 | [phrag.core :as core] 6 | [phrag.context :as ctx] 7 | [phrag.core-test :as test-core])) 8 | 9 | (defn- run-graphql-tests [db on-postgres] 10 | (let [opt {:db db} 11 | conf (ctx/options->config opt) 12 | schema (core/schema conf) 13 | test-gql (fn [q res-keys expected] 14 | (let [res (core/exec conf schema q nil {})] 15 | (prn res) 16 | (is (= expected (get-in res res-keys)))))] 17 | 18 | ;; Empty Case 19 | 20 | (testing "empty user" 21 | (test-gql "{ members { id email first_name }}" 22 | [:data :members] 23 | [])) 24 | 25 | ;; Root entities 26 | 27 | (testing "create 1st user" 28 | (test-gql (str "mutation {createMember (email: \"jim@test.com\" " 29 | "first_name: \"jim\" last_name: \"smith\") { id }}") 30 | [:data :createMember :id] 1)) 31 | 32 | (testing "create 2nd user" 33 | (test-gql (str "mutation {createMember (email: \"yoshi@test.com\" " 34 | "first_name: \"yoshi\" last_name: \"tanabe\") { id }}") 35 | [:data :createMember :id] 2)) 36 | 37 | (testing "list root type entity" 38 | (test-gql "{ members { id email first_name }}" 39 | [:data :members] 40 | [{:id 1 :email "jim@test.com" :first_name "jim"} 41 | {:id 2 :email "yoshi@test.com" :first_name "yoshi"}])) 42 | 43 | (testing "fetch root type entity" 44 | (test-gql "{ members (where: {id: {eq: 1}}) { id email first_name }}" 45 | [:data :members] 46 | [{:id 1 :email "jim@test.com" :first_name "jim"}])) 47 | 48 | (testing "aggregate root type entity" 49 | (test-gql "{ members_aggregate {count max {id} min {id}}}" 50 | [:data :members_aggregate] 51 | {:count 2 :max {:id 2} :min {:id 1}}) 52 | (test-gql "{ members_aggregate {count max {id email} min {id}}}" 53 | [:data :members_aggregate] 54 | {:count 2 :max {:id 2 :email "yoshi@test.com"} :min {:id 1}})) 55 | 56 | ;; One-to-many relationships 57 | 58 | (testing "create 1st venue" 59 | (test-gql (str "mutation {createVenue (name: \"office one\" " 60 | "postal_code: \"123456\") { vid }}") 61 | [:data :createVenue :vid] 1)) 62 | 63 | (testing "create 2nd venue" 64 | (test-gql (str "mutation {createVenue (name: \"city hall\" " 65 | "postal_code: \"234567\") { vid }}") 66 | [:data :createVenue :vid] 2)) 67 | 68 | (testing "create 1st group" 69 | (test-gql (str "mutation {createGroup (name: \"kafka group\") { id }}") 70 | [:data :createGroup :id] 1)) 71 | 72 | (testing "create 1st meetup under venue 2 and group 1" 73 | (test-gql (str "mutation {createMeetup (title: \"rust meetup\" " 74 | "start_at: \"2021-01-01 18:00:00\" venue_id: 2 group_id: 1) " 75 | "{ id }}") 76 | [:data :createMeetup :id] 1)) 77 | 78 | (testing "create 2nd meetup under venue 1" 79 | (test-gql (str "mutation {createMeetup (title: \"cpp meetup\" " 80 | "start_at: \"2021-01-12 18:00:00\" venue_id: 1) { id }}") 81 | [:data :createMeetup :id] 2)) 82 | 83 | (let [exp-time-1 (if on-postgres 84 | "2021-01-01T18:00:00" 85 | "2021-01-01 18:00:00") 86 | exp-time-2 (if on-postgres 87 | "2021-01-12T18:00:00" 88 | "2021-01-12 18:00:00") ] 89 | (testing "list entities with has-one param" 90 | (test-gql (str "{ meetups { id title start_at venue_id " 91 | "venue { vid name }}}") 92 | [:data :meetups] 93 | [{:id 1 :title "rust meetup" :start_at exp-time-1 94 | :venue_id 2 :venue {:vid 2 :name "city hall"}} 95 | {:id 2 :title "cpp meetup" :start_at exp-time-2 96 | :venue_id 1 :venue {:vid 1 :name "office one"}}])) 97 | 98 | (testing "fetch entity with has-one param" 99 | (test-gql (str "{ meetups (where: {id: {eq: 1}}) " 100 | "{ id title start_at venue_id venue { vid name }}}") 101 | [:data :meetups] 102 | [{:id 1 :title "rust meetup" :start_at exp-time-1 103 | :venue_id 2 :venue {:vid 2 :name "city hall"}}]) 104 | (test-gql (str "{ meetups (where: {id: {eq: 2}}) " 105 | "{ id title start_at venue_id venue { vid name }}}") 106 | [:data :meetups] 107 | [{:id 2 :title "cpp meetup" :start_at exp-time-2 108 | :venue_id 1 :venue {:vid 1 :name "office one"}}]))) 109 | 110 | (testing "list entities with has-many param" 111 | (test-gql (str "{ venues { vid name postal_code meetups { id title }}}") 112 | [:data :venues] 113 | [{:vid 1 :name "office one" :postal_code "123456" 114 | :meetups [{:id 2 :title "cpp meetup"}]} 115 | {:vid 2 :name "city hall" :postal_code "234567" 116 | :meetups [{:id 1 :title "rust meetup"}]}])) 117 | 118 | (testing "list entities with has-many param and aggregation" 119 | (test-gql (str "{ venues { vid name postal_code meetups { id title } " 120 | "meetups_aggregate {count max {id title} min {id}}}}") 121 | [:data :venues] 122 | [{:vid 1 :name "office one" :postal_code "123456" 123 | :meetups [{:id 2 :title "cpp meetup"}] 124 | :meetups_aggregate {:count 1 :min {:id 2} 125 | :max {:id 2 :title "cpp meetup"}}} 126 | {:vid 2 :name "city hall" :postal_code "234567" 127 | :meetups [{:id 1 :title "rust meetup"}] 128 | :meetups_aggregate {:count 1 :min {:id 1} 129 | :max {:id 1 :title "rust meetup"}}}])) 130 | 131 | (testing "fetch entity with has-many param" 132 | (test-gql (str "{ venues (where: {vid: {eq: 1}}) " 133 | "{ name postal_code meetups { id title }}}") 134 | [:data :venues] 135 | [{:name "office one" :postal_code "123456" 136 | :meetups [{:id 2 :title "cpp meetup"}]}]) 137 | (test-gql (str "{ venues (where: {vid: {eq: 2}}) " 138 | "{ name postal_code meetups { id title }}}") 139 | [:data :venues] 140 | [{:name "city hall" :postal_code "234567" 141 | :meetups [{:id 1 :title "rust meetup"}]}])) 142 | 143 | (testing "fetch entity with has-many param and aggregate" 144 | (test-gql (str "{ venues (where: {vid: {eq: 1}}) " 145 | "{ name postal_code meetups { id title } " 146 | "meetups_aggregate {count max {id} min {id}}}}") 147 | [:data :venues] 148 | [{:name "office one" :postal_code "123456" 149 | :meetups [{:id 2 :title "cpp meetup"}] 150 | :meetups_aggregate {:count 1 :min {:id 2} :max {:id 2}}}])) 151 | 152 | ;; Many-to-many relationships 153 | 154 | (testing "add member 1 to meetup 1" 155 | (test-gql (str "mutation {createMeetupsMember (meetup_id: 1" 156 | "member_id: 1) { meetup_id member_id }}") 157 | [:data :createMeetupsMember] 158 | {:meetup_id 1 :member_id 1})) 159 | 160 | (testing "add member 1 to meetup 2" 161 | (test-gql (str "mutation {createMeetupsMember (meetup_id: 2" 162 | "member_id: 1) { meetup_id member_id }}") 163 | [:data :createMeetupsMember] 164 | {:meetup_id 2 :member_id 1})) 165 | 166 | (testing "add member 2 to meetup 1" 167 | (test-gql (str "mutation {createMeetupsMember (meetup_id: 1" 168 | "member_id: 2) { meetup_id member_id }}") 169 | [:data :createMeetupsMember] 170 | {:meetup_id 1 :member_id 2})) 171 | 172 | (testing "list entities with many-to-many param" 173 | (test-gql "{ members { email meetups_members { meetup { id title }}}}" 174 | [:data :members] 175 | [{:email "jim@test.com" 176 | :meetups_members [{:meetup {:id 1 :title "rust meetup"}} 177 | {:meetup {:id 2 :title "cpp meetup"}}]} 178 | {:email "yoshi@test.com" 179 | :meetups_members [{:meetup {:id 1 :title "rust meetup"}}]}])) 180 | 181 | (testing "list entities with limit on nested has-many query" 182 | (test-gql (str "{ members { email meetups_members (limit: 1) " 183 | "{ meetup { id title }}}}") 184 | [:data :members] 185 | [{:email "jim@test.com" 186 | :meetups_members [{:meetup {:id 1 :title "rust meetup"}}]} 187 | {:email "yoshi@test.com" 188 | :meetups_members [{:meetup {:id 1 :title "rust meetup"}}]}]) 189 | (test-gql (str "{ members { email meetups_members (limit: 1, offset: 1) " 190 | "{ meetup { id title }}}}") 191 | [:data :members] 192 | [{:email "jim@test.com" 193 | :meetups_members [{:meetup {:id 2 :title "cpp meetup"}}]} 194 | {:email "yoshi@test.com" 195 | :meetups_members []}]) 196 | (test-gql (str "{ members { email meetups_members " 197 | "(sort: {meetup_id: desc}, limit:1, offset: 1) " 198 | "{ meetup { id title }}}}") 199 | [:data :members] 200 | [{:email "jim@test.com" 201 | :meetups_members [{:meetup {:id 1 :title "rust meetup"}}]} 202 | {:email "yoshi@test.com" 203 | :meetups_members []}])) 204 | 205 | (testing "list entities with many-to-many param and aggregation" 206 | (test-gql (str "{ members { email meetups_members { meetup { id title }} " 207 | "meetups_members_aggregate { count " 208 | "max { meetup_id member_id } " 209 | "min { meetup_id member_id }}}}") 210 | [:data :members] 211 | [{:email "jim@test.com" 212 | :meetups_members [{:meetup {:id 1 :title "rust meetup"}} 213 | {:meetup {:id 2 :title "cpp meetup"}}] 214 | :meetups_members_aggregate {:count 2 215 | :max {:meetup_id 2 :member_id 1} 216 | :min {:meetup_id 1 :member_id 1}}} 217 | {:email "yoshi@test.com" 218 | :meetups_members [{:meetup {:id 1 :title "rust meetup"}}] 219 | :meetups_members_aggregate 220 | {:count 1 221 | :max {:meetup_id 1 :member_id 2} 222 | :min {:meetup_id 1 :member_id 2}}}])) 223 | 224 | (testing "list entities with many-to-many param and filtered aggregation" 225 | (test-gql (str "{ members { email meetups_members { meetup { id title }} " 226 | "meetups_members_aggregate (where: {meetup_id: {lt: 2}}) " 227 | "{ count max { meetup_id member_id } " 228 | "min { meetup_id member_id }}}}") 229 | [:data :members] 230 | [{:email "jim@test.com" 231 | :meetups_members [{:meetup {:id 1 :title "rust meetup"}} 232 | {:meetup {:id 2 :title "cpp meetup"}}] 233 | :meetups_members_aggregate {:count 1 234 | :max {:meetup_id 1 :member_id 1} 235 | :min {:meetup_id 1 :member_id 1}}} 236 | {:email "yoshi@test.com" 237 | :meetups_members [{:meetup {:id 1 :title "rust meetup"}}] 238 | :meetups_members_aggregate 239 | {:count 1 240 | :max {:meetup_id 1 :member_id 2} 241 | :min {:meetup_id 1 :member_id 2}}}])) 242 | 243 | (testing "fetch entity with many-to-many param" 244 | (test-gql (str "{ members (where: {id: {eq: 1}}) " 245 | "{ email meetups_members {meetup { id title }}}}") 246 | [:data :members] 247 | [{:email "jim@test.com" 248 | :meetups_members [{:meetup {:id 1 :title "rust meetup"}} 249 | {:meetup {:id 2 :title "cpp meetup"}}]}]) 250 | (test-gql (str "{ members (where: {id: {eq: 2}}) " 251 | "{ email meetups_members {meetup { id title }}}}") 252 | [:data :members] 253 | [{:email "yoshi@test.com" 254 | :meetups_members [{:meetup {:id 1 :title "rust meetup"}}]}])) 255 | 256 | (testing "fetch entity with many-to-many param and aggregation" 257 | (test-gql (str "{ members (where: {id: {eq: 1}}) " 258 | "{ email meetups_members {meetup { id title }} " 259 | "meetups_members_aggregate {count " 260 | "max {meetup_id member_id} min {meetup_id member_id}}}}") 261 | [:data :members] 262 | [{:email "jim@test.com" 263 | :meetups_members [{:meetup {:id 1 :title "rust meetup"}} 264 | {:meetup {:id 2 :title "cpp meetup"}}] 265 | :meetups_members_aggregate {:count 2 266 | :max {:meetup_id 2 :member_id 1} 267 | :min {:meetup_id 1 :member_id 1}}}]) 268 | (test-gql (str "{ members (where: {id: {eq: 2}}) " 269 | "{ email meetups_members {meetup { id title }} " 270 | "meetups_members_aggregate {count " 271 | "max {meetup_id member_id} min {meetup_id member_id}}}}") 272 | [:data :members] 273 | [{:email "yoshi@test.com" 274 | :meetups_members [{:meetup {:id 1 :title "rust meetup"}}] 275 | :meetups_members_aggregate 276 | {:count 1 277 | :max {:meetup_id 1 :member_id 2} 278 | :min {:meetup_id 1 :member_id 2}}}])) 279 | 280 | ;; Circular many-to-many relationship 281 | 282 | (testing "add member 2 follow to member 1" 283 | (test-gql (str "mutation {createMemberFollow (member_id: 2" 284 | "created_by: 1) { member_id created_by }}") 285 | [:data :createMemberFollow] 286 | {:member_id 2 :created_by 1})) 287 | 288 | (testing "list entities with circular many-to-many pararm" 289 | (test-gql (str "{ members { first_name member_follows_on_created_by " 290 | "{ member { first_name }}}}") 291 | [:data :members] 292 | [{:first_name "jim" 293 | :member_follows_on_created_by 294 | [{:member {:first_name "yoshi"}}]} 295 | {:first_name "yoshi" 296 | :member_follows_on_created_by []}]) 297 | (test-gql (str "{ members { first_name member_follows_on_member_id " 298 | "{ created_by_member { first_name }}}}") 299 | [:data :members] 300 | [{:first_name "jim" 301 | :member_follows_on_member_id []} 302 | {:first_name "yoshi" 303 | :member_follows_on_member_id 304 | [{:created_by_member {:first_name "jim"}}]}])) 305 | 306 | (testing "list entities with circular many-to-many param and aggregation" 307 | (test-gql (str "{ members { first_name member_follows_on_created_by " 308 | "{ member { first_name }}" 309 | "member_follows_on_created_by_aggregate { count " 310 | "max { member_id created_by } " 311 | "min { member_id created_by }}}}") 312 | [:data :members] 313 | [{:first_name "jim" 314 | :member_follows_on_created_by 315 | [{:member {:first_name "yoshi"}}] 316 | :member_follows_on_created_by_aggregate 317 | {:count 1 318 | :max {:member_id 2 :created_by 1} 319 | :min {:member_id 2 :created_by 1}}} 320 | {:first_name "yoshi" 321 | :member_follows_on_created_by [] 322 | :member_follows_on_created_by_aggregate 323 | {:count 0 324 | :max {:member_id nil :created_by nil} 325 | :min {:member_id nil :created_by nil}}}]) 326 | (test-gql (str "{ members { first_name member_follows_on_member_id " 327 | "{ created_by_member { first_name }}" 328 | "member_follows_on_member_id_aggregate { count " 329 | "max { member_id created_by } " 330 | "min { member_id created_by }}}}") 331 | [:data :members] 332 | [{:first_name "jim" 333 | :member_follows_on_member_id [] 334 | :member_follows_on_member_id_aggregate 335 | {:count 0 336 | :max {:member_id nil :created_by nil} 337 | :min {:member_id nil :created_by nil}}} 338 | {:first_name "yoshi" 339 | :member_follows_on_member_id 340 | [{:created_by_member {:first_name "jim"}}] 341 | :member_follows_on_member_id_aggregate 342 | {:count 1 343 | :max {:member_id 2 :created_by 1} 344 | :min {:member_id 2 :created_by 1}}}])) 345 | 346 | (testing "add member 1 follow to member 2" 347 | (test-gql (str "mutation {createMemberFollow (member_id: 1" 348 | "created_by: 2) { member_id created_by }}") 349 | [:data :createMemberFollow] 350 | {:member_id 1 :created_by 2})) 351 | 352 | (testing "list both entities of circular many-to-many relationship" 353 | (test-gql (str "{ members { first_name " 354 | "member_follows_on_created_by { member { first_name }} " 355 | "member_follows_on_member_id { created_by_member " 356 | "{ first_name }}}}") 357 | [:data :members] 358 | [{:first_name "jim" 359 | :member_follows_on_member_id 360 | [{:created_by_member {:first_name "yoshi"}}] 361 | :member_follows_on_created_by 362 | [{:member {:first_name "yoshi"}}]} 363 | {:first_name "yoshi" 364 | :member_follows_on_member_id 365 | [{:created_by_member {:first_name "jim"}}] 366 | :member_follows_on_created_by 367 | [{:member {:first_name "jim"}}]}])) 368 | 369 | ;; Filters 370 | (testing "list entity with where arg" 371 | (test-gql (str "{ members (where: {first_name: {eq: \"yoshi\"}}) " 372 | "{ id last_name }}") 373 | [:data :members] 374 | [{:id 2 :last_name "tanabe"}]) 375 | (test-gql (str "{ members (where: {first_name: {eq: \"yoshi\"} " 376 | "last_name: {eq: \"unknown\"}}) { id last_name }}") 377 | [:data :members] 378 | [])) 379 | 380 | (testing "list entity with AND group" 381 | (test-gql (str "{ members (where: " 382 | "{ and: [{id: {gt: 1}}, {first_name: {eq: \"yoshi\"}}]}) " 383 | "{ id last_name }}") 384 | [:data :members] 385 | [{:id 2 :last_name "tanabe"}]) 386 | (test-gql (str "{ members (where: " 387 | "{ and: [{id: {gt: 0}}, {first_name: {eq: \"jim\"}}]}) " 388 | "{ id last_name }}") 389 | [:data :members] 390 | [{:id 1 :last_name "smith"}])) 391 | 392 | (testing "list entity with OR group" 393 | (test-gql (str "{ members (where: " 394 | "{ or: [{id: {eq: 1}}, {first_name: {eq: \"yoshi\"}}]}) " 395 | "{ id last_name }}") 396 | [:data :members] 397 | [{:id 1 :last_name "smith"} 398 | {:id 2 :last_name "tanabe"}]) 399 | (test-gql (str "{ members (where: " 400 | "{ or: [{id: {gt: 1}}, {first_name: {eq: \"yoshi\"}}]}) " 401 | "{ id last_name }}") 402 | [:data :members] 403 | [{:id 2 :last_name "tanabe"}])) 404 | 405 | (testing "list entity with nested OR group" 406 | (test-gql (str "{ members (where: " 407 | "{ or: [" 408 | " {and: [{id: {eq: 1}}, {first_name: {eq: \"yoshi\"}}]}" 409 | " {and: [{id: {eq: 2}}, {first_name: {eq: \"yoshi\"}}]}" 410 | " ]}) { id last_name }}") 411 | [:data :members] 412 | [{:id 2 :last_name "tanabe"}]) 413 | (test-gql (str "{ members (where: " 414 | "{ or: [" 415 | " {and: [{id: {eq: 1}}, {first_name: {eq: \"yoshi\"}}]}" 416 | " {and: [{id: {eq: 2}}, {first_name: {eq: \"unknown\"}}]}" 417 | " ]}) { id last_name }}") 418 | [:data :members] 419 | [])) 420 | 421 | (testing "fetch entity with has-many param filtered with where" 422 | (test-gql (str "{ venues (where: {vid: {eq: 1}}) " 423 | "{ name postal_code meetups { id title }}}") 424 | [:data :venues] 425 | [{:name "office one" :postal_code "123456" 426 | :meetups [{:id 2 :title "cpp meetup"}]}]) 427 | (test-gql (str "{ venues (where: {vid: {eq: 1}}) " 428 | "{ name postal_code meetups " 429 | "(where: {title: {like: \"%rust%\"}}) { id title }}}") 430 | [:data :venues] 431 | [{:name "office one" :postal_code "123456" 432 | :meetups []}])) 433 | 434 | (testing "list entity with possibly ambiguous filter of id" 435 | (test-gql (str "{ meetups (where: {id: {eq: 1}}) { id title group { " 436 | "id name }}}") 437 | [:data :meetups] 438 | [{:id 1 :title "rust meetup" 439 | :group {:id 1 :name "kafka group"}}])) 440 | 441 | ;; Pagination 442 | (testing "list entity with pagination" 443 | (test-gql "{ members (limit: 1) { id first_name }}" 444 | [:data :members] 445 | [{:id 1 :first_name "jim"}]) 446 | (test-gql "{ members (limit: 1, offset: 1) { id first_name }}" 447 | [:data :members] 448 | [{:id 2 :first_name "yoshi"}])) 449 | 450 | ;; Sorting 451 | (testing "list entity sorted" 452 | (test-gql "{ members (sort: {first_name: desc}) { id first_name }}" 453 | [:data :members] 454 | [{:id 2 :first_name "yoshi"} 455 | {:id 1 :first_name "jim"}]) 456 | (test-gql "{ members (sort: {first_name: asc}) { id first_name }}" 457 | [:data :members] 458 | [{:id 1 :first_name "jim"} 459 | {:id 2 :first_name "yoshi"}])) 460 | 461 | ;; Update mutation 462 | (testing "update entity" 463 | (test-gql (str "mutation {updateMember " 464 | "(pk_columns: {id: 1} email: \"ken@test.com\" " 465 | "first_name: \"Ken\" last_name: \"Spencer\") {result}}") 466 | [:data :updateMember :result] 467 | true) 468 | (test-gql (str "{ members (where: {id: {eq: 1}}) " 469 | "{ id first_name last_name email}}") 470 | [:data :members] 471 | [{:id 1 :first_name "Ken" :last_name "Spencer" 472 | :email "ken@test.com"}])) 473 | 474 | ;; Delete mutation 475 | (testing "delete entity" 476 | (test-gql "{ member_follows { member_id created_by}}" 477 | [:data :member_follows] 478 | [{:member_id 2 :created_by 1} 479 | {:member_id 1 :created_by 2}]) 480 | (test-gql (str "mutation {deleteMemberFollow (" 481 | "pk_columns: {created_by: 1, member_id: 2}) { result }}") 482 | [:data :deleteMemberFollow :result] 483 | true) 484 | (test-gql "{ member_follows { member_id created_by}}" 485 | [:data :member_follows] 486 | [{:member_id 1 :created_by 2}])) 487 | 488 | ;; View query 489 | (testing "list meetups from view" 490 | (test-gql (str "{ meetup_with_venues " 491 | "{ id title venue_id venue_name }}") 492 | [:data :meetup_with_venues] 493 | [{:id 1, :title "rust meetup" :venue_id 2 494 | :venue_name "city hall"} 495 | {:id 2, :title "cpp meetup" :venue_id 1 496 | :venue_name "office one"}]) 497 | (test-gql (str "{ meetup_with_venues " 498 | "(where: { venue_name: {eq: \"office one\"}}) " 499 | "{ id title venue_id venue_name }}") 500 | [:data :meetup_with_venues] 501 | [{:id 2, :title "cpp meetup" :venue_id 1 502 | :venue_name "office one"}]) 503 | (test-gql (str "{ meetup_with_venues " 504 | "(limit: 1, sort: {venue_id: asc}) " 505 | "{ id title venue_id venue_name }}") 506 | [:data :meetup_with_venues] 507 | [{:id 2, :title "cpp meetup" :venue_id 1 508 | :venue_name "office one"}])) 509 | 510 | (testing "aggregate meetups from view" 511 | (test-gql (str "{ meetup_with_venues_aggregate { count }}") 512 | [:data :meetup_with_venues_aggregate] 513 | {:count 2}) 514 | (test-gql (str "{ meetup_with_venues_aggregate { max { venue_id }}}") 515 | [:data :meetup_with_venues_aggregate] 516 | {:max {:venue_id 2}}) 517 | (test-gql (str "{ meetup_with_venues_aggregate { min { venue_id }}}") 518 | [:data :meetup_with_venues_aggregate] 519 | {:min {:venue_id 1}})))) 520 | 521 | (deftest graphql-queries [] 522 | (if (test-core/postgres-testable?) 523 | (do (run-graphql-tests (test-core/postgres-conn) true) 524 | (run-graphql-tests (test-core/sqlite-conn) false)) 525 | (run-graphql-tests (test-core/sqlite-conn) false))) 526 | 527 | ;;; Testing signals 528 | 529 | (defn- change-where-from-ctx [selection ctx] 530 | ;; Apply filter with email from ctx 531 | (assoc-in selection [:arguments :where :email] {:eq (:email ctx)})) 532 | 533 | (defn- change-res-from-ctx [res ctx] 534 | ;; Replace first_name with one from ctx 535 | (map #(assoc % :first_name (:first-name ctx)) res)) 536 | 537 | (defn- change-arg-from-ctx [args ctx] 538 | ;; Replace email with one from ctx 539 | (assoc args :email (:email ctx))) 540 | 541 | (defn- change-id-to-count [res _ctx] 542 | ;; expected: 7 543 | ;; id: 2 544 | ;; res keys: (:email :first_name :last_name :last_insert_rowid() :id) 545 | (assoc res :id (+ (:id res) (count (keys res))))) 546 | 547 | (defn- increment-id-count [res _ctx] 548 | (update res :id + 1)) 549 | 550 | (defn- members-pre-update [_args _ctx] 551 | nil) 552 | 553 | (defn- members-post-update [_args _ctx] 554 | {:result true}) 555 | 556 | (defn- run-graphql-signal-tests [db] 557 | (let [db (doto db 558 | (jdbc/insert! :members {:email "jim@test.com" 559 | :first_name "jim" 560 | :last_name "smith"})) 561 | opt {:db db 562 | :tables [{:name "members" 563 | :columns [{:name "id" :type "integer"} 564 | {:name "first_name" :type "text"} 565 | {:name "last_name" :type "text"} 566 | {:name "email" :type "text"}] 567 | :table-type :root 568 | :fks [] 569 | :pks [{:name "id" :type "integer"}]}] 570 | :scan-tables false 571 | :use-aggregation true 572 | :signal-ctx {:email "context@test.com" 573 | :first-name "context-first-name"} 574 | :signals {:members {:query {:pre [change-where-from-ctx] 575 | :post [change-res-from-ctx]} 576 | :create {:pre change-arg-from-ctx 577 | :post [change-id-to-count 578 | increment-id-count]} 579 | :update {:pre members-pre-update 580 | :post members-post-update}}}} 581 | conf (ctx/options->config opt) 582 | schema (core/schema conf) 583 | test-gql (fn [q res-keys expected] 584 | (let [res (core/exec conf schema q nil {})] 585 | (prn res) 586 | (is (= expected (get-in res res-keys)))))] 587 | 588 | (testing "post-create signal mutate with ctx" 589 | (test-gql (str "mutation {createMember (email: \"input-email\" " 590 | "first_name: \"yoshi\" last_name: \"tanabe\") { id }}") 591 | [:data :createMember :id] 8)) 592 | 593 | (testing "pre-create / post-query signal" 594 | (test-gql "{ members (where: {email: {eq: \"a\"}}) { id email first_name }}" 595 | [:data :members] 596 | [{:id 2 :email "context@test.com" 597 | :first_name "context-first-name"}])) 598 | 599 | (testing "pre-update signal" 600 | (let [q (str "mutation { updateMember (pk_columns: {id: 2}" 601 | "email: \"fake-email\" " 602 | "first_name: \"fake-first-name\") { result }}") 603 | res (core/exec conf schema q nil {})] 604 | (is (= (get-in res [:data :updateMember :result]) nil)) 605 | (is (= (:message (first (:errors res))) 606 | "These SQL clauses are unknown or have nil values: :set")))))) 607 | 608 | (deftest graphql-signals 609 | (run-graphql-signal-tests (test-core/sqlite-conn))) 610 | 611 | (defn- run-graphql-config-tests [db] 612 | (let [opt {:db db 613 | :default-limit 2 614 | :max-nest-level 2} 615 | conf (ctx/options->config opt) 616 | schema (core/schema conf) 617 | test-gql (fn [q res-keys expected] 618 | (let [res (core/exec conf schema q nil {})] 619 | (prn res) 620 | (is (= expected (get-in res res-keys)))))] 621 | 622 | (testing "create 1st venue" 623 | (test-gql (str "mutation {createVenue (name: \"office one\" " 624 | "postal_code: \"123456\") { vid }}") 625 | [:data :createVenue :vid] 1)) 626 | 627 | (testing "create 2nd venue" 628 | (test-gql (str "mutation {createVenue (name: \"city hall\" " 629 | "postal_code: \"234567\") { vid }}") 630 | [:data :createVenue :vid] 2)) 631 | 632 | (testing "create 3rd venue" 633 | (test-gql (str "mutation {createVenue (name: \"city square\" " 634 | "postal_code: \"34567\") { vid }}") 635 | [:data :createVenue :vid] 3)) 636 | 637 | (testing "create 1st meetup under venue 3" 638 | (test-gql (str "mutation {createMeetup (title: \"rust meetup\" " 639 | "start_at: \"2021-01-01 18:00:00\" venue_id: 3) { id }}") 640 | [:data :createMeetup :id] 1)) 641 | 642 | (testing "create 2nd meetup under venue 3" 643 | (test-gql (str "mutation {createMeetup (title: \"cpp meetup\" " 644 | "start_at: \"2021-01-12 18:00:00\" venue_id: 3) { id }}") 645 | [:data :createMeetup :id] 2)) 646 | 647 | (testing "create 3nd meetup under venue 3" 648 | (test-gql (str "mutation {createMeetup (title: \"erlang meetup\" " 649 | "start_at: \"2021-09-29 18:00:00\" venue_id: 3) { id }}") 650 | [:data :createMeetup :id] 3)) 651 | 652 | (testing "default limit of 2 is applied for root entities" 653 | (test-gql (str "{ meetups { id title start_at venue_id " 654 | "venue { vid name }}}") 655 | [:data :meetups] 656 | [{:id 1 :title "rust meetup" :start_at "2021-01-01 18:00:00" 657 | :venue_id 3 :venue {:vid 3 :name "city square"}} 658 | {:id 2 :title "cpp meetup" :start_at "2021-01-12 18:00:00" 659 | :venue_id 3 :venue {:vid 3 :name "city square"}}])) 660 | 661 | (testing "override limit of 3 is applied for root entities" 662 | (test-gql (str "{ meetups (limit: 3) { id title start_at venue_id " 663 | "venue { vid name }}}") 664 | [:data :meetups] 665 | [{:id 1 :title "rust meetup" :start_at "2021-01-01 18:00:00" 666 | :venue_id 3 :venue {:vid 3 :name "city square"}} 667 | {:id 2 :title "cpp meetup" :start_at "2021-01-12 18:00:00" 668 | :venue_id 3 :venue {:vid 3 :name "city square"}} 669 | {:id 3 :title "erlang meetup" :start_at "2021-09-29 18:00:00" 670 | :venue_id 3 :venue {:vid 3 :name "city square"}}])) 671 | 672 | (testing "nest level of 1 works while max nest level is 2" 673 | (test-gql (str "{ meetups { id title start_at venue_id " 674 | "venue { vid name }}}") 675 | [:data :meetups] 676 | [{:id 1 :title "rust meetup" :start_at "2021-01-01 18:00:00" 677 | :venue_id 3 :venue {:vid 3 :name "city square"}} 678 | {:id 2 :title "cpp meetup" :start_at "2021-01-12 18:00:00" 679 | :venue_id 3 :venue {:vid 3 :name "city square"}}])) 680 | 681 | (testing "max nest level of 2 throws an exception" 682 | (let [q (str "{ meetups { id title start_at venue_id " 683 | "venue { vid name meetups { id title venue { vid }}}}}") 684 | res (core/exec conf schema q nil {})] 685 | (is (= (get-in res [:data :meetups]) nil)) 686 | (is (= (:message (first (:errors res))) 687 | "Exceeded maximum nest level.")))))) 688 | 689 | (deftest graphql-config 690 | (run-graphql-config-tests (test-core/sqlite-conn))) 691 | -------------------------------------------------------------------------------- /test/phrag/table_test.clj: -------------------------------------------------------------------------------- 1 | (ns phrag.table-test 2 | (:require [clojure.test :refer :all] 3 | [clojure.set :as st] 4 | [clojure.java.jdbc :as jdbc] 5 | [com.walmartlabs.lacinia :as lcn] 6 | [phrag.core-test :refer [sqlite-conn]] 7 | [phrag.db.adapter :as db-adapter] 8 | [phrag.table :as tbl])) 9 | 10 | ;; Schema data validation 11 | 12 | (defn- subset-maps? [expected subject id-key] 13 | (let [exp-map (zipmap (map id-key expected) expected) 14 | sbj-map (zipmap (map id-key subject) subject)] 15 | (every? (fn [[k v]] 16 | (is (st/subset? (set v) (set (get sbj-map k))))) 17 | exp-map))) 18 | 19 | (defn- schema-as-expected? [expected subject] 20 | (let [exp-map (zipmap (map :name expected) expected) 21 | sbj-map (zipmap (map :name subject) subject)] 22 | (is (not (or (nil? subject) (empty? subject)))) 23 | (every? (fn [[sbj-tbl-name sbj-tbl]] 24 | (let [exp-tbl (get exp-map sbj-tbl-name)] 25 | (every? (fn [k] 26 | (cond 27 | (= :name k) (is (:name exp-tbl) (:name sbj-tbl)) 28 | (= :table-type k) (is (:table-type exp-tbl) 29 | (:table-type sbj-tbl)) 30 | (= :columns k) (subset-maps? (:columns exp-tbl) 31 | (:columns sbj-tbl) :name) 32 | (= :fks k) (subset-maps? (:fks exp-tbl) 33 | (:fks sbj-tbl) :from) 34 | (= :pks k) (subset-maps? (:pks exp-tbl) 35 | (:pks sbj-tbl) :name))) 36 | (keys exp-tbl)))) 37 | sbj-map))) 38 | 39 | ;; Expected table data 40 | 41 | (def ^:private members 42 | {:name "members" 43 | ;; Column type info in capital. 44 | :columns [{:name "id" :type "INTEGER"} 45 | {:name "first_name" :type "TEXT"} 46 | {:name "last_name" :type "TEXT"} 47 | {:name "email" :type "TEXT"}] 48 | :fks [] 49 | :pks [{:name "id" :type "INTEGER"}]}) 50 | 51 | (def ^:private groups 52 | {:name "groups" 53 | :columns [{:name "id" :type "integer"} 54 | {:name "name" :type "text"} 55 | {:name "created_at" :type "timestamp"}] 56 | :fks [] 57 | :pks [{:name "id" :type "integer"}]}) 58 | 59 | (def ^:private venues 60 | {:name "venues" 61 | :columns [{:name "vid" :type "integer"} 62 | {:name "name" :type "text"} 63 | {:name "postal_code" :type "text"}] 64 | :fks [] 65 | :pks [{:name "vid" :type "integer"}]}) 66 | 67 | (def ^:private meetups 68 | {:name "meetups" 69 | :columns [{:name "id" :type "integer"} 70 | {:name "title" :type "text"} 71 | {:name "start_at" :type "timestamp"} 72 | {:name "venue_id" :type "int"} 73 | {:name "group_id" :type "int"}] 74 | :fks [{:table "venues" :from "venue_id" :to "vid"} 75 | {:table "groups" :from "group_id" :to "id"}] 76 | :pks [{:name "id" :type "integer"}]}) 77 | 78 | (def ^:private meetups-members 79 | {:name "meetups_members" 80 | :columns [{:name "meetup_id" :type "int"} 81 | {:name "member_id" :type "int"}] 82 | :fks [{:table "meetups" :from "meetup_id" :to "id"} 83 | {:table "members" :from "member_id" :to "id"}] 84 | :pks [{:name "meetup_id" :type "int"} 85 | {:name "member_id" :type "int"}]}) 86 | 87 | (deftest db-schema-with-fks 88 | (let [db (sqlite-conn)] 89 | (testing "scan DB with fk: no config table data" 90 | (schema-as-expected? 91 | [members 92 | groups 93 | venues 94 | meetups 95 | meetups-members] 96 | (-> (tbl/db-schema {:db db 97 | :db-adapter (db-adapter/db->adapter db) 98 | :scan-tables true 99 | :tables []}) 100 | :tables))) 101 | 102 | (testing "scan DB with fk: additional config table data" 103 | (let [extra-table {:name "extra" 104 | :columns [{:name "extra-column" :type "extra-type"}] 105 | :pks [{:name "extra-column" :type "extra-type"}]}] 106 | (schema-as-expected? 107 | [members 108 | groups 109 | venues 110 | meetups 111 | meetups-members 112 | extra-table] 113 | (-> (tbl/db-schema {:db db 114 | :db-adapter (db-adapter/db->adapter db) 115 | :scan-tables true 116 | :tables [extra-table]}) 117 | :tables)))) 118 | 119 | (testing "scan DB with fk: config table data to override" 120 | (let [venues-columns [{:name "id" :type "integer"}] 121 | meetups-fks [{:table "venues" :from "venue_id" :to "vid"}]] 122 | (schema-as-expected? 123 | [members 124 | groups 125 | (assoc venues :columns venues-columns) 126 | (assoc meetups :fks meetups-fks) 127 | meetups-members] 128 | (-> (tbl/db-schema {:db db 129 | :db-adapter (db-adapter/db->adapter db) 130 | :scan-tables true 131 | :tables [{:name "venues" 132 | :columns venues-columns} 133 | {:name "meetups" 134 | :fks meetups-fks}]}) 135 | :tables)))))) 136 | 137 | (def ^:private meetups-with-venues 138 | {:name "meetup_with_venue" 139 | :columns [{:name "id" :type "integer"} 140 | {:name "title" :type "text"} 141 | {:name "venue_id" :type "integer"} 142 | {:name "venue_name" :type "text"}] 143 | :fks [] 144 | :pks []}) 145 | 146 | (deftest db-view-schema 147 | (let [db (sqlite-conn)] 148 | (testing "scan DB for view schema" 149 | (schema-as-expected? 150 | [meetups-with-venues] 151 | (-> (tbl/db-schema {:db db 152 | :db-adapter (db-adapter/db->adapter db) 153 | :scan-tables true 154 | :scan-views true 155 | :tables []}) 156 | :views))) 157 | 158 | (testing "views not scanned for config false" 159 | (let [scm (tbl/db-schema {:db db 160 | :db-adapter (db-adapter/db->adapter db) 161 | :scan-tables true 162 | :scan-views false})] 163 | (is (empty? (:views scm))) 164 | (is (not-empty (:tables scm))))))) 165 | --------------------------------------------------------------------------------