Findings — filter: selecting the rows you want
How the API behaves in this part of a session. Each rule is the entry's own Teaches, copied whole.
016_dotted_multi_entity
A dotted path through a multi_entity field reads back nothing: HTTP 200 with the key silently absent from attributes. Filters on that same path work, including two hops.
Trap. The read failure is silent and indistinguishable from "no data": the key is not in
attributes, the same quiet drop a bogus?fieldsname gets (probe 004). Single-entity paths likesg_sequence.Sequence.codecome back fine, so the difference is the field'sdata_type, not the syntax.The filters are evaluated, not ignored: every negative control returns 0, and two hops (
assets.Asset.sg_asset_type) resolve. On the probed site the positives return partial counts, 284 of 300.Filter through multi-entity freely. To read those values, query the child entity separately (
/entity/tasksfiltered by the parent) rather than asking for them inline.Name the target type and a field that type has. A path is validated against the named type, not against the link's
valid_types, sonote_links.Booking.codeandnote_links.Group.nameboth 400doesn't existwhile both types are legal targets.cached_display_nameis the one name field every type answers to, and it is what a "notes about X" search should use (probe 071).Corrects probe 005:
page[size]is not capped at 100. On the probed site 150 returned 150 rows and 500 returned all 300.
corpus/findings/016_dotted_multi_entity.md
017_filter_operators
is/is_not/contains/not_contains/starts_with/ends_with/in/not_in all work, on text fields and through dotted paths; an unknown operator 400s on all 21 data types, naming the valid list on 16.
An unknown operator 400s on every data type: 21 of 21 reachable read-only.
sourcenames the field's whole legal vocabulary on 16 of them. The other five,calculated,password,serializable,summaryandurl, answerdata type cannot be used in a filter.and enumerate nothing, because they take no operator at all.A bogus
?fieldsname is the opposite, dropped at HTTP 200 (probe 004), so a filter typo can never masquerade as "no filter".A write can be accepted at 200 and silently discarded.
cached_display_nametakes a write and drops it (field_types/text.md),Task.splitsstoresnullfor any well-formed payload (field_types/serializable.md), and themulti_entityupdate modes spelled in the query string return 200 and replace the whole list (field_types/multi_entity.md).An invalid operator, by contrast, 400s on all 21 data types tried.
Every negative control returns 0 rather than the baseline, so these operators are applied, not ignored.
intakes a plain list for scalars, but on an entity field it needs full{type, id}hashes:[{id: N}]400s withinvalid/missing entity hash string 'type'and bare ints 400 withexpected [Hash, ...] but got Integer.containsthrough a dotted path (entity.Shot.code) makes server-side type-ahead over names one call, with no client-side scan.A
listfield takes four relations,is,is_not,in,not_in, and refuses the text four:["sg_note_type", "contains", "Client"]400s withAPI read() Note.sg_note_type's 'list' data type doesn't support 'contains' 'relation'(probe 068). Filter a dropdown within.The
Valid relationslist is what the type accepts, not what it evaluates.Note.read_by_current_usernames all four, and onlyisandis_notfilter:in,not_inand anisvalue outside the vocabulary each answer 200 with the caller's unread rows (probe 068). A negative control is the only way to tell.Through a
multi_entitylink, the path has to name the target type and a field that type has. OnNote.note_links,cached_display_nameresolves on all 28 types the probed site allows,codeon 27 andnameon 1 (probe 071, and probe 016 for the read half).
corpus/findings/017_filter_operators.md
020_summarize
_summarize needs the same vendor Content-Type as _search, and one grouping call returns a field's distinct-value count and its empty count. At ~300ms a field, rank a shortlist, never scan.
The
_summarizeendpoint aggregates rows named at call time; thesummarydata type is a per-row rollup defined in the schema (field_types/summary). Same word, different mechanism.groupingby a field returns one group per distinct value, with empties under a''group, so one call gives both cardinality and the empty count. A fill-rate scan gives neither: it callscodeandflaggedboth 100% (probe 007), whilegroupingreturns one group per row forcode(an identifier) and exactly one group forflagged(no information).No cap showed below 1009 groups: on the probed site an unfiltered
Version.codegrouping returns 1009 groups over 1057 rows in 425ms, and one project's 300 distinct Shot codes return 300 groups.At ~300ms a call, and up to 1.5s on an entity field, scanning every Version field costs many multiples of one paged fetch of 100 rows (306ms on the probed site). Fetch one page for the broad fill-rate pass, then
_summarizeonly the shortlist to rank it by cardinality.A checkbox cannot be filtered
is_not None: 400API summarize() Version.flagged expected [String, FalseClass, TrueClass] data type(s) but got NilClass: nil. Take fill rate on a checkbox fromgrouping, not from a filter.application/jsonis 415Unsupported Content-Type 'application/json'; send the same vendor Content-Type as_search(probe 004).A bogus
type400sRequest Parameters invalid.andsource.summary_fieldsnames the whole set, indexed by position in the list:type must be one of: record_count, count, sum, maximum, minimum, average, earliest, latest, percentage, status_percentage, status_percentage_as_float, status_list, checked, unchecked. Ask the endpoint rather than guessing (probe 017).
One summary type per field per call. summaries is an object keyed by field name, so a second entry
for the same field overwrites the first at 200, with nothing in the response to say so. The last entry wins.
On the probed site, over 100 versions:
summary_fields |
summaries |
|---|---|
id count |
{"id": 100} |
id maximum |
{"id": 25568} |
id count + id maximum |
{"id": 25568} |
id maximum + id count |
{"id": 100} |
id count + id count |
{"id": 100} |
id count + id minimum + id maximum |
{"id": 25568} |
id count + frame_count sum |
{"id": 100, "frame_count": 0} |
Two types over one field costs two calls. Two different fields in one call return both.
A group's label and its value are not interchangeable. group_value is what the grouping was
computed on; group_name is the server's render of it for display:
| grouping field | group_name |
group_value |
|---|---|---|
user, an entity field |
"<user>" |
{"type": "HumanUser", "id": 385, "name": "<user>", "valid": "valid"} |
sg_task, an entity field, no value set |
"" |
null |
sg_status_list, a list field |
"na" |
"na" |
workload, a calculated field |
"1.25 days" |
"600.000000" (field_types/calculated) |
Key on group_value, display group_name. On an entity grouping group_value is the full reference
object, so group_value.id identifies the group and survives a rename; group_name is the display name
and is not unique. On the probed site a user grouping over 100 versions returned 7 groups under 6
distinct group_name, two HumanUsers of different id sharing one display name. A client keyed on
group_name merges those two people into one row.
corpus/findings/020_summarize.md
030_complex_filters
api3_hash nests and/or groups 265 deep and mixes leaves with sub-groups; api3_array cannot express or, query-string filter[] is ignored on _search, and {path,relation,values} runs nowhere.
Boolean logic needs api3_hash. The two vendor Content-Types are not interchangeable, and a client
that wants anything but a conjunction needs both, or api3_hash throughout. api3_hash also expresses
a plain and, so one Content-Type covers every case.
| Content-Type | filters accepted |
boolean logic |
|---|---|---|
api3_array |
[[field, op, value]], and only that |
and of the list, implicit; no other spelling |
api3_hash |
{"logical_operator", "conditions"}, conditions being triples or further groups |
and and or, nested |
Four spellings of a boolean under api3_array all 400: a nested list, a group object inside the list,
an operator string as the first element, and a fourth element on a triple. ["id","is",862,"or"] is
400 Invalid condition, so the shape is fixed at three elements.
Top-level
orworks. It is the wholefiltersvalue, not something that has to sit inside anand.orover two conditions disjoint onidreturns both rows andandover the same two returns 0, so the operator is applied rather than defaulted.andandorare the whole vocabulary, lowercase.AND,OR,Or,not,xor,nandand""are each400 Invalid logical operator: <what you sent>. There is no negation operator; use the negative relations (is_not,not_contains,not_in) from probe 017 on the leaf.Both keys are required and neither is inferred: a group without
logical_operatoris400 Missing logical operator, one withoutconditionsis400 Missing conditions parameter."conditions": []is 200 and matches every row, so an empty group is not a no-match, it is no filter.Depth stops at 265 groups, loudly. 266 is
500 {"title": "Shotgun Server Error", "source": null}, repeatably, with nosourceto read. Nothing is silently truncated below that: the control, anandof two disjoint conditions at the innermost level, returns 0 at every depth from 1 to 265, where a dropped inner group would return the full page.The limit is depth, not payload: one flat
orgroup of 5000 conditions at 90090 bytes is 200.A query-string
filter[]onPOST _searchis read by nothing.?filter[id]=863alongside a body filter selecting row 862 returns row 862;?filter[project.Project.id]=<p1>alongside a body filter selecting a row in another project returns that other row.A misspelled
?filter[zzz_not_a_field]=1is 200, where the same name in a body filter is 400 (probe 004). The same parameter on theGETlisting endpoint filters correctly, so the two endpoints take their filters in different places and a client moving fromGETto_searchmust move the filter into the body.The
{path, relation, values}object runs nowhere over REST. It is rejected as a condition inside a group, as the wholefiltersvalue, under both Content-Types, and on_summarizeas well as_search.It is the web interface's storage format only (probe 023) and the rollup definition format (
field_types/summary), so converting a saved page's filters into a query is a translation, never a pass-through: rewrite each leaf{"path": p, "relation": r, "values": v}as a triple and keep thelogical_operatorgroups as they stand.The group's own extra keys are tolerated:
filter_name,filter_idand an unknown key alongside triple conditions are all 200. The leaf's are not, so drop them rather than appending;activeandtop_level_project_filterboth appear (recipes/003).v[0]is right only where the relation takes a scalar.in_lastandin_nexttake the whole list, and passing the first element alone is 400expects a 2-element array: [4](recipes/003), so the translator branches on the relation rather than flattening every leaf the same way.One group holds mixed operators and mixes leaf conditions with sub-groups as siblings, which is what the stored page trees do.
and [["project","is",<p1>], {"or": [id is 862, id is 863]}]returns 2 and the same with an innerandreturns 0.Dotted paths work inside a nested group, and an error inside one is reported as at the top level: a bogus operator 400s with the field's
Valid relationslist, a bogus field withAPI read() Shot.sg_not_a_field doesn't exist.
corpus/findings/030_complex_filters.md
046_search_without_a_path
/hierarchy/_expand and /hierarchy/_search refuse the vendor content types every other POST requires and take application/json alone, so one shared POST helper 415s on half the API.
The vendor content type is not the API's rule, it is the endpoint's. Half the POST endpoints demand it and
/hierarchy/*refuses it. A client with one POST helper meets 415 on whichever half it was not written against. Both 415s name their legal set, so the error is enough to fix it.search_criteriamust be a hash keyed exactlyentity.size must be 1is counting recognised keys, so any other single key reads as a size problem and the real cause is never named. Two other shapes and a list were tried; only{"entity": {"type", "id"}}answers 200._text_searchreturns a flattened row with nofieldsparameter:name,status, and alinkspair of bare strings that cannot be followed. Re-read bylinks.selffor anything else.entity_typesmaps a schema name to that type's own filter array, so one call can be scoped differently per type. Nothing else in the API keys a filter by the type it applies to._expandreturns one level.childrennames the next paths andhas_childrensays which are worth a call, so walking a project costs one call per node._searchanswersincremental_path, the breadcrumb to the row, and it runs through a field name (sg_sequence), so the tree follows the site's navigation configuration rather than a fixed shape.
corpus/findings/046_search_without_a_path.md
053_text_search_matching
page.size caps at 25 and defaults to 25 with no links, so page with page.number. Every word must match a case-insensitive substring of the name or of the linked row's name. [partial]
not measured: Only description and a linked row's name were tried as fields beyond the name, and the tie-break between equal-length names was not reached. The probe's limit, not the site's.
sent as page |
answered |
|---|---|
absent, {}, {"number": 2} |
200, 25 rows |
{"size": 25}, {"size": "25"} |
200, 25 rows |
{"size": 26} and up |
400 code 103, {"page": {"size": ["size must be less than 25"]}} |
{"size": 0} and below |
400 code 103, {"page": {"size": ["size must be greater than 0"]}} |
{"number": 0} |
400 code 103, {"page": {"number": ["number must be greater than 0"]}} |
25 is the cap and the default, and the message is off by one:
size must be less than 25is what{"size": 26}answers, while{"size": 25}answers 200 with 25 rows.page.sizeis the only reason a caller sendspageat all here, and neither bound is the 500 the spec documents.The response has no
linkskey, so nothing in it says whether there is more. Page withpage.number, which starts at 1 and answers disjoint ids, and stop whendatais empty. This is the opposite of the paginated reads, wherelinks.nextis emitted forever (probe 006).sortis accepted and ignored. Four values, one of them a field that does not exist, all answered 200 with the rows in the order nosortgives.
| text | matched |
|---|---|
qat, QAT, Qat, qAt |
the same rows: case is ignored |
020, h01, at |
anywhere inside the name, not a prefix |
_qat_, 053_qat, qat_0020 |
underscore is part of the string, not a word separator |
qat 0020, 0020 qat |
the rows holding both, whichever order they are sent in |
qat kif, qat nomatch |
nothing: every word has to match |
q |
every row whose name holds a q |
a word only in description |
nothing |
qat on a Version whose entity is a Shot named ..._qat_0020 |
that Version |
Every word must match, each as a case-insensitive substring, so a one-letter text is a legal query returning whatever fits in 25 rows. Send the longest distinguishing fragment, not a word.
A row matches on the name of the row it links to. The pair under
attributes.linksis part of what is searched, so a Version whosecodeholds none of the text is returned because itsentityis a Shot whose name does. Readattributes.linksbefore deciding a row is a false positive.descriptionis not searched, on Shot or on Asset, and neither is any other field measured here. A search over descriptions or note bodies isPOST /entity/<type>/_searchwithcontains.Rows come back shortest name first, across types, and the order does not change with the order the
entity_typeskeys are given in. Three Shots created longest-name-first came back shortest first, so it is neither id nor creation order. A short generic name outranks a longer exact one, and with the cap at 25 the row a caller wants can be off the page: narrow with the per-type filter.On the probed site, one call over 14 types took 331-369 ms and the same words asked as one
contains_searchper type in sequence took 3811-3859 ms over four runs, about 270 ms a call. One call is worth it for a picker; a client that needs the full row still re-reads bylinks.self.
corpus/findings/053_text_search_matching.md
063_text_search_filter_shape
An entity_types value follows the request Content-Type: an array of triples under api3_array, a logical_operator group under api3_hash, which alone nests. The other shape is 400 code 103.
Content-Type |
the value of an entity_types key |
no filter |
|---|---|---|
application/vnd+shotgun.api3_array+json |
[[field, op, value]] |
[] |
application/vnd+shotgun.api3_hash+json |
{"logical_operator": "and"|"or", "conditions": [...]} |
{"logical_operator": "and", "conditions": []} |
The per-type filter is parsed by whatever the request's vendor content type selects, the same split
filtersonPOST /entity/<type>/_searchis under (probe 004). A client that sends[]for "search everything" gets 400 code 103Query is not an Hash: []the moment it switches toapi3_hash, and there is no shape both content types accept.Under
api3_hash, no filter is a group with an emptyconditions.A
conditionsentry may itself be a group, soorand three levels of nesting both filter and both answer the rows their branches answer:orover two codes returns those two rows and nothing else.The array form has no
logical_operatorand takes basic condition arrays alone: a group as one element is 400Invalid filter. Expected array of basic condition arrays but received:, and two triples in one array answer the rows theandof the same two answers.The shape is checked per key, so a map may not mix the two forms: the key whose value is the other shape decides the 400 and no rows come back for any type.
| the filter names | answer |
|---|---|
| a field the type lacks, at any depth | 400 API _text_search() Shot.content doesn't exist. |
| an operator the data type lacks | 400 naming every Valid relations for that type |
| a key no entity type is named by | 400 entity_types must use valid entity names as keys |
{}, null or a string |
400 entity_types must have an array or non-empty object as each key's value |
One bad key fails the whole call, so a picker over ten types that names one field wrong returns nothing rather than the nine types it got right. The error names the type and the field.
textis unchanged by a filter being present: every word still has to match a case-insensitive substring of the name (probe 053), and the filter narrows what those words are matched against. A text matching nothing and a filter matching nothing both answer 200 with an emptydata.
corpus/findings/063_text_search_filter_shape.md
068_note_read_state
read_by_current_user is per person and missing from the schema; is and is_not are evaluated, while in, not_in and an unknown is value all return the unread rows at 200.
A field the schema does not have. GET /schema/Note/fields returns 33 fields and none of them is
this one, yet every Note read and every create response returns it, ?fields selects it, a filter
resolves it and a PUT is validated against 'read', 'unread'. GET /schema/Note/fields/<field>
separates the two cases: an undeclared field answers 200 with data: null, a name that is nothing at
all answers 404 Field 'Note.<name>' does not exist. Ask that call, not the field census, before
concluding a field is absent.
It is per person, and an ApiUser has no state to write. The value is whatever the authenticated
identity last set, so the same Note reads read for one caller and unread for another in the same
second. A script's own PUT answers 200 and stores nothing, which is the same silent-write shape as
cached_display_name (probe 028). Mark a note read under sudo_as_login as the person it belongs to,
then re-read as that person to confirm.
Only two of the four relations it advertises are evaluated.
| operator | answers | evaluated |
|---|---|---|
is "read" / is "unread" |
the rows in that state for the caller | yes |
is_not "read" / is_not "unread" |
the rows in the other state | yes |
is "<not read or unread>" |
the caller's unread rows, not 0 | no |
in [...], any members |
the caller's unread rows | no |
not_in [...], any members |
the caller's unread rows | no |
in and not_in are named in the field's own Valid relations list, take a 200 from _search and
from _summarize, and answer identically under both api3_array and api3_hash. They return the
unread set whatever the list holds, so in ["read"] reads as "no notes are read" on a caller who has
read half of them. On a script user, for whom every row is unread, every broken case returns the whole
baseline, which is how this looks like "the filter was dropped".
An out-of-vocabulary is value behaves the same way. That differs from an ordinary list field, where
a value outside valid_values matches 0 rows (field_types/list), so the usual "a filter typo reads as
no rows match" does not hold here: it reads as every unread row.
Filter unread with ["read_by_current_user", "is", "unread"] and read with is "read". Never with
in.
corpus/findings/068_note_read_state.md
071_note_link_name_filter
Filter notes about a thing on note_links.<Type>.cached_display_name: it resolves for every valid type, code 400s on Booking and name on all but Department. The path cannot be read back.
The type in the path picks the name field, and the types disagree. Shot, Asset, Sequence
and Version are named by code, Department and Group by name, a Booking by neither.
cached_display_name is on every type and resolved for all 28, and on a Shot it matched the same
20 rows as code. Use it unless the query needs a field only that type has.
A wrong type and a wrong field fail the same way. note_links.ZzNotAType.code and
note_links.Shot.zz_not_a_field both answer 400 doesn't exist., so the error does not say which
segment is wrong. Check the type against valid_types first.
One filter per type. A path names one type, and note_links spans 28. "Notes about anything
called sh010" is one _search per type the client cares about, or one ["note_links", "in", [{"type": "Shot", "id": N}, ...]] after resolving the ids. There is no bare text relation on the
field: note_links contains is 400.
Read the links back through the field, not the path. ?fields=note_links.Shot.code returns 200
with the key absent (probe 016). Ask for note_links and take relationships.note_links.data[].name.
corpus/findings/071_note_link_name_filter.md