← All posts

I’m not a GQL specialist. I still surfaced hidden patterns in a Spanner database in an afternoon.

Diénert Vieira · · 5 min read

I’m not a GQL specialist. I still surfaced hidden patterns in a Spanner database in an afternoon.

If your company runs on Google Cloud Spanner, the answers to some of your CEO’s most important questions are probably already in your database. Which customers spend the most? Which products get bought together? Who added something to their cart and never came back? The data is all there, encoded in foreign keys and join tables, waiting to be asked.

The problem is that SQL makes you state every connection explicitly. By the time you’ve written a five-table join to answer one business question, you’ve lost the thread of what you were actually trying to find. And the result is a table of rows, not a picture of how your customers actually behave.

I ran into this while working with Cymbal, Google Cloud’s sample e-commerce Spanner database. I extended the built-in property graph with one DDL statement, connected it to GraphXR for visual exploration, and loaded a Grovebook with seven queries any analyst can run with a single click. I also want to be upfront: I’m not a GQL specialist. I hit syntax errors along the way, and Gemini fixed most of them in seconds. That combination turned out to be the real story.

What the graph layer adds

Cymbal includes a basic property graph called ECommerceGraph — five node types, two edge types, enough to traverse from users to products via orders. But it doesn’t name the relationships already implicit in the schema. The `Orders` table knows which user placed an order and where it shipped. The Payments table knows who made a payment and what it was for. Those relationships exist; they’re just not accessible as graph edges.

The extension names them. Using Spanner Graph’s LABEL syntax, one table can produce multiple named node or edge types at the same time. `Orders` becomes both a PLACED edge (User to Order) and a SHIPPED_TO edge (Order to Address). No new data, just named semantics layered on top of what’s already there. The result: 7 node types, 9 named relationships.

Cymbal Extended ECommerce Graph Schema Cymbal Extended ECommerce Graph Schema

The DDL looks like this:

CREATE PROPERTY GRAPH ECommerceGraphExtended
  NODE TABLES(
    -- Standard tables — LABEL renames them to singular
    Users         KEY(UserID)            LABEL User
                  PROPERTIES(UserID, Email),
    Products      KEY(ProductID)         LABEL Product
                  PROPERTIES(ProductID, Name, Description, ImageURL, PriceUSD, 
                             Category),
    Orders        KEY(OrderID)           LABEL `Order`
                  PROPERTIES(OrderID, OrderDate, TotalAmountUSD, OrderStatus),
    Payments      KEY(PaymentID)         LABEL Payment
                  PROPERTIES(PaymentID, PaymentDate, 
                             PaymentMethod, AmountUSD, Status, 
                             TransactionID),
    Addresses     KEY(UserID, AddressID) LABEL Address
                  PROPERTIES(AddressID, StreetAddress, City, State, 
                             Country, ZipCode),

    -- Promoted from edges: now first-class nodes with queryable properties
    OrderItems    KEY(OrderID, OrderItemID)  LABEL OrderItem
                  PROPERTIES(OrderItemID, OrderID, ProductID, Quantity, 
                             PriceAtOrderUSD),
    ShoppingCarts KEY(UserID, ProductID)     LABEL ShoppingCart
                  PROPERTIES(UserID, ProductID, Quantity, AddedDate)
  )
  EDGE TABLES(
    -- OrderItem connects to its parent Order and to the Product it references
    OrderItems AS IS_PART_OF
      KEY(OrderID, OrderItemID)
      SOURCE KEY(OrderID, OrderItemID) REFERENCES OrderItems(OrderID, OrderItemID)
      DESTINATION KEY(OrderID)         REFERENCES Orders(OrderID)
      LABEL IS_PART_OF,

    OrderItems AS REFERENCES_PRODUCT
      KEY(OrderID, OrderItemID)
      SOURCE KEY(OrderID, OrderItemID) REFERENCES OrderItems(OrderID, OrderItemID)
      DESTINATION KEY(ProductID)       REFERENCES Products(ProductID)
      LABEL REFERENCES_PRODUCT,

    -- ShoppingCart connects to its User and to the Product it contains
    ShoppingCarts AS BELONGS_TO
      KEY(UserID, ProductID)
      SOURCE KEY(UserID, ProductID) REFERENCES ShoppingCarts(UserID, ProductID)
      DESTINATION KEY(UserID)       REFERENCES Users(UserID)
      LABEL BELONGS_TO,

    ShoppingCarts AS CONTAINS_PRODUCT
      KEY(UserID, ProductID)
      SOURCE KEY(UserID, ProductID) REFERENCES ShoppingCarts(UserID, ProductID)
      DESTINATION KEY(ProductID)    REFERENCES Products(ProductID)
      LABEL CONTAINS_PRODUCT,

    -- Orders table yields two edge types: who placed it, where it shipped
    Orders AS PLACED
      KEY(OrderID)
      SOURCE KEY(UserID)       REFERENCES Users(UserID)
      DESTINATION KEY(OrderID) REFERENCES Orders(OrderID)
      LABEL PLACED,

    Orders AS SHIPPED_TO
      KEY(OrderID)
      SOURCE KEY(OrderID)                        REFERENCES Orders(OrderID)
      DESTINATION KEY(UserID, ShippingAddressID) REFERENCES Addresses(UserID, 
                                                                      AddressID)
      LABEL SHIPPED_TO,

    -- Where the user lives
    Addresses AS LIVES_AT
      KEY(UserID, AddressID)
      SOURCE KEY(UserID)                 REFERENCES Users(UserID)
      DESTINATION KEY(UserID, AddressID) REFERENCES Addresses(UserID, 
                                                              AddressID)
      LABEL LIVES_AT,

    -- Payments table yields two edge types: what it paid for, who made it
    Payments AS PAYS_FOR
      KEY(PaymentID)
      SOURCE KEY(PaymentID) REFERENCES Payments(PaymentID)
      DESTINATION KEY(OrderID) REFERENCES Orders(OrderID)
      LABEL PAYS_FOR,

    Payments AS MADE_BY
      KEY(PaymentID)
      SOURCE KEY(UserID)         REFERENCES Users(UserID)
      DESTINATION KEY(PaymentID) REFERENCES Payments(PaymentID)
      LABEL MADE_BY
  );

One statement. The graph is live immediately.

One thing caught me off guard: Order is a reserved word in SQL. Spanner Studio refused to parse my DDL, and the error message wasn’t helpful. I stared at it for a while before remembering that backticks fix it. Easy fix once you know, but it cost me time.

The questions you can ask now

GraphXR reads the schema automatically — all 7 node types and 9 edge types appear in the query bar. I loaded them into a Grovebook, GraphXR’s interactive notebook, so anyone on the team can run the queries below with a single click.

Who added something to their cart and never bought it?

GRAPH ECommerceGraphExtended
MATCH (sc:ShoppingCart)-[:BELONGS_TO]->(u:User),
      (sc)-[:CONTAINS_PRODUCT]->(p:Product)
WHERE NOT EXISTS {
  MATCH (user_alias:User)-[:PLACED]->(o:`Order`)<-[:IS_PART_OF]-
  (oi:OrderItem)-[:REFERENCES_PRODUCT]->(product_alias:Product)
  WHERE user_alias = u AND product_alias = p
}
RETURN u.Email, p.Name, sc.Quantity, sc.AddedDate
LIMIT 100

A table of users, the products they abandoned, and when. In SQL, that’s a NOT EXISTS across three joined tables. Here, it reads almost like a sentence.

Which products get bought together?

GRAPH ECommerceGraphExtended
MATCH (p1:Product)<-[:REFERENCES_PRODUCT]-(oi1:OrderItem)-[:IS_PART_OF]->(o:`Order`)<-[:IS_PART_OF]-(oi2:OrderItem)-[:REFERENCES_PRODUCT]->(p2:Product)
WHERE p1.ProductID < p2.ProductID
WITH p1, p2, COUNT(o) AS timesBoughtTogether
ORDER BY timesBoughtTogether DESC
LIMIT 20
RETURN p1.Name AS product1, p2.Name AS product2, timesBoughtTogether

A ranked list of product pairs by co-occurrence. The raw material for a recommendation engine, queryable in seconds. This one only works because OrderItem is a first-class node you can traverse through — without that, there’s no path connecting two products through a shared order.

Products bought together Products bought together

Who are your top customers by lifetime value?

GRAPH ECommerceGraphExtended
MATCH (u:User)-[:PLACED]->(o:`Order`)
WITH u, SUM(o.TotalAmountUSD) AS totalSpend, COUNT(o) AS orderCount
ORDER BY totalSpend DESC
LIMIT 20
RETURN u.Email AS email, u.UserID AS userId, totalSpend, orderCount

In GraphXR, these load as nodes sized by spend. Your biggest customers are literally the biggest circles on the screen.

Where Gemini came in

The NOT EXISTS pattern in particular tripped me up — Spanner GQL doesn’t let you reuse outer query variables directly inside a subquery. Every time I tried, the query returned nothing and I couldn’t figure out why.

I pasted the error into Gemini. It explained the issue in one paragraph: inner subqueries need their own typed variable names, bound back to the outer scope with a WHERE clause. I fixed the remaining queries in a few minutes. The point isn’t that GQL is hard — it’s that you don’t need to master it before you can use it. You just need to be willing to debug with help.

The broader point

Your Spanner schema already encodes relationships. Every foreign key is a named relationship waiting to be expressed. The graph DDL is just the act of naming them so they become traversable, and once they’re traversable, questions that felt like engineering projects become one-line queries.

If your team has Spanner and a business question that involves more than one table, this pattern is worth an afternoon.

Resources:

  1. Cymbal Extended ECommerce — GraphXR Project
  2. Spanner Graph DDL Reference
  3. Kineviz GraphXR for Spanner Graph

Cloud SpannerGraph DatabaseGoogle Cloud PlatformData VisualizationGql