Skip to content

Commit 7b6fc6a

Browse files
committed
feat(gb-9097): derive/is introspection support
1 parent 6d7fc34 commit 7b6fc6a

25 files changed

Lines changed: 3121 additions & 50 deletions

File tree

.github/workflows/pull-request.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ jobs:
5555
strategy:
5656
matrix:
5757
version:
58-
- gateway: "0.37.2"
59-
cli: "0.94.1"
58+
- gateway: "0.38.0"
59+
cli: "0.95.1"
6060
env:
6161
RUSTFLAGS: -D warnings
6262
steps:

Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/postgres/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "grafbase-postgres"
3-
version = "0.3.5"
3+
version = "0.3.6"
44
edition = "2024"
55
license = "Apache-2.0"
66

cli/postgres/README.md

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ enable_queries = true
171171
# you can define them manually from this map. Key/value from relation name
172172
# to config.
173173
relations = {}
174+
175+
# Configure derives for cross-database joins.
176+
derives = {}
174177
```
175178

176179
### View Configuration
@@ -201,6 +204,9 @@ columns = {}
201204
# can define relations manualy from this map.
202205
# Key/value from relation name to config.
203206
relations = {}
207+
208+
# Configure derives for cross-database joins.
209+
derives = {}
204210
```
205211

206212
#### Unique Key Definitions
@@ -258,6 +264,173 @@ referenced_columns = ["id", "name"]
258264

259265
Define these relations in your config file to enable joins to and from your views.
260266

267+
### Derive Definitions
268+
269+
Our derives setup offers a powerful way to join data efficiently between multiple Postgres databases. You can use several approaches to enable joins across two or more Postgres databases.
270+
271+
The Grafbase Gateway prevents GraphQL N+1 problems in all cross-database joins by minimizing the number of queries needed to load data.
272+
273+
Let's explore some examples using these SQL schemas:
274+
275+
Database A:
276+
277+
```sql
278+
CREATE TABLE "posts" (
279+
id INT PRIMARY KEY,
280+
title VARCHAR(255) NOT NULL,
281+
author_id INT NOT NULL
282+
);
283+
```
284+
285+
Database B:
286+
287+
```sql
288+
CREATE TABLE "users" (
289+
id INT PRIMARY KEY,
290+
name VARCHAR(255) NOT NULL
291+
)
292+
```
293+
294+
We want to create a federated GraphQL schema that joins posts and users:
295+
296+
```graphql
297+
type Post @key(fields: "id") {
298+
id: Int!
299+
title: String!
300+
authorId: Int!
301+
author: User
302+
}
303+
304+
type User @key(fields: "id") {
305+
id: Int!
306+
name: String!
307+
}
308+
```
309+
310+
#### Entity Joins
311+
312+
The first approach uses GraphQL federation spec entity joins. This method requires both subgraph schemas to define the same type. Database A needs a view that represents unique users, which we create with:
313+
314+
```sql
315+
CREATE VIEW "users" AS SELECT DISTINCT(author_id) AS id FROM posts ORDER BY author_id;
316+
```
317+
318+
Add this configuration for the grafbase-postgres CLI extension before introspection:
319+
320+
```toml
321+
[schemas.public.views.users.columns.id]
322+
nullable = false
323+
unique = true
324+
```
325+
326+
This gives subgraph A the following types:
327+
328+
```graphql
329+
type Post @key(fields: "id") {
330+
id: Int!
331+
title: String!
332+
authorId: Int!
333+
author: User!
334+
}
335+
336+
type User @key(fields: "id") {
337+
id: Int!
338+
}
339+
```
340+
341+
When you compose this with subgraph B, you can efficiently join posts and users.
342+
343+
#### Deriving
344+
345+
Creating an extra view to join data between databases often adds maintenance overhead and can slow down performance. Instead, use the composite spec `@is` and `@derive` directives. Define derives for subgraph A:
346+
347+
```toml
348+
[schemas.public.tables.posts.derives.author]
349+
referenced_type = "User"
350+
fields = { id = "authorId" }
351+
```
352+
353+
This tells the system: for the `posts` table, create a derived field `author` that loads a single `User` entity. The `id` field of the User type maps to the `authorId` field of the `Post` type.
354+
355+
Introspection then produces these types:
356+
357+
```graphql
358+
type Post @key(fields: "id") {
359+
id: Int!
360+
title: String!
361+
authorId: Int!
362+
author: User! @derive @is(field: "{ id: authorId }")
363+
}
364+
365+
type User @key(fields: "id") {
366+
id: Int!
367+
}
368+
```
369+
370+
The introspection creates a User type with fields needed for joining and a derived relation field with the corresponding directives. Compose this with the other subgraph to create a federated graph that joins posts to users across databases.
371+
372+
#### One to Many
373+
374+
We're currently working on support for one-to-many joins with deriving. For now, use the entity join approach if you need to fetch many entities from another graph.
375+
376+
Add a view to the database with the posts table:
377+
378+
```sql
379+
CREATE VIEW "users" AS SELECT DISTINCT(author_id) AS id FROM posts ORDER BY author_id;
380+
```
381+
382+
Then add this configuration to create a relation between the users view and posts table:
383+
384+
```toml
385+
[schemas.public.views.users.relations.users_to_posts]
386+
referenced_schema = "public"
387+
referenced_table = "posts"
388+
referencing_columns = ["id"]
389+
referenced_columns = ["author_id"]
390+
```
391+
392+
Introspection produces this SDL:
393+
394+
```graphql
395+
type Post @key(fields: "id") {
396+
id: Int!
397+
title: String!
398+
authorId: Int!
399+
author: User!
400+
}
401+
402+
type User @key(fields: "id") {
403+
id: Int!
404+
posts: [Post!]!
405+
}
406+
```
407+
408+
When you compose this with the other subgraph:
409+
410+
```graphql
411+
type User @key(fields: "id") {
412+
id: Int!
413+
name: String!
414+
}
415+
```
416+
417+
You get this federated graph:
418+
419+
```graphql
420+
type Post @key(fields: "id") {
421+
id: Int!
422+
title: String!
423+
authorId: Int!
424+
author: User!
425+
}
426+
427+
type User @key(fields: "id") {
428+
id: Int!
429+
name: String!
430+
posts: [Post!]!
431+
}
432+
```
433+
261434
## License
262435

263436
Apache-2.0

cli/postgres/grafbase-postgres.toml

Lines changed: 0 additions & 1 deletion
This file was deleted.

cli/postgres/install.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ if [[ ${OS:-} = Windows_NT ]]; then
66
exit 1
77
fi
88

9-
LATEST_VERSION="0.3.5"
9+
LATEST_VERSION="0.3.6"
1010

1111
# Reset
1212
Color_Off=''

crates/database-definition/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,13 @@ impl DatabaseDefinition {
421421
.map(|id| self.walk(id))
422422
}
423423

424+
/// Retrieves a table walker for the given schema and table name.
425+
pub fn get_table(&self, schema: &str, table_name: &str) -> Option<TableWalker<'_>> {
426+
self.get_schema_id(schema)
427+
.and_then(|schema_id| self.get_table_id(schema_id, table_name))
428+
.map(|table_id| self.walk(table_id))
429+
}
430+
424431
/// Finds the id of a schema with the given name, if existing.
425432
pub fn get_schema_id(&self, schema: &str) -> Option<SchemaId> {
426433
self.schemas

crates/postgres-introspection/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ itertools.workspace = true
2323
Inflector.workspace = true
2424
indenter = { version = "0.3.3", features = ["std"] }
2525
serde = { workspace = true, features = ["derive"] }
26+
indexmap.workspace = true
2627

2728
[lints]
2829
workspace = true

crates/postgres-introspection/src/config.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use grafbase_database_definition::TableWalker;
2+
use indexmap::IndexMap;
23
use serde::Deserialize;
34
use std::collections::BTreeMap;
45

@@ -121,6 +122,9 @@ pub struct TableConfig {
121122
/// Configuration details for relationships originating from this view, keyed by relationship name.
122123
#[serde(default)]
123124
pub relations: BTreeMap<String, RelationConfig>,
125+
/// Configuration for derived fields in this table, keyed by derive name.
126+
#[serde(default)]
127+
pub derives: BTreeMap<String, DeriveConfig>,
124128
}
125129

126130
/// Represents the configuration settings for a specific database relation (e.g., a view).
@@ -139,6 +143,78 @@ pub struct ViewConfig {
139143
/// Configuration details for relationships originating from this view, keyed by relationship name.
140144
#[serde(default)]
141145
pub relations: BTreeMap<String, RelationConfig>,
146+
/// Configuration for derived fields in this table, keyed by derive name.
147+
#[serde(default)]
148+
pub derives: BTreeMap<String, DeriveConfig>,
149+
}
150+
151+
/// Represents the configuration for a derived field within a table or view.
152+
///
153+
/// A derived field allows you to enable joins between subgraphs. By defining a derived field in
154+
/// this database, the introspection generates a virtual type, which then composes with the full
155+
/// federated schema.
156+
///
157+
/// For example:
158+
///
159+
/// ```toml
160+
/// # In your config.toml file:
161+
///
162+
/// [schemas.public.tables.Post.derives.author]
163+
/// referenced_type = "User"
164+
/// field = { author_id = "id" }
165+
/// ```
166+
///
167+
/// This then is reflected in the generated schema as follows:
168+
///
169+
/// ```graphql
170+
/// type Post @key(fields: "id") {
171+
/// id: ID!
172+
/// title: String!
173+
/// email: String!
174+
/// # introspection will create this field
175+
/// author: User! @derive @is(field: "{ author_id: id }")
176+
/// }
177+
///
178+
/// """
179+
/// Introspection will create this type.
180+
/// """
181+
/// type User @key(fields: "id") {
182+
/// id: ID!
183+
/// }
184+
/// ```
185+
///
186+
/// Keep in mind that you do not need to define derives for types that are already defined in the
187+
/// schema. The introspection process will detect the relationship between the types through
188+
/// foreign keys. Also, if you have the same type in two different databases, automatically utilize the
189+
/// Apollo federation keys to implement an entity join:
190+
///
191+
/// Subgraph A:
192+
///
193+
/// ```
194+
/// type User @key(fields: "id") {
195+
/// id: ID!
196+
/// name: String!
197+
/// }
198+
/// ```
199+
///
200+
/// Subgraph B:
201+
///
202+
/// ```
203+
/// type User @key(fields: "id") {
204+
/// id: ID!
205+
/// socialSecurityNumber: String!
206+
/// }
207+
/// ```
208+
///
209+
/// The composition will combine these User types into a single User type without extra
210+
/// configuration.
211+
#[derive(Deserialize, Debug)]
212+
#[serde(deny_unknown_fields)]
213+
pub struct DeriveConfig {
214+
/// The type the derived field points to.
215+
pub referenced_type: String,
216+
/// A map of referencing field to referenced field.
217+
pub fields: IndexMap<String, String>,
142218
}
143219

144220
/// Represents the configuration for a specific column within a view.

crates/postgres-introspection/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ pub async fn introspect(conn: &mut sqlx::PgConnection, config: Config) -> anyhow
3232

3333
database_definition.finalize();
3434

35-
Ok(render::to_sdl(database_definition, &config))
35+
render::to_sdl(database_definition, &config)
3636
}
3737

3838
/// A list of schemas to filter out automatically on every introspection.

0 commit comments

Comments
 (0)