SeaQL / seaography

🧭 GraphQL framework for SeaORM
Apache License 2.0
380 stars 35 forks source link

Add InputBuilder validators & guards on fields #141

Open karatakis opened 1 year ago

karatakis commented 1 year ago

Motivation

When creating, updating or deleting and item you can access all fields and properties. There should be a way to limit this by applying policy rules.

Proposed Solutions

Extend the input module to allow the configuration of validators and guards to be associated with entity input fields.

Additional Information

See guards on entities output fields

ShivangRawat30 commented 1 year ago

Hey I am currently learning rust, as it is a good first issue can you please assign this to me.

karatakis commented 1 year ago

@ShivangRawat30 yeah no problem.

You have to extend https://github.com/SeaQL/seaography/blob/main/src/builder_context/guards.rs to support:

And apply the respective checks at

YiNNx commented 10 months ago

Hi, I'm interested in the approach to adding the InputBuilder Validators. Are there more details about how to implement it?

guidomb commented 10 months ago

Hi, I'm also interested in this feature. My current use case is skipping fields in input object types for which their corresponding SeaORM entity has a default value. For example:

This is a migration that creates an author table where the columns id and created_at have default values and must not be null. The idea is that values will be provided by the database.

use entities::*;
use sea_orm_migration::{
    prelude::*,
    sea_orm::{EntityTrait, Set},
};

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(Author)
                    .if_not_exists()
                    .col(
                        ColumnDef::new(author::Column::Id)
                            .uuid()
                            .extra("DEFAULT gen_random_uuid()")
                            .not_null()
                            .primary_key(),
                    )
                    .col(
                        ColumnDef::new(author::Column::Email)
                            .string_len(254)
                            .unique_key()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(author::Column::FirstName)
                            .string_len(100)
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(author::Column::LastName)
                            .string_len(100)
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(author::Column::Username)
                            .string_len(50)
                            .unique_key()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(post::Column::CreatedAt)
                            .timestamp_with_time_zone()
                            .extra("DEFAULT now()")
                            .not_null(),
                    )
                    .to_owned(),
            )
            .await?;

        Ok(())
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(Author).to_owned())
            .await?;

        Ok(())
    }
}

here is the corresponding entity definition

use sea_orm::entity::prelude::*;
use serde::Serialize;

use crate::AuthorRole;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize)]
#[sea_orm(table_name = "author")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub id: Uuid,
    #[sea_orm(column_type = "Text", unique)]
    pub email: String,
    #[sea_orm(column_type = "Text")]
    pub first_name: String,
    #[sea_orm(column_type = "Text")]
    pub last_name: String,
    pub created_at: DateTimeWithTimeZone,
    #[sea_orm(unique)]
    pub username: String,
    pub role: AuthorRole,
}

Currently on main (9c43f72017fb27bd5e2602c553ee59acc45ae26e) the generated GraphQL schema

type Author {
  id: String!
  email: String!
  firstName: String!
  lastName: String!
  createdAt: String!
  username: String!
  role: AuthorRoleEnum!
  post(
    filters: PostFilterInput
    orderBy: PostOrderInput
    pagination: PaginationInput
  ): PostConnection!
}

type AuthorBasic {
  id: String!
  email: String!
  firstName: String!
  lastName: String!
  createdAt: String!
  username: String!
  role: AuthorRoleEnum!
}

type AuthorConnection {
  pageInfo: PageInfo!
  paginationInfo: PaginationInfo
  nodes: [Author!]!
  edges: [AuthorEdge!]!
}

type AuthorEdge {
  cursor: String!
  node: Author!
}

input AuthorFilterInput {
  id: TextFilterInput
  email: StringFilterInput
  firstName: StringFilterInput
  lastName: StringFilterInput
  createdAt: TextFilterInput
  username: StringFilterInput
  role: AuthorRoleEnumFilterInput
  and: [AuthorFilterInput!]
  or: [AuthorFilterInput!]
}

input AuthorInsertInput {
  id: String!
  email: String!
  firstName: String!
  lastName: String!
  createdAt: String!
  username: String!
  role: AuthorRoleEnum!
}

input AuthorOrderInput {
  id: OrderByEnum
  email: OrderByEnum
  firstName: OrderByEnum
  lastName: OrderByEnum
  createdAt: OrderByEnum
  username: OrderByEnum
  role: OrderByEnum
}

enum AuthorRoleEnum {
  WRITTER
  PUBLISHER
  EDITOR
}

input AuthorRoleEnumFilterInput {
  eq: AuthorRoleEnum
  ne: AuthorRoleEnum
  gt: AuthorRoleEnum
  gte: AuthorRoleEnum
  lt: AuthorRoleEnum
  lte: AuthorRoleEnum
  is_in: [AuthorRoleEnum!]
  is_not_in: [AuthorRoleEnum!]
  is_null: AuthorRoleEnum
  is_not_null: AuthorRoleEnum
}

input AuthorUpdateInput {
  id: String
  email: String
  firstName: String
  lastName: String
  createdAt: String
  username: String
  role: AuthorRoleEnum
}

input CursorInput {
  cursor: String
  limit: Int!
}

type Mutation {
  _ping: String
  authorCreateOne(data: AuthorInsertInput!): AuthorBasic!
  authorCreateBatch(data: [AuthorInsertInput!]!): [AuthorBasic!]!
  authorUpdate(
    data: AuthorUpdateInput!
    filter: AuthorFilterInput
  ): [AuthorBasic!]!
}

input OffsetInput {
  limit: Int!
  offset: Int!
}

enum OrderByEnum {
  ASC
  DESC
}

type PageInfo {
  hasPreviousPage: Boolean!
  hasNextPage: Boolean!
  startCursor: String
  endCursor: String
}

input PageInput {
  limit: Int!
  page: Int!
}

type PaginationInfo {
  pages: Int!
  current: Int!
  offset: Int!
  total: Int!
}

input PaginationInput {
  cursor: CursorInput
  page: PageInput
  offset: OffsetInput
}

type Query {
  author(
    filters: AuthorFilterInput
    orderBy: AuthorOrderInput
    pagination: PaginationInput
  ): AuthorConnection!
}

input StringFilterInput {
  eq: String
  ne: String
  gt: String
  gte: String
  lt: String
  lte: String
  is_in: [String!]
  is_not_in: [String!]
  is_null: String
  is_not_null: String
  contains: String
  starts_with: String
  ends_with: String
  like: String
  not_like: String
  between: [String!]
  not_between: [String!]
}

input TextFilterInput {
  eq: String
  ne: String
  gt: String
  gte: String
  lt: String
  lte: String
  is_in: [String!]
  is_not_in: [String!]
  is_null: String
  is_not_null: String
  between: [String!]
  not_between: [String!]
}

Ideally I think that the behavior should be that the fields that have default values don't need to be optionally in the Rust entity but should not be included in the UpdateInput and InsertInput. What do you think?

guidomb commented 10 months ago

I found that if I modify how the BuilderContext is initialized I can skip fields from the input object generation. For example:

lazy_static::lazy_static! {
    static ref CONTEXT : BuilderContext = {
        let mut context = BuilderContext::default();
        context.entity_input.insert_skips.push("Author.id".into());
        context.entity_input.insert_skips.push("Author.role".into());
        context.entity_input.insert_skips.push("Author.createdAt".into());
        context
    };
}

What would the proper way of initializing the BuilderContext without having to write this manually by inferring it from the SQL schema?

guidomb commented 10 months ago

The problem I see is that for some fields I would like to exclude them from the insert and update input like id or created_at but there are other fields like role that have a default value in postgres, should not be skipped in the insert or update input, should have a default value in the generated GraphQL schema but must not be optional in the Rust entity.

karatakis commented 10 months ago

Hello @YiNNx and thanks for your interest. So far the supported mutations are create and update and I hope to release the delete soon. If you read the comment I created a while ago it provides a file from where to start. The guards file currently has guards for query and query filter options. If you search those on code you can find more about how this feature works. We just need to further expand it for mutations.

I will look it up and add more info on this issue later.

karatakis commented 10 months ago

Thanks for your comments @guidomb. Could you open a separate issue so we can address this separately?