Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolves domain base types #402

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@
- bugfix: queries failed to run if the database was in read-only replica mode

## master
- feature: add support to resolve domain types to their base types
86 changes: 86 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,96 @@ For more fine grained adjustments to reflected names, see [renaming](#renaming).
The default page size for collections is 30 entries. To adjust the number of entries on each page, set a `max_rows` directive on the relevant schema entity.

For example, to increase the max rows per page for each table in the `public` schema:

```sql
comment on schema public is e'@graphql({"max_rows": 100})';
```

### Resolve Base Type

By default, Postgres domain types are mapped to GraphQL `Opaque` type. For example:

```sql
create domain pos_int as int check (value > 0);

create table users (
id serial primary key,
age pos_int
);
```

The `age` field is an `Opaque` type:

```graphql
type Users {
id: ID!
age: Opaque
}
```

Set the `resolve_base_type` directive to `true` to resolve the domain types to their base types instead. For example:
andrew-w-ross marked this conversation as resolved.
Show resolved Hide resolved

```sql
comment on schema public is e'@graphql({"resolve_base_type": true})';
```

The `age` field is an `Int` type now:

```graphql
type Users {
id: ID!
age: Int
}
```

!!! note

`resolve_base_type` should be enabled on the schema where the domain type is used, not where it is defined. In the above example, it should be enabled on the schema of the `users` table, not the schema of the `pos_int` domain type.

Note that a `not null` constraint on the domain type doesn't make the GraphQL type non-null. For example:

```sql
comment on schema public is e'@graphql({"resolve_base_type": true})';

create domain pos_int as int not null;

create table users {
id serial primary key,
age pos_int
};
```

The `age` field is still nullable:

```graphql
type Users {
id: ID!
age: Int
}
```

To make a domain type field non-null, add the `not null` constraint on the field in the table:

```sql
comment on schema public is e'@graphql({"resolve_base_type": true})';

create domain pos_int as int not null;

create table users {
id serial primary key,
age pos_int not null
};
```

The `age` field is now non-null:

```graphql
type Users {
id: ID!
age: Int!
}
```

### totalCount

`totalCount` is an opt-in field that extends a table's Connection type. It provides a count of the rows that match the query's filters, and ignores pagination arguments.
Expand Down
35 changes: 34 additions & 1 deletion sql/load_sql_context.sql
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
with search_path_oids(schema_oid) as (
with recursive search_path_oids(schema_oid) as (
select y::regnamespace::oid from unnest(current_schemas(false)) x(y)
),
schemas_(oid, name) as (
Expand All @@ -15,6 +15,17 @@ schemas_(oid, name) as (
pn.oid,
'USAGE'
)
),
type_hierarchy(base_oid, current_oid) AS (
SELECT oid, oid
FROM pg_type
WHERE typbasetype = 0

UNION ALL

SELECT th.base_oid, pt.oid
FROM pg_type pt
JOIN type_hierarchy th ON pt.typbasetype = th.current_oid
)
andrew-w-ross marked this conversation as resolved.
Show resolved Hide resolved
select
jsonb_build_object(
Expand All @@ -23,6 +34,24 @@ select
'role', current_role,
'schema_version', graphql.get_schema_version()
),
'base_type_map', coalesce(
(
select
jsonb_object_agg(
current_oid,
base_oid
)
from (
select current_oid::int, min(base_oid::int) as base_oid
from
type_hierarchy
where
current_oid <> base_oid
group by current_oid
) as gt
),
jsonb_build_object()
),
'enums', coalesce(
(
-- CTE is for performance. Issue #321
Expand Down Expand Up @@ -192,6 +221,10 @@ select
'max_rows', coalesce(
(graphql.comment_directive(pg_catalog.obj_description(pn.oid, 'pg_namespace')) ->> 'max_rows')::int,
30
),
'resolve_base_type', coalesce(
(graphql.comment_directive(pg_catalog.obj_description(pn.oid, 'pg_namespace')) -> 'resolve_base_type') = to_jsonb(true),
false
)
)
)
Expand Down
7 changes: 5 additions & 2 deletions src/graphql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,7 @@ pub enum Scalar {
Cursor,
BigFloat,
// Unknown or unhandled types.
// There is no guarentee how they will be serialized
// There is no guarantee how they will be serialized
// and they can't be filtered or ordered
Opaque,
}
Expand Down Expand Up @@ -3381,6 +3381,9 @@ impl ___Type for FilterEntityType {
let mut or_column_exists = false;
let mut not_column_exists = false;

const JSON_TYPE_OID: u32 = 114;
const JSONB_TYPE_OID: u32 = 3802;

let mut f: Vec<__InputValue> = self
.table
.columns
Expand All @@ -3391,7 +3394,7 @@ impl ___Type for FilterEntityType {
// No filtering on composites
.filter(|x| !self.schema.context.is_composite(x.type_oid))
// No filtering on json/b. they do not support = or <>
.filter(|x| !vec!["json", "jsonb"].contains(&x.type_name.as_ref()))
.filter(|x| ![JSON_TYPE_OID, JSONB_TYPE_OID].contains(&x.type_oid))
.filter_map(|col| {
// Should be a scalar
if let Some(utype) = sql_column_to_graphql_type(col, &self.schema) {
Expand Down
22 changes: 2 additions & 20 deletions src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ use crate::parser_util::*;
use crate::sql_types::get_one_readonly;
use crate::transpile::{MutationEntrypoint, QueryEntrypoint};
use graphql_parser::query::{
Definition, Document, FragmentDefinition, Mutation, OperationDefinition, Query, SelectionSet,
Text,
Definition, Document, FragmentDefinition, Mutation, OperationDefinition, SelectionSet, Text,
};
use itertools::Itertools;
use serde_json::{json, Value};
Expand Down Expand Up @@ -90,7 +89,7 @@ where
},
Some(op) => match op {
OperationDefinition::Query(query) => {
resolve_query(query, schema, variables, fragment_defs)
resolve_selection_set(query.selection_set, schema, variables, fragment_defs)
}
OperationDefinition::SelectionSet(selection_set) => {
resolve_selection_set(selection_set, schema, variables, fragment_defs)
Expand All @@ -108,23 +107,6 @@ where
}
}

fn resolve_query<'a, 'b, T>(
query: Query<'a, T>,
schema_type: &__Schema,
variables: &Value,
fragment_definitions: Vec<FragmentDefinition<'a, T>>,
) -> GraphQLResponse
where
T: Text<'a> + Eq + AsRef<str>,
{
resolve_selection_set(
query.selection_set,
schema_type,
variables,
fragment_definitions,
)
}

fn resolve_selection_set<'a, 'b, T>(
selection_set: SelectionSet<'a, T>,
schema_type: &__Schema,
Expand Down
78 changes: 78 additions & 0 deletions src/sql_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,8 @@ pub struct SchemaDirectives {
pub inflect_names: bool,
// @graphql({"max_rows": 20})
pub max_rows: u64,
// @graphql({"resolve_base_type": true})")
pub resolve_base_type: bool,
}

#[derive(Deserialize, Clone, Debug, Eq, PartialEq, Hash)]
Expand All @@ -340,6 +342,7 @@ pub struct Context {
pub types: HashMap<u32, Arc<Type>>,
pub enums: HashMap<u32, Arc<Enum>>,
pub composites: Vec<Arc<Composite>>,
base_type_map: HashMap<u32, u32>,
}

impl Hash for Context {
Expand Down Expand Up @@ -540,6 +543,79 @@ pub fn load_sql_context(_config: &Config) -> Result<Arc<Context>, String> {
let sql_result: serde_json::Value = get_one_readonly::<JsonB>(query).unwrap().unwrap().0;
let context: Result<Context, serde_json::Error> = serde_json::from_value(sql_result);

fn replace_array_element_base_type(mut context: Context) -> Context {
for (_, type_) in context.types.iter_mut() {
let array_element_type_oid = match type_.array_element_type_oid {
Some(oid) => oid,
None => continue,
};

let resolve_base_type = context
.schemas
.get(&type_.schema_oid)
.is_some_and(|s| s.directives.resolve_base_type);

if !resolve_base_type {
continue;
}

let array_element_type = match context.base_type_map.get(&array_element_type_oid) {
Some(oid) => *oid,
None => continue,
};

if let Some(type_) = Arc::get_mut(type_) {
type_.array_element_type_oid = Some(array_element_type);
}
}

context
}

fn replace_table_base_types(mut context: Context) -> Context {
for (_, table) in context.tables.iter_mut() {
let resolve_base_type = context
.schemas
.get(&table.schema_oid)
.is_some_and(|s| s.directives.resolve_base_type);

if !resolve_base_type {
continue;
}

let table = match Arc::get_mut(table) {
None => continue,
Some(table) => table,
};

for column in table.columns.iter_mut() {
let base_oid = match context.base_type_map.get(&column.type_oid) {
Some(oid) => *oid,
None => continue,
};

if let Some(column) = Arc::get_mut(column) {
column.type_oid = base_oid;
}
}

//Technically speaking, we should check the schema of the function to see if we should resolve the base type
//Problem is this might be counter-intuitive to the user as in this case the function is acting as a virtual column of the table
for function in table.functions.iter_mut() {
andrew-w-ross marked this conversation as resolved.
Show resolved Hide resolved
let base_oid = match context.base_type_map.get(&function.type_oid) {
Some(oid) => *oid,
None => continue,
};

if let Some(function) = Arc::get_mut(function) {
function.type_oid = base_oid;
}
}
}

context
}

Comment on lines +546 to +618
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about extending TypeCategory and TypeDetails with a Domain variant and handling the resolution logic in to_graphql_type to keep it consistent with the existing type resolution stuff?

The context is the source of truth for the database' state and I'd like to be able to continue using it confidently for things like casting user input via the SQL layer during transpilation

Copy link
Contributor Author

@andrew-w-ross andrew-w-ross Sep 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@olirice Fair point I was trying to change as little as possible initially but that's been thrown out of the window at this point. It makes sense to include it as there is a decent amount more that can be done with that data. I'll get to it this weekend but if you don't mind just review this outline and correct me if I misunderstood something.

Change the base_type_map to domain instead and pull out all relevant, I'm thinking the oid, schema_oid, name, nullability, and possibly the element_oid. Might add the base_oid to that type to help with traversing the types later.

Change the types to include Domain in category type and adding it to the TypeCategory enum. Also create a Domain struct and add it to TypeDetails.

Revert the replace functions in load_sql_context. Mapping the domain type in the type details function.

Finally adding another match block to for Domain in the to_graphql_type function.

As for the tests, you're happy with them as is so if they pass we're good right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change the base_type_map to domain instead and pull out all relevant, I'm thinking the oid, schema_oid, name, nullability, and possibly the element_oid. Might add the base_oid to that type to help with traversing the types later.

sorry I didn't understand what you're proposing here. Could you explain the goal at a higher level?

was that related to my comment "How about resolve_domains_as_base_type for clarity?"?
if so, I was only referring to the name of the comment directive. The name of the SQL CTE is okay as-is

Change the types to include Domain in category type and adding it to the TypeCategory enum. Also create a Domain struct and add it to TypeDetails.

yep

Revert the replace functions in load_sql_context. Mapping the domain type in the type details function.

yep

Finally adding another match block to for Domain in the to_graphql_type function.

yep

As for the tests, you're happy with them as is so if they pass we're good right?

they looked good on my first pass. I'll re-review when you're ready but if any need to be adjusted I'm happy to do it

Copy link
Contributor Author

@andrew-w-ross andrew-w-ross Sep 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was that related to my comment "How about resolve_domains_as_base_type for clarity?"?

Nope, just changing the config key.

sorry I didn't understand what you're proposing here. Could you explain the goal at a higher level?

Type category tends to map to a type off of the context query. Enum to enums, Composite to composites and Table to tables. I was just going to do the same for domain types. I can add the base_oid under table_oid. That's all the info I need to get this done for now. I was just thinking ahead for all the other data you might need for domains, like is it nullable or is it an array, etc.

/// This pass cross-reference types with its details
fn type_details(mut context: Context) -> Context {
let mut array_types = HashMap::new();
Expand Down Expand Up @@ -669,6 +745,8 @@ pub fn load_sql_context(_config: &Config) -> Result<Arc<Context>, String> {
}

context
.map(replace_array_element_base_type)
.map(replace_table_base_types)
.map(type_details)
.map(column_types)
.map(Arc::new)
Expand Down
Loading
Loading