├── .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 |  [](https://clojars.org/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 |
--------------------------------------------------------------------------------