FuelLabs / fuel-indexer

🗃 The Fuel indexer is a standalone service that can be used to index various components of the Fuel blockchain.
https://docs.fuel.network/docs/indexer/
140 stars 66 forks source link

Implement `.find()` on `Entity` #1437

Closed ra0x3 closed 9 months ago

ra0x3 commented 10 months ago

Context

What we currently have

#[derive(Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct #ident {
    #struct_fields
}

impl<'a> Entity<'a> for #ident {
    const TYPE_ID: i64 = #type_id;
    const JOIN_METADATA: Option<[Option<JoinMetadata<'a>>; MAX_FOREIGN_KEY_LIST_FIELDS]> = #join_metadata;

    fn from_row(mut vec: Vec<FtColumn>) -> Self {
        #field_extractors
        Self {
            #from_row
        }
    }

    fn to_row(&self) -> Vec<FtColumn> {
        vec![
            #to_row
        ]
    }

}

What we want

#[derive(Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct #ident {
    #struct_fields
}

impl<'a> Entity<'a> for #ident {
    const TYPE_ID: i64 = #type_id;
    const JOIN_METADATA: Option<[Option<JoinMetadata<'a>>; MAX_FOREIGN_KEY_LIST_FIELDS]> = #join_metadata;

    #const_fields

    fn from_row(mut vec: Vec<FtColumn>) -> Self {
        #field_extractors
        Self {
            #from_row
        }
    }

    fn to_row(&self) -> Vec<FtColumn> {
        vec![
            #to_row
        ]
    }

}

So with the following schema

type Foo @entity {
  id: ID!
  account: Address!
  name: String!
}

You'd get the following const fields on the Entity


trait SQLFragment {
  fn equals<T: Sized>(val: T) -> String;
}

struct FooId;

impl FooId {
  const name: &str = "id";
}

impl SQLFragment for FooId {
  fn equals<T: Sized>(val: T) -> String {
      format!("{} = '{}'", Self::name, val);
  }
}

struct FooAccount;

impl FooAccount {
  const name: &str = "account";
}

struct FooName;

impl FooName {
  const name: &str = "name";
}

impl<'a> Entity<'a> for Foo {
   const id: String = FooId;
   const account: Address =  FooAddress;
   const name: &str = FooName;

   // .. all the other code ...
}

This ☝🏼 would allow us to build a .find() method as follows:

impl<'a> Entity<'a> for Foo {

   // .. all the other code ...

   fn find(fragments: Vec<impl SQLFragment>) -> Option<Self> {
      let where_clause = fragments.join(" AND ");
      let query = format!("SELECT * FROM {} WHERE {} LIMIT 1", self.name, where_clause);
      let buff = bincode::serialize(&query);
      let mut bufflen = (buff.len() as u32).to_le_bytes();
      let ptr = ff_arbitrary_single_select(Self::TYPE_ID, buff.as_ptr(), bufflen.as_mut_ptr());

       match ptr {
            Some(p) => {
                   let len = u32::from_le_bytes(bufflen) as usize;
                   let bytes = Vec::from_raw_parts(ptr, len, len).unwrap();
                   let data = deserialize(&bytes).unwrap();
                   Some(Self::from_row(data));
            }
            None => None,
       }
   }
}
extern crate alloc;
use fuel_indexer_utils::prelude::*;

mod indexer_mod {
    fn handle_burn_receipt(order: OrderEntity) {
        // single find parameter
        let found_order = OrderEntity.find(vec![OrderEntity::amount::equals(5)])?; 

        // multiple find parameters
        let found_order = OrderEntity.find(vec![OrderEntity::amount::equals(5), OrderEntity::address::equals("0x00001")])?; 
    }
}

Future work

trait SQLFragment {
  fn equals<T: Sized>(val: T) -> String;
  fn gt<T: Sized>(val: T) -> String;
  fn lt<T: Sized>(val: T) -> String;
  // .. so on and so forth ..
}
ra0x3 commented 10 months ago

This ☝🏼 is all pseudo-code, but CC @Voxelot @deekerno @lostman for comment, as this (implementing .find()) is gonna block #886 because we need to be able to lookup the CoinOutput (for a given InputCoin) by the owner: Address field (which we currently don't support)

lostman commented 10 months ago

@ra0x3,

We would have to generate these functions ::field_name::equals() for all types in the schema, correct?

vec![OrderEntity::amount::equals(5), OrderEntity::address::equals("0x00001")]

And they would produce SQLFragments directly?

ra0x3 commented 10 months ago

@lostman

deekerno commented 10 months ago

I have to say that I'm not the greatest fan of using a vector for multiple parameters. I'd much rather a cleaner "object-based" syntax similar to how selection parameters are done in Prisma. However, I don't think that we can easily do that right now given the fact that Rust doesn't allow for anonymous structs; additionally, we'd want type safety wherever possible so doing something with JSON would probably not be worth the headache. In any case, I think this style is probably the best type-safe way and will work for now in order to not block the predicate support work.

Perhaps in the future, we could leverage the builder pattern to do something in a more functional style:

let found_order = OrderEntity
  .find()
  .filter(OrderEntity::amount::gt(5))
  .filter(OrderEntity::address::equals("0x00001")
  .select()?

...or something to that effect.

ra0x3 commented 10 months ago

@deekerno

let found_order = OrderEntity.find(OrderEntity::amount::gt(5).and(OrderEntity::address::eq("0x00001"));

@deekerno @lostman thoughts? ☝🏼

deekerno commented 10 months ago

I like that idea even better and we should aim for that if possible. The only hesitation I have is how we would support operator combination, if at all.

From the Prisma docs:

const result = await prisma.user.findMany({
  where: {
    OR: [
      {
        email: {
          endsWith: 'prisma.io',
        },
      },
      { email: { endsWith: 'gmail.com' } },
    ],
    NOT: {
      email: {
        endsWith: 'hotmail.com',
      },
    },
  },
  select: {
    email: true,
  },
})

I'd imagine that the vector style would have to make a reappearance here, but we can cross the proverbial bridge when we get to it.

ra0x3 commented 10 months ago

@deekerno The above ☝🏼 would be written as

User.find(
  User::email::ends_with("prisma.io")
  .or(User::email::ends_with("gmail.com")
  .and(User::email::not_ends_with("hotmail.com")
);
ra0x3 commented 10 months ago

Again, I'm not married to my method so feel free to push back

lostman commented 10 months ago

I don't think this is valid Rust:

OrderEntity::amount::gt(5)
^^^^^^^^^^^
type          ^^^^^^
              ?      ^^
                     associated function

I was thinking about something like this:

pub struct Constraint<T> {
    constraint: String,
    phantom: std::marker::PhantomData<T>,
}

pub trait Field<T> {
    // TODO: Type needs to be convertible SQL fragment
    type Type: Sized + std::fmt::Debug;
    const NAME: &'static str;

    fn equals(val: Self::Type) -> Constraint<T> {
        Constraint {
            constraint: format!("{} = '{:?}'", Self::NAME, val),
            phantom: std::marker::PhantomData,
        }
    }
}

Then:

struct Order {
  id: usize,
  amount: i32,
}

struct OrderIdField;

impl Field<Order> for OrderIdField {
    type Type = usize;
    const NAME: &'static str = "id";
}

struct OrderAmountField;

impl Field<Order> for OrderAmountField {
    type Type = i32;
    const NAME: &'static str = "amount";
}

And using the vec! syntax (for now):

pub trait Entity<'a>: Sized + PartialEq + Eq + std::fmt::Debug {
    fn find(constraints: Vec<Constraint<Self>>) -> String {
        let mut buf = String::new();
        for c in constraints {
            if !buf.is_empty() {
                buf += " AND ";
            }
            buf += &c.constraint
        }
        buf
    }
}

So, we'd have something like:

find(vec![OrderIdField::eq(1usize), OrderAmountField::lt(123i32)])

Of course, we can instead have a simple AST:

pub struct Constraint<T> {
    constraint: String,
    phantom: std::marker::PhantomData<T>,
}

impl<T> Constraint<T> {
    pub fn and(self, other: impl Into<Expr<T>>) -> Expr<T> {
        Expr::And(Box::new(Expr::Leaf(self)), Box::new(other.into()))
    }
}

impl<T> From<Constraint<T>> for Expr<T> {
    fn from(c: Constraint<T>) -> Expr<T> {
        Expr::Leaf(c)
    }
}
pub enum Expr<T> {
    And(Box<Expr<T>>, Box<Expr<T>>),
    Leaf(Constraint<T>)
}

impl<T> Expr<T> {
    pub fn and(self, other: impl Into<Expr<T>>) -> Expr<T> {
        Expr::And(Box::new(self), Box::new(other.into()))
    }
}

An example:

        let x: Expr<Block> = {
            let e1: Constraint<Block> = BlockIdField::equals(block_frag.id.clone());
            let e2: Constraint<Block> = BlockIdField::equals(block_frag.id.clone());
            // constraint and constraint = expr
            e1.and(e2)
        };

        let y: Expr<Block> = {
            let e = BlockIdField::equals(block_frag.id.clone());
            // constraint and expr = expr
            // e.and(x)
            // or expr and constraint = expr
            x.and(e)
        };

        let z: Expr<Block> = {
            let e1: Constraint<Block> = BlockIdField::equals(block_frag.id.clone());
            let e2: Constraint<Block> = BlockIdField::equals(block_frag.id.clone());
            e1.and(e2)
        };

        // expr and expr = expr
        let v = z.and(y);

It may look a little convoluted, but it shows that these can be easily mixed and matched.

Another:

        let complex = BlockIdField::equals(block_frag.id.clone())
            .and(BlockConsensusField::equals(consensus.id.clone()))
            .and(BlockHeaderField::equals(header.id.clone()));
ra0x3 commented 10 months ago

@lostman

lostman commented 10 months ago

@ra0x3, the AST is an internal implementation detail and pretty simple. Avoiding it wouldn't make anything simpler.

Some questions:

  1. What to do about nullable fields? If we have Option<U32> then SomeField::eq(None), or SomeField::lt(Some(5)) should be supported, correct?

The first would translate to WHERE some_field = null and the second to WHERE some_field < 5, correct?

  1. What about Array fields? What operations should we support if we have Array<U32>?

Some form of any and all come to mind.

        let arr2 = TransactionInputsField::any(
            TransactionInputsField::equals(
                SizedAsciiString::new("".to_string()).unwrap(),
            )
            .and(TransactionInputsField::equals(
                SizedAsciiString::new("xyz".to_string()).unwrap(),
            )),
        );

This is a little verbose but can be shortened to:

        let arr2: ArrayExpr<Transaction> = {
            type T = TransactionInputsField;
            T::any(
                T::equals(SizedAsciiString::new("".to_string()).unwrap())
                    .and(T::equals(SizedAsciiString::new("xyz".to_string()).unwrap())),
            )
        };
lostman commented 10 months ago

Some options for structuring the DSL for writing constraint expressions:

Using modules:

        let c: Constraint<ScriptTransaction> = {
            use script_transaction::*;
            // ((gas_price < '100' OR gas_price > '200') AND gas_limit = '1000')
            gas_price()
                .lt(100i32)
                .or(gas_price().gt(200))
                .and(gas_limit().eq(1000))
        };

Using associated functions:

        let z = ScriptTransaction::gas_price()
            .lt(100i32)
            .or(ScriptTransaction::gas_price().gt(200))
            .and(ScriptTransaction::gas_limit().eq(1000));

Which could be shortened with:

use ScriptTransactionResult as T;
T::gas_price()...

Having a module with these functions seems cleaner.

I still need to think about how array fields would fit into this.

ra0x3 commented 10 months ago
let result = ScriptTransaction.find(ScriptTransaction::gas_price()
            .lt(100i32)
            .or(ScriptTransaction::gas_price().gt(200))
            .and(ScriptTransaction::gas_limit().eq(1000)));
ra0x3 commented 10 months ago

@lostman