Move Fields to Nested Objects

Move product fields into line items so a sale can contain multiple product entries.

Extending a set of fields into a list of field sets.

Business Step

Dave’s sale document still treats product information as if every sale has only one product.

That worked when each sale meant one lemonade order. But the business is now closer to a fast-food chain model. One sale can contain multiple things: a drink, a cookie, a snack, or several product variants in the same order.

The old product fields are still useful:

But they no longer belong directly to the sale as a whole. They belong to each item inside the sale.

This is a bigger change than adding a field or changing a field format. The product part of the document changes cardinality. It moves from one scalar set of fields to a repeatable list of item objects.

Evolution Goal

This scenario moves product fields out of sales and into a new lineitems table.

sales_v1 remains the older compatibility API. It continues to expose one root-level product_type and one scalar tags value. Internally, those fields come from the primary compatibility lineitem for the sale.

sales_v2 is the newer API. It exposes lineitems as an array, where each item has its own product fields.

Documentation view Role
sales_v1 Older compatibility API. Existing clients continue to read and write one root product_type and one scalar tags value.
sales_v2 Newer API. Newer clients read and write a lineitems array with one or more product rows.

Shape Change

The older product shape is flat.

{
  "_id"           : 19,
  "date"          : "2026-01-29",
  "quantity"      : 4,
  "amount"        : 18.00,
  "product_type"  : "Melon Lemonade",
  "tags"          : "fresh",
  "customer_name" : "diya",
  "email_address" : "diya@example.com",
  "stand_info"    : {
    "_id"           : 11,
    "stand_pin"     : "560011",
    "stand_address" : "theater cart, balcony"
  }
}

The newer product shape moves product information into a list.

{
  "_id"              : 20,
  "date"             : "2026-01-30",
  "total_quantity"   : 7,
  "amount"           : 32.50,
  "customer_details" : {
    "_id"   : 30,
    "name"  : "rhea",
    "email" : "rhea@example.com"
  },
  "lineitems"        : [
    {
      "_id"             : 200,
      "product_type"    : "Berry Lemonade",
      "product_subtype" : "Lemonade",
      "tags"            : "seasonal",
      "qty"             : 5
    },
    {
      "_id"             : 201,
      "product_type"    : "Lemon Cookie",
      "product_subtype" : "Cookie",
      "tags"            : "snack",
      "qty"             : 2
    }
  ],
  "stand_info"       : {
    "_id"           : 12,
    "stand_pin"     : "560012",
    "stand_address" : "arcade cart, main hall"
  }
}

The sale still has sale-level quantity and amount. The product details now live in lineitems.

Preprocessing

Before this scenario, product_type and tags are stored directly on sales.

After this scenario, sales stores sale-level data, and lineitems stores item-level product data.

The preprocessing creates the evolved product table.

CREATE TABLE lineitems (
  item_id          INTEGER GENERATED BY DEFAULT ON NULL AS IDENTITY,
  sale_id          INTEGER NOT NULL,
  is_primary       BOOLEAN DEFAULT FALSE NOT NULL,
  product_type     VARCHAR2(40) NOT NULL,
  product_subtype  VARCHAR2(40) DEFAULT 'Classic' NOT NULL,
  tags             VARCHAR2(40) DEFAULT 'classic' NOT NULL,
  qty              NUMBER(6) DEFAULT 1 NOT NULL,
  CONSTRAINT lineitems_pk PRIMARY KEY (item_id),
  CONSTRAINT lineitems_sale_fk FOREIGN KEY (sale_id)
    REFERENCES sales (sale_id)
);

The important new column is is_primary.

The older API can expose only one flat product field set. The newer storage model can hold many lineitems for the same sale. The is_primary boolean field identifies the one lineitem that represents the old scalar product slot.

Existing sales are backfilled with one primary compatibility lineitem.

INSERT INTO lineitems (item_id, sale_id, is_primary, product_type,
                       product_subtype, tags, qty)
SELECT sale_id, sale_id, TRUE, product_type,
       CASE
         WHEN product_type LIKE '%Cookie' THEN 'Cookie'
         WHEN product_type LIKE '%Cooler' THEN 'Cooler'
         WHEN product_type LIKE '%Limeade' THEN 'Limeade'
         ELSE 'Lemonade'
       END,
       tags,
       quantity
FROM sales;

For migrated rows, item_id is set to the same value as sale_id so the old sales-rooted compatibility view can regenerate the hidden lineitem key from the root _id during writes. This keeps old flat documents addressable after product data moves from sales into lineitems.

After the lineitem rows are created, the product fields can be removed from sales.

ALTER TABLE sales DROP (product_type, tags);

After this change, sales no longer need to store product fields directly. Product data lives in lineitems.

Relational Shape After Preprocessing

After preprocessing, the affected tables have this shape.

SQL> DESCRIBE sales;

Name                                      Null?    Type
----------------------------------------- -------- ----------------------------
SALE_ID                                   NOT NULL NUMBER(38)
SALE_DATE                                          DATE
QUANTITY                                  NOT NULL NUMBER
AMOUNT                                    NOT NULL NUMBER(10,2)
STAND_ID                                  NOT NULL NUMBER(38)
CUSTOMER_ID                               NOT NULL NUMBER(38)

SQL> DESCRIBE lineitems;

Name                                      Null?    Type
----------------------------------------- -------- ----------------------------
ITEM_ID                                   NOT NULL NUMBER(38)
SALE_ID                                   NOT NULL NUMBER(38)
IS_PRIMARY                                NOT NULL BOOLEAN
PRODUCT_TYPE                              NOT NULL VARCHAR2(40)
PRODUCT_SUBTYPE                           NOT NULL VARCHAR2(40)
TAGS                                      NOT NULL VARCHAR2(40)
QTY                                       NOT NULL NUMBER(6)

The relationship is:

sales.sale_id -> lineitems.sale_id

sales owns the sale-level facts. lineitems owns the product-level facts.

sales_v1 Compatibility View

sales_v1 keeps the old flat product API alive.

The view remains rooted at sales. That keeps the old sale document identity stable: _id maps to sales.sale_id.

The old root-level product_type and tags fields now come from the primary compatibility lineitem. The view uses the is_primary: true condition so the unnested lineitem path targets only the primary compatibility row.

CREATE OR REPLACE JSON RELATIONAL DUALITY VIEW sales_v1 AS
sales @insert @update
{
  _id: sale_id,
  date: sale_date,
  quantity,
  amount,
  customers @insert @update @nodelete @unnest
  {
    customer_id: customer_id @hidden,
    customer_name: customer_name,
    email_address: email_address
  },
  lineitems (is_primary: true) @insert @update @nodelete @unnest
  {
    item_id @hidden,
    sale_id @hidden @generated(path: "$._id", on: "write"),
    is_primary @hidden @generated(path: "true", on: "write"),
    product_type,
    tags
  },
  stand_info: stands @insert @update @nodelete
  {
    _id: stand_id,
    stand_pin: pin,
    stand_address: address
  }
};

The key block is the unnested lineitems mapping.

lineitems (is_primary: true) @insert @update @nodelete @unnest
{
  item_id @hidden,
  sale_id @hidden @generated(path: "$._id", on: "write"),
  is_primary @hidden @generated(path: "true", on: "write"),
  product_type,
  tags
}

This block preserves the older JSON shape.

Field or clause Purpose
lineitems condition on is_primary = true Restricts the old flat product fields to the primary compatibility item.
@unnest Keeps product_type and tags at the root of the old JSON document.
item_id @hidden Keeps the lineitem identity out of the old API.
sale_id @hidden @generated(path: “$._id”, on: “write”) Connects the compatibility lineitem to the sale being written.
is_primary @hidden @generated(path: “true”, on: “write”) Marks old-view product writes as the primary compatibility item.

sales_v1 Write Behavior

When an older client inserts through sales_v1, the document still looks flat.

{
  "_id"           : 19,
  "date"          : "2026-01-29",
  "quantity"      : 4,
  "amount"        : 18.00,
  "product_type"  : "Melon Lemonade",
  "tags"          : "fresh",
  "customer_name" : "diya",
  "email_address" : "diya@example.com",
  "stand_info"    : {
    "_id"           : 11,
    "stand_pin"     : "560011",
    "stand_address" : "theater cart, balcony"
  }
}

The compatibility view breaks that document into sale, customer, stand, and lineitem rows.

JSON field Stored in
_id sales.sale_id
date sales.sale_date
quantity sales.quantity
amount sales.amount
customer_name customers.customer_name
email_address customers.email_address
product_type lineitems.product_type on the primary compatibility row
tags lineitems.tags on the primary compatibility row
stand_info stands, linked from sales.stand_id

The old API does not expose the lineitem array. It writes one primary compatibility lineitem for the sale.

Use of the is_primary Boolean Field

The old API has one product slot.

The new API has a list of lineitems.

is_primary marks the one lineitem that represents the old product slot. That lets sales_v1 stay rooted at sales while still exposing product_type and tags as root-level fields.

Lineitem type Visible through sales_v1 Visible through sales_v2
Primary lineitem, is_primary = true Yes, as root-level product_type and tags. Yes, as one element in the lineitems array.
Non-primary lineitem, is_primary = false No. Yes, as another element in the lineitems array.

This avoids making the old API choose among multiple lineitems. It has one compatibility slot, and that slot is marked in storage.

sales_v2 Evolved View

sales_v2 exposes the new model directly.

The root is still sales. The sale contains a lineitems array. Each lineitem has its own product fields.

CREATE OR REPLACE JSON RELATIONAL DUALITY VIEW sales_v2 AS
sales @insert @update @delete
{
  _id: sale_id,
  date: sale_date,
  total_quantity: quantity,
  amount,
  customer_details: customers @insert @update @nodelete
  {
    _id: customer_id,
    name: customer_name,
    email: email_address
  },
  lineitems: lineitems @insert @update @delete
  [
    {
      _id: item_id,
      is_primary @hidden,
      product_type,
      product_subtype,
      tags,
      qty
    }
  ],
  stand_info: stands @insert @update @nodelete
  {
    _id: stand_id,
    stand_pin: pin,
    stand_address: address
  }
};

The is_primary field is hidden in the newer API. Newer clients work with the lineitem array. They do not need to know which row is used for old compatibility.

New Document Shape

A newer client can now insert a sale with multiple lineitems.

{
  "_id"              : 20,
  "date"             : "2026-01-30",
  "total_quantity"   : 7,
  "amount"           : 32.50,
  "customer_details" : {
    "_id"   : 30,
    "name"  : "rhea",
    "email" : "rhea@example.com"
  },
  "lineitems"        : [
    {
      "_id"             : 200,
      "product_type"    : "Berry Lemonade",
      "product_subtype" : "Lemonade",
      "tags"            : "seasonal",
      "qty"             : 5
    },
    {
      "_id"             : 201,
      "product_type"    : "Lemon Cookie",
      "product_subtype" : "Cookie",
      "tags"            : "snack",
      "qty"             : 2
    }
  ],
  "stand_info"       : {
    "_id"           : 12,
    "stand_pin"     : "560012",
    "stand_address" : "arcade cart, main hall"
  }
}

sales_v2 owns this shape. It can insert, update, and delete the full lineitem array.

Evolution Thought Process

This topic changes the cardinality of product data.

Before this scenario, a sale had one product_type and one tags value. After this scenario, a sale can have many lineitems, and each lineitem has its own product fields.

sales_v1 hides that change for old clients. It still presents one flat product_type and one scalar tags value by reading and writing the primary compatibility lineitem.

sales_v2 exposes the new model directly. It shows lineitems as an array because newer clients understand that one sale can contain many products.

Concept Old API New API
Product shape Flat product_type and tags at the sale root. lineitems array.
Product cardinality One product field set. Many product item objects.
Storage before this topic product_type and tags on sales. product_type and tags on sales.
Storage after this topic Primary compatibility row in lineitems. All product rows in lineitems.
Write scope One primary compatibility item. Full lineitems array.

The important design move is not only creating a new table. It is also choosing how the old one-product API maps to one row in the new child table.

Behavior

API or schema object Behavior
sales_v1.product_type Root-level field backed by the primary lineitems row.
sales_v1.tags Root-level field backed by the primary lineitems row.
lineitems.is_primary Marks the lineitem used by the old flat compatibility API.
sales_v2.lineitems Exposes the full list of product rows for the sale.
sales_v2.total_quantity Exposes sales.quantity as the sale-level total quantity.
lineitems.sale_id Links each item back to its sale.
lineitems_sale_fk Enforces that each lineitem belongs to a sale.

When to use this strategy

Use this strategy when one group of fields has become repeatable. It is a good fit when the old document had one product, contact method, address, or detail row, but the newer business process needs a list of those things.

Limitation

sales_v1 can preserve one flat product_type and one scalar tags value by mapping them to one primary compatibility lineitem. It does not expose the full lineitem list.

A sale inserted only through sales_v2 can contain only non-primary lineitems. In that case, sales_v1 intentionally does not flatten that sale into an old product document, because there is no primary compatibility item to represent the old scalar product slot. The new API still sees both kinds of rows: old primary compatibility lineitems and new non-primary lineitems.

Deletes via sales_v1 are intentionally disallowed. sales_v1 preserves insert and update for the single primary compatibility item, while full sale deletion belongs to sales_v2.

New Behavior Used

This scenario introduces the move from scalar fields to a child collection.

Feature Why it matters
lineitems table Stores product details as repeatable rows instead of sale-level columns.
is_primary compatibility marker Identifies the one lineitem that represents the old flat product fields.
is_primary = true condition Restricts old-view product reads and writes to the primary compatibility row.
@unnest on lineitems in sales_v1 Keeps product_type and tags at the root of the old JSON document.
@hidden generated fields Supply sale_id and is_primary for old-view writes without exposing them in the old API.
Nested array Lets sales_v2 expose many lineitem objects under one sale.

Evolution Check

By the end of this scenario, the evolution proves the following:

Topic Takeaway

This scenario turns one product field set into a repeatable item list.

Older clients still see one product_type and one tags value at the root. Internally, that old shape is backed by one primary compatibility lineitem.

Newer clients get a lineitems array that can represent a real multi-item order.

The key compatibility idea is the primary lineitem. One old flat product shape maps to one marked item row, while the newer API can use the full collection.