{"title":"Feed query language","slug":"feed-query-language","url":"https://support.storeconnect.com/articles/feed-query-language","url_markdown":"https://support.storeconnect.com/articles/feed-query-language.md","subtitle":null,"summary":"The clauses that decide which records a feed contains: joins, filters, ordering, and an optional projection with aggregates. Covers case handling, to-one against to-many joins, and the bindings that make a query per-store or incremental.","type":"Developer_Documentation","video_url":"","keywords":"feed query, query dsl, where clause, from clause, join, order clause, select clause, aggregates, string_agg, json_agg, parameter binding, last_built_at, delta feed, projection, case sensitivity, ilike, api name","last_modified":"2026-10-01T03:35:10+0000","body_markdown":"A feed's records come from a query: a base object plus four optional clauses. The clauses use SQL syntax but are not SQL. They are parsed, checked against the fields that actually exist, and rebuilt before they reach the database, so nothing written here is passed through as a SQL string.\n\n| Part | Field on **Query** | Purpose |\n|---|---|---|\n| Object | `s_c__Object_API_Name__c` | The base object to select from, as a Salesforce API name such as `product2` or `s_c__article__c` |\n| `from` | `s_c__From__c` | Declares related objects to join, each with an alias and a match condition |\n| `where` | `s_c__Where__c` | Filters which records are returned |\n| `order` | `s_c__Order__c` | Orders them |\n| `select` | `s_c__Select__c` | Optional. Projects a specific set of columns instead of whole records |\n\nA query with only an object returns every record of that object.\n\nEach part is a separate field on the **Query** record (`s_c__Query__c`), not one statement typed in one place. A field holds its clause body without the leading keyword, so **Where** holds `s_c__active__c = true`, not `WHERE s_c__active__c = true`. **From** is the exception: it holds the join clauses themselves, each beginning with `JOIN`, `INNER JOIN` or `LEFT JOIN`. Every example below is the contents of one field.\n\n## Naming fields\n\nRefer to a field by its Salesforce API name in full, including prefixes and suffixes: `s_c__slug__c`, `name`, `createddate`. Shortened or Ruby-style names are not accepted.\n\nAn unqualified name is a field on the base object. A qualified name such as `brand.name` is a field on a join, where `brand` is an alias declared in `from`. Keywords like `AND` and `JOIN` are case-insensitive.\n\nAn unknown object, unknown field or undeclared alias is rejected loudly. A query is never quietly reduced to an empty result because of a typo.\n\n## `where`\n\n| Operator | Meaning |\n|---|---|\n| `=` `!=` `\u003e` `\u003e=` `\u003c` `\u003c=` | Comparisons |\n| `BETWEEN x AND y` | Inclusive range |\n| `IN (a, b)` / `NOT IN (a, b)` | Membership |\n| `LIKE 'pattern'` / `NOT LIKE 'pattern'` | Pattern match, case-sensitive |\n| `ILIKE 'pattern'` / `NOT ILIKE 'pattern'` | Pattern match, case-insensitive |\n| `IS NULL` / `IS NOT NULL` | Null checks |\n\nCombine with `AND`, `OR`, `NOT` and parentheses. Values are single-quoted strings (doubling a quote to escape it), numbers, `true` or `false`, and `NULL`.\n\n```sql\n\ns_c__is_master__c = true AND s_c__active__c = true\n\ns_c__price__c BETWEEN 10 AND 50 AND brand.name LIKE 'Ac%'\n\ntype = 'physical' AND (s_c__category__c IN ('sale', 'clearance') OR s_c__featured__c = true)\n```\n\n:::warning\nA negated comparison does not match null. `!=`, `NOT IN` and `NOT LIKE` leave out rows where the field is empty, which is rarely what the author intended. Add `IS NULL` explicitly when those rows should be included.\n:::\n\n### Matching regardless of case\n\nEvery comparison is case-sensitive by default, so `name = 'Red'` does not match `red`.\n\nFor pattern matching, use `ILIKE` instead of `LIKE`. For everything else, wrap both operands in `LOWER()` or `UPPER()`:\n\n```sql\n\nLOWER(s_c__brand__c) = LOWER('Acme')\n\nLOWER(s_c__category__c) IN (LOWER('Sale'), LOWER('Clearance'))\n```\n\nFolding one side only leaves the other side's case intact and still fails to match, which is the usual mistake. `LOWER` and `UPPER` need a text column and are rejected on a number, date or boolean.\n\n## `from`\n\n```sql\n\n[INNER | LEFT] JOIN \u003cobject\u003e AS \u003calias\u003e ON \u003ccol\u003e = \u003ccol\u003e [AND \u003ccol\u003e = \u003ccol\u003e]\n```\n\nJoins default to `INNER`. Use `LEFT` to keep base rows with no match, such as a product with no brand. The `ON` is a match key only, written out in full, and joins on equality; it is not a second filter. An unqualified column in the `ON` is on the base object.\n\nSeveral joins are allowed, and a later one may reference an earlier alias to form a chain, which is how a junction object is traversed:\n\n```sql\n\nJOIN account AS brand ON s_c__brand_id__c = brand.sfid\n\nJOIN s_c__products_product_categories__c AS pc ON sfid = pc.s_c__product_id__c\nJOIN s_c__product_category__c AS cat ON pc.s_c__category_id__c = cat.sfid\n```\n\nAliases must be unique and must not share a name with a base-object field.\n\n### To-one and to-many joins behave differently\n\nWhich one a join is comes from the `ON`: the side whose matched column is that table's own `sfid` is the \"one\" side.\n\n- **To-one** (`base.\u003cforeign key\u003e = alias.sfid`) matches at most one related row, and can be used in both `where` and `order`.\n- **To-many** (`base.sfid = alias.\u003cforeign key\u003e`) may match many. It can filter in `where`, compiled as an existence check so base rows are never duplicated, but ordering by it is rejected, because ordering by a one-to-many relationship has no defined answer.\n\n## `order`\n\n```sql\n\n\u003cfield\u003e [ASC | DESC] [NULLS FIRST | NULLS LAST] [, ...]\n```\n\nOrder by a base field or a to-one join field. Ordering by a to-many join field is rejected. To sort regardless of case, wrap the field in `LOWER()`.\n\n## Bindings\n\nA clause can read a live value with a `:path` binding, evaluated against the same data a feed template sees. This is what lets one query serve every store:\n\n```sql\n\ns_c__store_id__c = :current_store.id\n```\n\nA binding may only appear where a value goes: the right side of a comparison, inside `IN`, or as a `BETWEEN` bound. It can never stand in for a field, an alias, a `LIKE` pattern or an ordering term. The value is bound as a parameter rather than pasted into the statement, so a binding cannot be an injection route.\n\nA binding is a plain path. No filters, no expressions. It must resolve to a single value; one that resolves to an object, to nothing, or to a list fails loudly, as does a request-specific value used by a pre-built feed, which renders with no request.\n\n### Two reserved bindings\n\n| Binding | Resolves to |\n|---|---|\n| `:last_built_at` | When this store's feed was last pre-built. A feed never built binds the epoch, so its first build is a full one |\n| `:now` | The time the feed renders |\n\nThese make an incremental feed possible:\n\n```sql\n\nsystemmodstamp \u003e :last_built_at\n```\n\nEach build records its own start time, so a record changed while a build was running is picked up by the next one. On a live request `:last_built_at` is the epoch, which makes the feed a full one.\n\n## `select`\n\nWithout a `select`, a feed returns whole records. Adding one switches to projection: the feed's records become exactly the columns listed, each addressed in the template by its alias.\n\n```sql\n\nsfid AS id,\nname AS item_name,\nbrand.s_c__display_name__c AS brand,\nSTRING_AGG(cat.sfid, '|') AS group_ids,\nMIN(pbe.unitprice) AS price\n```\n\nThe template shape does not change; only which fields exist:\n\n\n```liquid\n\n{% for record in record_set.records %}\n  {{ record.id }} - {{ record.item_name }} ({{ record.group_ids }})\n{% endfor %}\n```\n\n\nYou can project a base column, a to-one joined column, or an aggregate over a to-many join. A bare to-many column outside an aggregate is not projectable.\n\n### Aggregates\n\nAn aggregate reduces a to-many collection to one value per base record, such as joining every category id a product belongs to.\n\n`COUNT`, `SUM`, `MIN`, `MAX`, `AVG`, `STRING_AGG` and `JSON_AGG` are available, and `DISTINCT` is allowed inside any of them.\n\n- `STRING_AGG` requires a delimiter, and the others must not be given one.\n- `COUNT(*)` is not supported. Name the to-many alias, as in `COUNT(cat.sfid)`, so it is unambiguous which collection is being counted.\n- Aggregating a base or to-one column is rejected, because there is no collection to aggregate.\n- Aggregates over different collections compose without inflating each other's counts.\n- `JSON_AGG` projects a real array. In a JSON feed, printing it directly encodes it as a JSON array, and a record with no matches yields `[]` rather than nothing.\n\n### Aliases\n\nA bare base field takes its own name as its alias. Anything else, a qualified column or any aggregate, requires an explicit `AS`, because a column with no alias cannot be addressed in the template. Aliases must be unique, and are lower-cased, so reference them in lower case whatever you wrote.\n\n:::warning\nA to-one join used only in `select` and never in `where` is still an `INNER` join, so base rows whose foreign key is empty are dropped from the feed. Declare it as `LEFT JOIN` to keep them, and the projected column comes through empty.\n:::"}