Metafield Runtime Filtering Guide
This document explains how to filter products using metafields with runtime (JavaScript) filtering instead of database-level SQL queries.
Reference: Shopify Metafield Types Documentation
Why Runtime Filtering?
The Problem: Shopify has ~48 different metafield types (19 list types, 11 reference types, 18 base types). Trying to create database columns for each type is impractical and complex.
The Solution: Store all metafield values as a single value string column, then filter products in JavaScript at runtime based on the metafield type field.
Why This Works:
- ✅ Simple database schema (one
valuecolumn) - ✅ Handles all 48 types without schema changes
- ✅ Flexible filtering logic
- ✅ Perfect for ~300 products (current FG quiz = true) (small/medium dataset, manageable in memory)
- ✅ Progressive filtering example at runtime: 300 → 150 → 70 → final list for the AI.
Trade-offs:
- ❌ Must fetch all products, then filter in memory
- ❌ Can't use SQL indexes for filtering
- ⚠️ Performance degrades if scaling to thousands of products
Schema Design
Simple Approach: Single value Column
model ProductMetafield {
id String @id @default(cuid())
productId String
namespace String
key String
value String // All values stored as string (Shopify API format)
type String // Shopify type (e.g., 'single_line_text_field', 'list.single_line_text_field', 'money')
ownerType String
definitionId String?
// ... other fields
}
model VariantMetafield {
id String @id @default(cuid())
variantId String
namespace String
key String
value String // All values stored as string
type String // Shopify type
ownerType String
definitionId String?
// ... other fields
}Note: Shopify API always returns values as strings, so we store them as strings. The type field tells us how to parse and filter them.
The Use Case
- Start with ~300 products in database
- User fills out quiz/form with multiple questions
- Each question creates filter conditions:
- Include products (must have X)
- Exclude products (must NOT have X)
- Filter by metafields (custom data)
- Filter by product fields (vendor, tags, etc.)
- Filter by variant fields and variant metafields
- All conditions combined progressively narrow down: 300 → 150 → 70 → final list
- Final filtered product list sent to AI for processing
Value Parsing by Type
Helper: Parse Metafield Value
/**
* Parse metafield value based on its type
* @param {Object} metafield - Metafield object with 'type' and 'value'
* @returns {any} Parsed value (string, number, boolean, array, object, null)
*/
function parseMetafieldValue(metafield) {
if (!metafield || !metafield.value) return null;
const type = metafield.type;
// Array types (list.*) - stored as JSON string
if (type.startsWith('list.')) {
try {
return JSON.parse(metafield.value);
} catch {
return [];
}
}
// JSON object types - stored as JSON string
const jsonObjectTypes = [
'money', 'dimension', 'weight', 'volume',
'rating', 'link', 'json', 'rich_text_field'
];
if (jsonObjectTypes.includes(type)) {
try {
return JSON.parse(metafield.value);
} catch {
return null;
}
}
// Number types - stored as string, parse to number
if (type === 'number_integer' || type === 'number_decimal') {
return parseFloat(metafield.value);
}
// Boolean - stored as string "true"/"false"
if (type === 'boolean') {
return metafield.value === 'true' || metafield.value === true;
}
// Everything else is string (dates, URLs, text, references, etc.)
return metafield.value;
}Standardized Filter Functions
Operator Functions
/**
* Apply operator to single value
*/
function applyOperator(actualValue, operator, expectedValue) {
if (actualValue === null || actualValue === undefined) return false;
switch (operator) {
case 'equals':
return actualValue === expectedValue;
case 'not_equals':
return actualValue !== expectedValue;
case 'contains':
return String(actualValue).toLowerCase().includes(String(expectedValue).toLowerCase());
case 'starts_with':
return String(actualValue).toLowerCase().startsWith(String(expectedValue).toLowerCase());
case 'ends_with':
return String(actualValue).toLowerCase().endsWith(String(expectedValue).toLowerCase());
case 'greater_than':
return parseFloat(actualValue) > parseFloat(expectedValue);
case 'less_than':
return parseFloat(actualValue) < parseFloat(expectedValue);
case 'greater_equal':
return parseFloat(actualValue) >= parseFloat(expectedValue);
case 'less_equal':
return parseFloat(actualValue) <= parseFloat(expectedValue);
case 'in':
return Array.isArray(expectedValue) && expectedValue.includes(actualValue);
case 'not_in':
return Array.isArray(expectedValue) && !expectedValue.includes(actualValue);
default:
return false;
}
}
/**
* Apply operator to array value
*/
function applyArrayOperator(actualArray, operator, expectedValue) {
if (!Array.isArray(actualArray)) return false;
switch (operator) {
case 'contains':
return actualArray.includes(expectedValue);
case 'contains_any_of':
return Array.isArray(expectedValue) &&
expectedValue.some(val => actualArray.includes(val));
case 'contains_all_of':
return Array.isArray(expectedValue) &&
expectedValue.every(val => actualArray.includes(val));
case 'not_contains':
return !actualArray.includes(expectedValue);
case 'not_contains_any_of':
return Array.isArray(expectedValue) &&
!expectedValue.some(val => actualArray.includes(val));
case 'equals':
// Compare arrays (order-independent)
const sortedActual = [...actualArray].sort();
const sortedExpected = Array.isArray(expectedValue)
? [...expectedValue].sort()
: [expectedValue].sort();
return JSON.stringify(sortedActual) === JSON.stringify(sortedExpected);
default:
return false;
}
}Filter Functions by Field Type
/**
* Filter by product field (vendor, tags, status, etc.)
*/
function filterProductField(product, field, operator, value, exclude = false) {
let productValue = product[field];
// Handle array fields (tags)
if (Array.isArray(productValue)) {
const matches = applyArrayOperator(productValue, operator, value);
return exclude ? !matches : matches;
}
// Handle string/number/boolean fields
const matches = applyOperator(productValue, operator, value);
return exclude ? !matches : matches;
}
/**
* Filter by product metafield (any type)
*/
function filterProductMetafield(product, namespace, key, operator, value, exclude = false) {
const metafield = product.metafields?.find(
mf => mf.namespace === namespace && mf.key === key
);
if (!metafield) return exclude; // If metafield doesn't exist, exclude if exclude=true
const parsedValue = parseMetafieldValue(metafield);
const type = metafield.type;
// Handle array types
if (type.startsWith('list.')) {
const matches = applyArrayOperator(parsedValue, operator, value);
return exclude ? !matches : matches;
}
// Handle JSON object types (money, dimension, etc.)
if (type === 'money' && parsedValue?.amount) {
// For money: compare amount field
const matches = applyOperator(parseFloat(parsedValue.amount), operator, value);
return exclude ? !matches : matches;
}
if (type === 'dimension' && parsedValue?.value) {
// For dimension: compare value field
const matches = applyOperator(parseFloat(parsedValue.value), operator, value);
return exclude ? !matches : matches;
}
if (type === 'weight' && parsedValue?.value) {
// For weight: compare value field
const matches = applyOperator(parseFloat(parsedValue.value), operator, value);
return exclude ? !matches : matches;
}
if (type === 'volume' && parsedValue?.value) {
// For volume: compare value field
const matches = applyOperator(parseFloat(parsedValue.value), operator, value);
return exclude ? !matches : matches;
}
if (type === 'rating' && parsedValue?.value) {
// For rating: compare value field
const matches = applyOperator(parseFloat(parsedValue.value), operator, value);
return exclude ? !matches : matches;
}
// Handle number types
if (type === 'number_integer' || type === 'number_decimal') {
const matches = applyOperator(parsedValue, operator, value);
return exclude ? !matches : matches;
}
// Handle boolean
if (type === 'boolean') {
const matches = parsedValue === value;
return exclude ? !matches : matches;
}
// Handle string types (text, date, URL, reference, etc.)
// For dates, you might want to parse and compare properly
if (type === 'date' || type === 'date_time') {
// Date comparison
const actualDate = new Date(parsedValue);
const expectedDate = new Date(value);
let matches = false;
switch (operator) {
case 'equals':
matches = actualDate.getTime() === expectedDate.getTime();
break;
case 'after':
matches = actualDate > expectedDate;
break;
case 'before':
matches = actualDate < expectedDate;
break;
case 'on_or_after':
matches = actualDate >= expectedDate;
break;
case 'on_or_before':
matches = actualDate <= expectedDate;
break;
default:
matches = applyOperator(parsedValue, operator, value);
}
return exclude ? !matches : matches;
}
// Default: string comparison
const matches = applyOperator(parsedValue, operator, value);
return exclude ? !matches : matches;
}
/**
* Filter by variant metafield
*/
function filterVariantMetafield(product, namespace, key, operator, value, exclude = false) {
if (!product.variants) return exclude;
const hasMatchingVariant = product.variants.some(variant => {
const metafield = variant.metafields?.find(
mf => mf.namespace === namespace && mf.key === key
);
if (!metafield) return false;
const parsedValue = parseMetafieldValue(metafield);
const type = metafield.type;
// Handle array types
if (type.startsWith('list.')) {
return applyArrayOperator(parsedValue, operator, value);
}
// Handle JSON object types (same logic as product metafields)
if (type === 'money' && parsedValue?.amount) {
return applyOperator(parseFloat(parsedValue.amount), operator, value);
}
// Handle other types (same as product metafields)
if (type === 'number_integer' || type === 'number_decimal') {
return applyOperator(parsedValue, operator, value);
}
if (type === 'boolean') {
return parsedValue === value;
}
// Default: string comparison
return applyOperator(parsedValue, operator, value);
});
return exclude ? !hasMatchingVariant : hasMatchingVariant;
}
/**
* Filter by variant field
*/
function filterVariantField(product, field, operator, value, exclude = false) {
if (!product.variants) return exclude;
const hasMatchingVariant = product.variants.some(variant => {
let variantValue = variant[field];
// Handle price (stored as string)
if (field === 'price') {
variantValue = parseFloat(variantValue);
}
return applyOperator(variantValue, operator, value);
});
return exclude ? !hasMatchingVariant : hasMatchingVariant;
}Unified Filter Function
/**
* Apply a single filter to a product
* @param {Object} product - Product with metafields and variants
* @param {Object} filterConfig - Filter configuration
* @param {string} filterConfig.field - Field path (e.g., 'vendor', 'custom.flavors', 'variants.price', 'variants.custom.size')
* @param {string} filterConfig.operator - Operator ('equals', 'contains', 'greater_than', etc.)
* @param {any} filterConfig.value - Value to compare against
* @param {boolean} filterConfig.exclude - If true, exclude products that match (default: false)
* @returns {boolean} True if product passes filter
*/
function applyFilter(product, filterConfig) {
const { field, operator, value, exclude = false } = filterConfig;
// Parse field path
const parts = field.split('.');
// Product field (vendor, tags, status, productType, title)
const productFields = ['vendor', 'tags', 'status', 'productType', 'title', 'handle'];
if (productFields.includes(parts[0])) {
return filterProductField(product, parts[0], operator, value, exclude);
}
// Variant field (variants.price, variants.sku, variants.availableForSale)
if (parts[0] === 'variants' && parts.length === 2) {
const variantField = parts[1];
return filterVariantField(product, variantField, operator, value, exclude);
}
// Variant metafield (variants.custom.size, variants.custom.flavors)
if (parts[0] === 'variants' && parts.length === 3) {
const namespace = parts[1];
const key = parts[2];
return filterVariantMetafield(product, namespace, key, operator, value, exclude);
}
// Product metafield (custom.flavors, nutrition_panel.calories)
if (parts.length === 2) {
const namespace = parts[0];
const key = parts[1];
return filterProductMetafield(product, namespace, key, operator, value, exclude);
}
// Default: include if filter doesn't match
return true;
}Complete Usage Example
// 1. Fetch all products with metafields and variants
const allProducts = await prisma.product.findMany({
include: {
metafields: true,
variants: {
include: {
metafields: true
}
}
}
});
// 2. Filter configuration from quiz responses
const filters = [
// Product fields
{ field: 'vendor', operator: 'in', value: ['BrandA', 'BrandB', 'BrandC'], exclude: false },
{ field: 'vendor', operator: 'in', value: ['BrandX', 'BrandY'], exclude: true },
{ field: 'status', operator: 'equals', value: 'active', exclude: false },
{ field: 'tags', operator: 'contains_any_of', value: ['organic', 'gluten-free'], exclude: false },
// Product metafields (array types)
{ field: 'custom.dietary_preferences', operator: 'contains_any_of', value: ['vegan'], exclude: false },
{ field: 'custom.flavors', operator: 'contains_any_of', value: ['citrus', 'lemon'], exclude: true },
// Product metafields (single string)
{ field: 'custom.product_type', operator: 'equals', value: 'supplement', exclude: false },
// Product metafields (money type)
{ field: 'custom.price', operator: 'greater_than', value: 20, exclude: false },
// Product metafields (date type)
{ field: 'custom.release_date', operator: 'after', value: '2024-01-01', exclude: false },
// Variant fields
{ field: 'variants.availableForSale', operator: 'equals', value: true, exclude: false },
{ field: 'variants.price', operator: 'greater_than', value: 20, exclude: false },
// Variant metafields
{ field: 'variants.custom.size', operator: 'equals', value: 'large', exclude: false },
{ field: 'variants.custom.flavors', operator: 'contains_any_of', value: ['lemon'], exclude: true },
];
// 3. Apply all filters
const filteredProducts = allProducts.filter(product => {
return filters.every(filter => applyFilter(product, filter));
});
// 4. Result: ~70 products ready for AI processing
console.log(`Filtered from ${allProducts.length} to ${filteredProducts.length} products`);Real-World Example
Quiz Scenario:
- ✅ Must be vegan
- ✅ Must be from BrandA, BrandB, or BrandC
- ❌ Must NOT have citrus or lemon flavors
- ❌ Must NOT be from BrandX or BrandY
- ✅ Must have price > $20
- ✅ Must have at least one variant available for sale
const filters = [
{ field: 'custom.dietary_preferences', operator: 'contains_any_of', value: ['vegan'], exclude: false },
{ field: 'vendor', operator: 'in', value: ['BrandA', 'BrandB', 'BrandC'], exclude: false },
{ field: 'custom.flavors', operator: 'contains_any_of', value: ['citrus', 'lemon'], exclude: true },
{ field: 'vendor', operator: 'in', value: ['BrandX', 'BrandY'], exclude: true },
{ field: 'custom.price', operator: 'greater_than', value: 20, exclude: false },
{ field: 'variants.availableForSale', operator: 'equals', value: true, exclude: false },
];
const filteredProducts = allProducts.filter(product =>
filters.every(filter => applyFilter(product, filter))
);Supported Operators
String Operators
equals- Exact matchnot_equals- Not equalcontains- Contains substring (case-insensitive)starts_with- Starts with stringends_with- Ends with stringin- Value is in arraynot_in- Value is not in array
Number Operators
equals- Equal tonot_equals- Not equal togreater_than- Greater thanless_than- Less thangreater_equal- Greater than or equalless_equal- Less than or equal
Array Operators
contains- Array contains valuecontains_any_of- Array contains any of the valuescontains_all_of- Array contains all of the valuesnot_contains- Array does not contain valuenot_contains_any_of- Array does not contain any of the valuesequals- Arrays are equal (order-independent)
Date Operators
equals- Same dateafter- Date is afterbefore- Date is beforeon_or_after- Date is on or afteron_or_before- Date is on or before
Handling All Metafield Types
Array Types (list.*)
All list types are stored as JSON strings and parsed to arrays:
list.single_line_text_field→ Array of stringslist.number_integer→ Array of numbers (stored as strings, parsed)list.date→ Array of date stringslist.boolean→ Array of booleans (stored as strings, parsed)list.product_reference→ Array of GID stringslist.link→ Array of JSON objectslist.rating→ Array of JSON objects
JSON Object Types
All JSON object types are stored as JSON strings and parsed:
money→{amount: "5.99", currency_code: "CAD"}dimension→{value: 25.0, unit: "cm"}weight→{value: 2.5, unit: "kg"}volume→{value: 20.0, unit: "ml"}rating→{value: "3.5", scale_min: "1.0", scale_max: "5.0"}link→{text: "Learn more", url: "https://shopify.com"}rich_text_field→ Complex JSON structurejson→ Any JSON-serializable value
Primitive Types
number_integer,number_decimal→ Parsed to numberboolean→ Parsed to boolean- All others → Kept as string (dates, URLs, text, references, etc.)
Performance Considerations
For ~300 products:
- Fetching all products with metafields: ~100-200ms
- Runtime filtering: <10ms
- Total: Fast enough for real-time filtering
If scaling to thousands:
- Consider adding database-level pre-filtering for common fields (vendor, status)
- Cache frequently used filter results
- Consider pagination or lazy loading
Summary
This approach provides:
- ✅ Simple schema - Single
valuecolumn - ✅ Handles all types - All 48+ Shopify metafield types
- ✅ Flexible filtering - Any combination of include/exclude
- ✅ Type-aware - Parses values based on
typefield - ✅ Works for quiz filtering - Perfect for progressive filtering (300 → 70 products)
Field Reference Tables
Product Fields (from Prisma schema)
| Field | Type | JavaScript Shape | Example |
|---|---|---|---|
id | String | product.id | "gid://shopify/Product/123" (Shopify GID) |
title | String | product.title | "T-Shirt" |
productType | String | product.productType | "Clothing" |
tags | String[] | product.tags | ["organic", "gluten-free"] |
handle | String | product.handle | "t-shirt" |
description | String? | product.description | "A comfortable t-shirt" |
status | String | product.status | "active" |
vendor | String | product.vendor | "Nike" |
totalInventory | Int | product.totalInventory | 100 |
variantsCount | Int | product.variantsCount | 3 |
hasOnlyDefaultVariant | Boolean | product.hasOnlyDefaultVariant | false |
trackInventory | Boolean | product.trackInventory | true |
created_at | DateTime | product.created_at | Date |
updated_at | DateTime | product.updated_at | Date |
Variant Fields (from Prisma schema)
| Field | Type | JavaScript Shape | Example |
|---|---|---|---|
id | String | variant.id | "gid://shopify/ProductVariant/456" (Shopify GID) |
title | String | variant.title | "Large / Blue" |
price | String | variant.price | "29.99" (parse to number) |
compareAtPrice | String? | variant.compareAtPrice | "39.99" |
sku | String? | variant.sku | "TSH-001-L-BLUE" |
inventoryQuantity | Int? | variant.inventoryQuantity | 50 |
availableForSale | Boolean | variant.availableForSale | true |
created_at | DateTime | variant.created_at | Date |
updated_at | DateTime | variant.updated_at | Date |
Metafield Types (from Shopify Docs)
Reference: Shopify Metafield Types
| Type | Stored As | JavaScript Parse | Example Value |
|---|---|---|---|
boolean | "true" or "false" | value === "true" | true |
color | "#fff123" | value (string) | "#fff123" |
date | "2022-02-02" | value (string) | "2022-02-02" |
date_time | "2024-01-01T12:30:00" | value (string) | "2024-01-01T12:30:00" |
dimension | JSON string | JSON.parse(value) | {value: 25.0, unit: "cm"} |
id | "1234" | value (string) | "1234" |
json | JSON string | JSON.parse(value) | {ingredient: "flour", amount: 0.3} |
link | JSON string | JSON.parse(value) | {text: "Learn more", url: "https://shopify.com"} |
money | JSON string | JSON.parse(value) | {amount: "5.99", currency_code: "CAD"} |
multi_line_text_field | "text\nmore text" | value (string) | "Ingredients\nFlour\nWater" |
number_decimal | "10.4" | parseFloat(value) | 10.4 |
number_integer | "10" | parseFloat(value) | 10 |
rating | JSON string | JSON.parse(value) | {value: "3.5", scale_min: "1.0", scale_max: "5.0"} |
rich_text_field | JSON string | JSON.parse(value) | Complex JSON structure |
single_line_text_field | "text" | value (string) | "T-Shirt" |
url | "https://shopify.com" | value (string) | "https://shopify.com" |
phone_number | "+1234567890" | value (string) | "+1234567890" |
email | "user@example.com" | value (string) | "user@example.com" |
| Reference types (all) | GID string | value (string) | "gid://shopify/Product/123" |
list.single_line_text_field | JSON string | JSON.parse(value) | ["item1", "item2"] |
list.number_integer | JSON string | JSON.parse(value) | ["10", "20", "30"] |
list.number_decimal | JSON string | JSON.parse(value) | ["10.4", "20.5"] |
list.date | JSON string | JSON.parse(value) | ["2022-02-02", "2023-03-03"] |
list.boolean | JSON string | JSON.parse(value) | ["true", "false"] |
list.color | JSON string | JSON.parse(value) | ["#fff123", "#000456"] |
list.url | JSON string | JSON.parse(value) | ["https://shopify.com", "https://shopify.dev"] |
list.link | JSON string | JSON.parse(value) | [{text: "Link 1", url: "https://example.com"}] |
list.rating | JSON string | JSON.parse(value) | [{value: "3.5", scale_min: "1.0", scale_max: "5.0"}] |
list.dimension | JSON string | JSON.parse(value) | [{value: 20.0, unit: "cm"}] |
list.weight | JSON string | JSON.parse(value) | [{value: 2.5, unit: "kg"}] |
list.volume | JSON string | JSON.parse(value) | [{value: 20.0, unit: "ml"}] |
| List reference types (all) | JSON string | JSON.parse(value) | ["gid://shopify/Product/1", "gid://shopify/Product/2"] |