Repository: ponder-sh/ponder
Pull request #1274 (2024-11-22 22:47:50)

fix: graphql snake case bugs
See changeset. Note that there are still some cases of ambiguous parsing that we should consider validating against, e.g. if the column names themselves end with any of the where suffixes like `_not`, `_not_in`.


Patch:
diff --git a/.changeset/tasty-otters-flow.md b/.changeset/tasty-otters-flow.md
new file mode 100644
index 00000000..530c75f7
--- /dev/null
+++ b/.changeset/tasty-otters-flow.md
@@ -0,0 +1,5 @@
+---
+"@ponder/core": patch
+---
+
+Fixed several GraphQL bugs introduced in `0.7.0` that affected tables and columns using snake_case.
diff --git a/packages/core/src/graphql/index.ts b/packages/core/src/graphql/index.ts
index fe5bc456..f29047c1 100644
--- a/packages/core/src/graphql/index.ts
+++ b/packages/core/src/graphql/index.ts
@@ -1,5 +1,6 @@
 import type { Drizzle, OnchainTable, Schema } from "@/drizzle/index.js";
 import type { MetadataStore } from "@/indexing-store/metadata.js";
+import { never } from "@/utils/never.js";
 import { deserialize, serialize } from "@/utils/serialize.js";
 import DataLoader from "dataloader";
 import {
@@ -119,13 +120,13 @@ export function buildGraphQLSchema(schema: Schema): GraphQLSchema {
           if (type instanceof GraphQLList) {
             const baseType = innerType(type);
 
-            filterOperators.universal.forEach((suffix) => {
+            conditionSuffixes.universal.forEach((suffix) => {
               filterFields[`${columnName}${suffix}`] = {
                 type: new GraphQLList(baseType),
               };
             });
 
-            filterOperators.plural.forEach((suffix) => {
+            conditionSuffixes.plural.forEach((suffix) => {
               filterFields[`${columnName}${suffix}`] = { type: baseType };
             });
           }
@@ -139,20 +140,20 @@ export function buildGraphQLSchema(schema: Schema): GraphQLSchema {
           ) {
             if (type.name === "JSON") continue;
 
-            filterOperators.universal.forEach((suffix) => {
+            conditionSuffixes.universal.forEach((suffix) => {
               filterFields[`${columnName}${suffix}`] = {
                 type,
               };
             });
 
-            filterOperators.singular.forEach((suffix) => {
+            conditionSuffixes.singular.forEach((suffix) => {
               filterFields[`${columnName}${suffix}`] = {
                 type: new GraphQLList(type),
               };
             });
 
             if (["String", "ID"].includes(type.name)) {
-              filterOperators.string.forEach((suffix) => {
+              conditionSuffixes.string.forEach((suffix) => {
                 filterFields[`${columnName}${suffix}`] = {
                   type: type,
                 };
@@ -160,7 +161,7 @@ export function buildGraphQLSchema(schema: Schema): GraphQLSchema {
             }
 
             if (["Int", "Float", "BigInt"].includes(type.name)) {
-              filterOperators.numeric.forEach((suffix) => {
+              conditionSuffixes.numeric.forEach((suffix) => {
                 filterFields[`${columnName}${suffix}`] = {
                   type: type,
                 };
@@ -509,21 +510,6 @@ const innerType = (
   throw new Error(`Type ${type.toString()} is not implemented`);
 };
 
-const filterOperators = {
-  universal: ["", "_not"],
-  singular: ["_in", "_not_in"],
-  plural: ["_has", "_not_has"],
-  numeric: ["_gt", "_lt", "_gte", "_lte"],
-  string: [
-    "_contains",
-    "_not_contains",
-    "_starts_with",
-    "_ends_with",
-    "_not_starts_with",
-    "_not_ends_with",
-  ],
-} as const;
-
 async function executePluralQuery(
   table: TableRelationalConfig,
   drizzle: Drizzle<{ [key: string]: OnchainTable }>,
@@ -733,6 +719,25 @@ async function executePluralQuery(
   };
 }
 
+const conditionSuffixes = {
+  universal: ["", "_not"],
+  singular: ["_in", "_not_in"],
+  plural: ["_has", "_not_has"],
+  numeric: ["_gt", "_lt", "_gte", "_lte"],
+  string: [
+    "_contains",
+    "_not_contains",
+    "_starts_with",
+    "_ends_with",
+    "_not_starts_with",
+    "_not_ends_with",
+  ],
+} as const;
+
+const conditionSuffixesByLengthDesc = Object.values(conditionSuffixes)
+  .flat()
+  .sort((a, b) => b.length - a.length);
+
 function buildWhereConditions(
   where: Record<string, any> | undefined,
   columns: Record<string, Column>,
@@ -764,16 +769,32 @@ function buildWhereConditions(
       continue;
     }
 
-    const [fieldName, condition_] = whereKey.split(/_(.*)/s);
-    const condition = condition_ === undefined ? "" : condition_;
-
-    if (!fieldName || !(fieldName in columns)) {
-      throw new Error(`Invalid query: Unknown field ${fieldName}`);
+    // Search for a valid filter suffix, traversing the list from longest to shortest
+    // to avoid ambiguity between cases like `_not_in` and `_in`.
+    const conditionSuffix = conditionSuffixesByLengthDesc.find((s) =>
+      whereKey.endsWith(s),
+    );
+    if (conditionSuffix === undefined) {
+      throw new Error(
+        `Invariant violation: Condition suffix not found for where key ${whereKey}`,
+      );
     }
 
-    const column = columns[fieldName]!;
+    // Remove the condition suffix and use the remaining string as the column name.
+    const columnName = whereKey.slice(
+      0,
+      whereKey.length - conditionSuffix.length,
+    );
+
+    // Validate that the column name is present in the table.
+    const column = columns[columnName];
+    if (column === undefined) {
+      throw new Error(
+        `Invalid query: Where clause contains unknown column ${columnName}`,
+      );
+    }
 
-    switch (condition) {
+    switch (conditionSuffix) {
       case "":
         if (column.columnType === "PgArray") {
           conditions.push(
@@ -786,7 +807,7 @@ function buildWhereConditions(
           conditions.push(eq(column, rawValue));
         }
         break;
-      case "not":
+      case "_not":
         if (column.columnType === "PgArray") {
           conditions.push(
             not(
@@ -800,52 +821,50 @@ function buildWhereConditions(
           conditions.push(ne(column, rawValue));
         }
         break;
-      case "in":
+      case "_in":
         conditions.push(inArray(column, rawValue));
         break;
-      case "not_in":
+      case "_not_in":
         conditions.push(notInArray(column, rawValue));
         break;
-      case "has":
+      case "_has":
         conditions.push(arrayContains(column, [rawValue]));
         break;
-      case "not_has":
+      case "_not_has":
         conditions.push(not(arrayContains(column, [rawValue])));
         break;
-      case "gt":
+      case "_gt":
         conditions.push(gt(column, rawValue));
         break;
-      case "lt":
+      case "_lt":
         conditions.push(lt(column, rawValue));
         break;
-      case "gte":
+      case "_gte":
         conditions.push(gte(column, rawValue));
         break;
-      case "lte":
+      case "_lte":
         conditions.push(lte(column, rawValue));
         break;
-      case "contains":
+      case "_contains":
         conditions.push(like(column, `%${rawValue}%`));
         break;
-      case "not_contains":
+      case "_not_contains":
         conditions.push(notLike(column, `%${rawValue}%`));
         break;
-      case "starts_with":
+      case "_starts_with":
         conditions.push(like(column, `${rawValue}%`));
         break;
-      case "ends_with":
+      case "_ends_with":
         conditions.push(like(column, `%${rawValue}`));
         break;
-      case "not_starts_with":
+      case "_not_starts_with":
         conditions.push(notLike(column, `${rawValue}%`));
         break;
-      case "not_ends_with":
+      case "_not_ends_with":
         conditions.push(notLike(column, `%${rawValue}`));
         break;
       default:
-        throw new Error(
-          `Invalid query: Unknown condition ${condition} for field ${fieldName}`,
-        );
+        never(conditionSuffix);
     }
   }
 


Test patch:
diff --git a/packages/core/src/graphql/index.test.ts b/packages/core/src/graphql/index.test.ts
index f26ff019..e89f5ec4 100644
--- a/packages/core/src/graphql/index.test.ts
+++ b/packages/core/src/graphql/index.test.ts
@@ -2140,3 +2140,52 @@ test("column casing", async (context) => {
 
   await cleanup();
 });
+
+test("snake case table and column names with where clause", async (context) => {
+  const schema = {
+    deposited_token: onchainTable(
+      "deposited_token",
+      (t) => ({
+        chain_id: t.bigint().notNull(),
+        token_address: t.hex().notNull(),
+        first_seen_at: t.bigint().notNull(),
+        total_supply: t.bigint().notNull(),
+      }),
+      (table) => ({
+        pk: primaryKey({ columns: [table.token_address, table.chain_id] }),
+      }),
+    ),
+  };
+
+  const { database, indexingStore, metadataStore, cleanup } =
+    await setupDatabaseServices(context, { schema });
+  const contextValue = buildContextValue(database, metadataStore);
+  const query = (source: string) =>
+    execute({ schema: graphqlSchema, contextValue, document: parse(source) });
+
+  await indexingStore.insert(schema.deposited_token).values({
+    chain_id: 1n,
+    token_address: "0x0000000000000000000000000000000000000000",
+    first_seen_at: 0n,
+    total_supply: 0n,
+  });
+
+  const graphqlSchema = buildGraphQLSchema(schema);
+
+  const result = await query(`
+    query {
+      deposited_token(token_address: "0x0000000000000000000000000000000000000000", chain_id: "1") {
+        chain_id
+      }
+    }
+  `);
+
+  expect(result.errors?.[0]?.message).toBeUndefined();
+  expect(result.data).toMatchObject({
+    deposited_token: {
+      chain_id: "1",
+    },
+  });
+
+  await cleanup();
+});