Add a unified and predictable set of filters for JSON fields to the GraphQL API.
Filters should be:
- portable between adapters;
- safe from path injection;
- similar in form to Prisma JSON filters;
- simple enough to be implemented identically in PostgreSQL, MongoDB, MySQL, SQLite and other adapters;
- clear to developers regarding
null, empty object, empty array, and nullable JSON field.
- Any
JSONfield in the GraphQL API is always nullable. - A
JSONfield cannot be required. - If
nullis passed to aJSONfield, the entire field is set tonull. - The API does not distinguish between root-level JSON
nulland the field'snull. - A nested JSON
nullinside an object or array is a regular JSON value. {}and[]are regular JSON values and are not automatically removed.- No normalization of JSON values is performed.
pathis an array of safe tokens, not a JSONPath string.
Standard exact-match filters are generated for each JSON field:
{field}: JSON
{field}_not: JSON
{field}_in: [JSON!]
{field}_not_in: [JSON!]And one JSON-specific filter:
{field}_match: JsonMatchInputFor example, for the metadata field:
metadata: JSON
metadata_not: JSON
metadata_in: [JSON!]
metadata_not_in: [JSON!]
metadata_match: JsonMatchInputInput:
input JsonMatchInput {
path: [String!]
equals: JSON
not: JSON
in: [JSON!]
not_in: [JSON!]
exists: Boolean
number_lt: Float
number_lte: Float
number_gt: Float
number_gte: Float
string_contains: String
string_not_contains: String
string_starts_with: String
string_not_starts_with: String
string_ends_with: String
string_not_ends_with: String
array_contains: JSON
array_not_contains: JSON
}JsonMatchInput does not contain is_null.
To check for null, use equals: null or the field's standard exact-match filter.
A JSON field is always nullable.
This means the field has a state:
{
"metadata": null
}This state is called field null.
field null means there is no JSON document in the field.
On create, if no field value is passed and there is no default value, the field becomes null.
On update:
- if the field is not passed, the field value does not change;
- if the field is passed as
null, the entire field becomesnull; - if the field is passed as
{}, the entire field becomes an empty object; - if the field is passed as
[], the entire field becomes an empty array; - if the field is passed as an object/array/scalar, the value is saved as is.
Example:
mutation {
updateUser(id: "1", data: { metadata: null }) {
id
}
}Means:
metadata = null;And not:
metadata = JSON null value inside existing documentThe API has two different practical cases.
field null is the null of the entire JSON field.
The API does not expose a separate root-level JSON null state. If a database can distinguish database NULL from root JSON null, the adapter must hide that difference and treat both as API-level field null.
{
"metadata": null
}field null is searched as follows:
where: {
metadata: null
}or like this:
where: {
metadata_match: {
exists: false
}
}or like this:
where: {
metadata_match: {
equals: null
}
}All three options mean:
metadata === null;Nested JSON null is a null inside a JSON document.
{
"metadata": {
"profile": {
"middleName": null
}
}
}Nested JSON null is searched only via path:
where: {
metadata_match: {
path: ["profile", "middleName"]
equals: null
}
}For such a value:
metadata_match: {
path: ["profile", "middleName"]
exists: true
}also matches.
But:
metadata_match: {
path: ["profile", "middleName"]
exists: false
}does not match.
missing path means that the path cannot be resolved within the JSON document.
Examples of missing path:
metadata.profile.country; // key is missing
metadata.tags[10]; // index is missing
metadata.profile.age.x; // age is a number, cannot go furtherIf the field itself is null, any nested path is considered missing.
For such a value:
{
"metadata": null
}this filter matches:
metadata_match: {
path: ["profile", "country"]
exists: false
}and this one does not match:
metadata_match: {
path: ["profile", "country"]
exists: true
}{} and [] are regular JSON values.
They are not automatically removed and are not considered missing.
{
"metadata": {}
}For such a value:
metadata_match: {
exists: true
}matches.
metadata_match: {
exists: false
}does not match.
metadata_match: {
equals: {}
}matches.
Similarly for root array:
{
"metadata": []
}metadata_match: {
exists: true
}matches.
metadata_match: {
equals: []
}matches.
Standard filters {field}, {field}_not, {field}_in, {field}_not_in work with the entire value of the field.
where: {
metadata: null
}Means:
metadata === null;where: {
metadata: {
profile: {
country: "DE"
}
}
}Means:
deepEqual(metadata, { profile: { country: 'DE' } });where: {
metadata_not: null
}Means:
metadata !== null;where: {
metadata_not: {
profile: {
country: "DE"
}
}
}Means:
metadata === null || !deepEqual(metadata, { profile: { country: 'DE' } });where: {
metadata_in: [
{ profile: { country: "DE" } },
{ profile: { country: "FR" } }
]
}Means:
metadata !== null && values.some(value => deepEqual(metadata, value));{field}_in must be a non-empty array.
Due to the type [JSON!], the list cannot contain null.
If you need to find null or one of the JSON values, use OR:
OR: [
{ metadata: null },
{ metadata_in: [{ profile: { country: "DE" } }] }
]where: {
metadata_not_in: [
{ profile: { country: "DE" } },
{ profile: { country: "FR" } }
]
}Means:
metadata === null || values.every(value => !deepEqual(metadata, value));{field}_not_in must be a non-empty array.
Due to the type [JSON!], the list cannot contain null.
If you need to exclude null, use an additional condition:
AND: [
{ metadata_not: null },
{ metadata_not_in: [{ profile: { country: "DE" } }] }
]Exactly one operator must be passed in a single JsonMatchInput.
path is not considered an operator.
Operators:
- equals
- not
- in
- not_in
- exists
- number_lt
- number_lte
- number_gt
- number_gte
- string_contains
- string_not_contains
- string_starts_with
- string_not_starts_with
- string_ends_with
- string_not_ends_with
- array_contains
- array_not_containsValid:
metadata_match: {
path: ["profile", "country"]
equals: "DE"
}Invalid:
metadata_match: {
path: ["profile", "country"]
equals: "DE"
exists: true
}Invalid:
metadata_match: {
path: ["profile", "country"]
}Important: the check for "whether an operator is passed" should consider the presence of the key, not the truthy/falsy value.
These values are valid conditions:
equals: null
equals: false
equals: 0
equals: ""
equals: {}
equals: []
exists: false
array_contains: null
array_contains: {}
array_contains: []
array_not_contains: null
array_not_contains: {}
array_not_contains: []path points to a value inside a JSON field.
If path is not passed, the operator is applied to the entire JSON field.
metadata_match: {
equals: {
source: "crm"
}
}If path is passed, the operator is applied to the value at that path.
metadata_match: {
path: ["profile", "country"]
equals: "DE"
}path is an array of tokens, not a JSONPath, SQL expression, MongoDB expression, or a dot-separated string.
Correct:
['profile', 'country'];
['profile', 'age'];
['addresses', '0', 'city'];
['tags', '1'];Incorrect:
['profile.country'];
['$.profile.country'];
['profile', '*', 'country'];
['profile', '__proto__'];
['profile', 'constructor'];
['profile', '__typename'];A path segment can be an object key or an array index.
Object key:
const JSON_PATH_KEY_SEGMENT_REGEX =
/^(?!(?:__proto__|prototype|constructor|__typename)$)[_A-Za-z][_A-Za-z0-9]*$/;Array index:
const JSON_PATH_INDEX_SEGMENT_REGEX = /^(?:0|[1-9][0-9]{0,3})$/;General segment validator:
function isValidJsonPathSegment(segment: string) {
return JSON_PATH_KEY_SEGMENT_REGEX.test(segment) || JSON_PATH_INDEX_SEGMENT_REGEX.test(segment);
}Rules:
- an object key can contain only ASCII letters, digits, and
_; - an object key must start with a letter or
_; __proto__,prototype,constructor,__typenameare forbidden;- an array index is written as a string:
"0","1","12"; - negative indices are forbidden;
- wildcards are forbidden;
- recursive search is forbidden;
- JSONPath filters are forbidden;
- a numeric segment is considered an array index;
- object keys like
"0"are not supported as queryable path keys.
An allow-list of permitted paths can be specified for each JSON field.
metadata: {
type: Json,
enableMatchFilter: true,
allowedMatchFilterPaths: [
["profile", "country"],
["profile", "age"],
["profile", "email"],
["tags"],
["tags", "0"],
["addresses", "0", "city"],
],
}If path is passed, it must:
- be a non-empty array;
- consist only of valid path segments;
- exactly match one of the paths in
allowedMatchFilterPaths.
If a path is syntactically valid but is not in allowedMatchFilterPaths, the request should fail with a user input error.
JSON path "profile.secretToken" is not allowed for User.metadata
If path is not passed, the filter is applied to the entire JSON field and does not require an allow-list path.
Checks for the existence of a value.
Without path:
metadata_match: {
exists: true
}Means:
metadata !== null;metadata_match: {
exists: false
}Means:
metadata === null;With path:
metadata_match: {
path: ["profile", "country"]
exists: true
}Means:
metadata !== null && path existsmetadata_match: {
path: ["profile", "country"]
exists: false
}Means:
metadata === null || path is missingnull, {} and [] inside a JSON document are considered existing values.
Matches if the selected value exists and is deep-equal to the passed JSON value.
Without path:
metadata_match: {
equals: null
}Means:
metadata === null;metadata_match: {
equals: {}
}Means:
deepEqual(metadata, {});With path:
metadata_match: {
path: ["profile", "country"]
equals: "DE"
}Means:
metadata !== null && path exists && deepEqual(value, expected)Any JSON values are allowed:
equals: null
equals: {}
equals: []
equals: false
equals: 0
equals: ""Equality is type-sensitive:
10 !== "10"
false !== 0
null !== missing
[] !== {}Object key order does not affect equality.
Array order affects equality.
Matches if the selected value is missing or not equal to the passed JSON value.
Without path:
metadata_match: {
not: null
}Means:
metadata !== null;With path:
metadata_match: {
path: ["profile", "country"]
not: "DE"
}Means:
metadata === null || path is missing || !deepEqual(value, expected)If you need to apply not only to existing values, use AND with exists: true.
AND: [
{
metadata_match: {
path: ["profile", "country"]
not: "DE"
}
},
{
metadata_match: {
path: ["profile", "country"]
exists: true
}
}
]Matches if the selected value exists and is equal to one of the values in the list.
metadata_match: {
path: ["profile", "country"]
in: ["DE", "FR"]
}metadata !== null && path exists && values.some(item => deepEqual(value, item))If path is not passed:
metadata !== null && values.some(item => deepEqual(metadata, item));in must be a non-empty array.
Due to the type [JSON!], the in list cannot contain null.
If you need to check for null along with other values, use OR.
For field null:
OR: [
{ metadata: null },
{ metadata_match: { in: [{ source: "crm" }] } }
]For nested null:
OR: [
{
metadata_match: {
path: ["profile", "country"]
equals: null
}
},
{
metadata_match: {
path: ["profile", "country"]
in: ["DE", "FR"]
}
}
]Matches if the selected value is missing or not equal to any value in the list.
metadata_match: {
path: ["profile", "country"]
not_in: ["DE", "FR"]
}metadata === null || path is missing || values.every(item => !deepEqual(value, item))If path is not passed:
metadata === null || values.every(item => !deepEqual(metadata, item));not_in must be a non-empty array.
Due to the type [JSON!], the not_in list cannot contain null.
If you need to apply not_in only to existing values, use AND with exists: true.
AND: [
{
metadata_match: {
path: ["profile", "country"]
not_in: ["DE", "FR"]
}
},
{
metadata_match: {
path: ["profile", "country"]
exists: true
}
}
]Numeric operators are applied only to existing JSON number values.
metadata_match: {
path: ["profile", "age"]
number_gte: 18
}metadata !== null && path exists && typeof value === "number" && value >= expectedIf path is not passed:
metadata !== null && typeof metadata === 'number' && metadata >= expected;If the value is missing, null, string, boolean, object, or array, the condition does not match.
'18'; // not a number
null; // not a number
{
} // not a number
[]; // not a numberMatches if the selected value exists, is a string, and contains the substring.
metadata_match: {
path: ["profile", "email"]
string_contains: "example.com"
}metadata !== null && path exists && typeof value === "string" && value.includes(expected)If path is not passed:
metadata !== null && typeof metadata === 'string' && metadata.includes(expected);Matches if the selected value is missing, is not a string, or the string does not contain the substring.
metadata_match: {
path: ["profile", "email"]
string_not_contains: "spam"
}metadata === null || path is missing || typeof value !== "string" || !value.includes(expected)If path is not passed:
metadata === null || typeof metadata !== 'string' || !metadata.includes(expected);Matches if the selected value exists, is a string, and starts with the passed string.
metadata_match: {
path: ["profile", "email"]
string_starts_with: "admin"
}metadata !== null && path exists && typeof value === "string" && value.startsWith(expected)Matches if the selected value is missing, is not a string, or the string does not start with the passed string.
metadata_match: {
path: ["profile", "email"]
string_not_starts_with: "admin"
}metadata === null || path is missing || typeof value !== "string" || !value.startsWith(expected)Matches if the selected value exists, is a string, and ends with the passed string.
metadata_match: {
path: ["profile", "email"]
string_ends_with: ".com"
}metadata !== null && path exists && typeof value === "string" && value.endsWith(expected)Matches if the selected value is missing, is not a string, or the string does not end with the passed string.
metadata_match: {
path: ["profile", "email"]
string_not_ends_with: ".ru"
}metadata === null || path is missing || typeof value !== "string" || !value.endsWith(expected)String filters are case-sensitive.
'Alex'.includes('A') === true;
'Alex'.includes('a') === false;Case-insensitive mode is not supported in this version of the API.
Matches if the selected value exists, is an array, and the array contains an element deep-equal to the passed JSON value.
metadata_match: {
path: ["tags"]
array_contains: "beta"
}metadata !== null
&& path exists
&& Array.isArray(value)
&& value.some(item => deepEqual(item, expected))If path is not passed:
metadata !== null && Array.isArray(metadata) && metadata.some(item => deepEqual(item, expected));Any JSON values are allowed:
array_contains: null
array_contains: {}
array_contains: []
array_contains: false
array_contains: 0
array_contains: "beta"
array_contains: { code: "x" }An object value inside the array is compared via deep equality.
{ "code": "x" }matches the element:
{ "code": "x" }but does not match the element:
{ "code": "x", "extra": true }Matches if the selected value is missing, is not an array, or the array does not contain an element deep-equal to the passed JSON value.
metadata_match: {
path: ["tags"]
array_not_contains: "beta"
}metadata === null
|| path is missing
|| !Array.isArray(value)
|| value.every(item => !deepEqual(item, expected))If path is not passed:
metadata === null || !Array.isArray(metadata) || metadata.every(item => !deepEqual(item, expected));If you need to exclude missing path and field null, use AND with exists: true.
This does not require the selected value to be an array. Existing non-array values still match array_not_contains. This version of the API does not have a separate is_array type filter.
AND: [
{
metadata_match: {
path: ["tags"]
array_not_contains: "beta"
}
},
{
metadata_match: {
path: ["tags"]
exists: true
}
}
]Positive operators require an existing value of a suitable type when path is passed.
When path is not passed, the operator is applied to the whole JSON field. In that mode, whole-field semantics from each operator section apply.
For example, metadata_match: { equals: null } matches field null, and metadata_match: { not: null } excludes field null.
Positive operators:
- equals
- in
- exists: true
- number_lt
- number_lte
- number_gt
- number_gte
- string_contains
- string_starts_with
- string_ends_with
- array_containsWhen path is passed, negative operators include missing path, field null, and values of a different type (type mismatches).
When path is not passed, there is no nested path to resolve, so negative operators are evaluated against the whole field value. In that mode, negative operators also include field null and values of a different type.
Negative operators:
- not
- not_in
- exists: false
- string_not_contains
- string_not_starts_with
- string_not_ends_with
- array_not_containsExample:
metadata_match: {
path: ["profile", "country"]
not: "DE"
}means:
metadata === null || country is missing || country is not a string || country !== "DE"If missing path and field null need to be excluded:
AND: [
{
metadata_match: {
path: ["profile", "country"]
not: "DE"
}
},
{
metadata_match: {
path: ["profile", "country"]
exists: true
}
}
]Multiple conditions are combined using standard AND / OR in the parent WhereInput.
AND: [
{
metadata_match: {
path: ["profile", "country"]
equals: "DE"
}
},
{
metadata_match: {
path: ["profile", "age"]
number_gte: 18
}
}
]OR: [
{
metadata_match: {
path: ["profile", "country"]
equals: "DE"
}
},
{
metadata_match: {
path: ["profile", "country"]
equals: "FR"
}
}
]NOT is not included in this specification.
To negate individual JSON conditions, use explicit negative operators:
not
not_in
string_not_contains
string_not_starts_with
string_not_ends_with
array_not_contains
exists: falseA JSON field is always nullable.
If a developer attempts to declare a JSON field as required, the schema should fail with a configuration error.
JSON field "metadata" cannot be required. JSON fields are always nullable.
Exactly one operator must be passed.
path is not considered an operator.
You cannot pass multiple operators:
metadata_match: {
path: ["profile", "age"]
number_gte: 18
number_lt: 65
}You must use AND:
AND: [
{
metadata_match: {
path: ["profile", "age"]
number_gte: 18
}
},
{
metadata_match: {
path: ["profile", "age"]
number_lt: 65
}
}
]exists must be a boolean.
exists: true
exists: falsenumber_lt, number_lte, number_gt, number_gte must be numbers.
string_* must be strings.
in, not_in, {field}_in, {field}_not_in must be non-empty arrays.
The presence of a key counts as passing an operator even when the value is null. However, null is valid only for JSON-valued operators: equals, not, array_contains, and array_not_contains. For other operators, values such as exists: null, number_gte: null, string_contains: null, in: null, and not_in: null are invalid.
equals, not, array_contains, array_not_contains accept any JSON value, including:
- null
- {}
- []
- string
- number
- boolean
- object
- arrayAn empty path cannot be passed:
metadata_match: {
path: []
equals: "DE"
}A forbidden path cannot be passed:
metadata_match: {
path: ["profile", "secretToken"]
equals: "abc"
}An empty in cannot be passed:
metadata_match: {
path: ["profile", "country"]
in: []
}An empty not_in cannot be passed:
metadata_match: {
path: ["profile", "country"]
not_in: []
}An empty {field}_in cannot be passed:
where: {
metadata_in: []
}An empty {field}_not_in cannot be passed:
where: {
metadata_not_in: []
}JSON values are saved without normalization.
If the client writes:
{
"profile": {
"country": "DE",
"middleName": null
},
"settings": {},
"tags": []
}then these values should remain in the JSON.
The API should not automatically remove:
- nested null
- empty object
- empty arrayTherefore, filters must distinguish between:
field null
missing path
nested explicit null
empty object
empty arrayRoot-level null is an exception.
If the client writes:
metadata: nullthen this means field null.
The API does not provide a separate way to write a root-level JSON null distinct from field null.
Queries are equivalent:
metadata_match: { equals: null }
metadata_match: { exists: false }
metadata: null
And that:
metadata_match: { not: null }
metadata_match: { exists: true }
metadata_not: null
Adapters must receive an already validated path.
Adapters should not concatenate the user-provided path into a raw query.
Adapters must:
- check the
pathagainst a regex; - check the
pathagainstallowedMatchFilterPaths; - translate path tokens into the native database mechanism;
- pass filter values as parameters;
- preserve type-sensitive semantics;
- preserve the distinction between field null, missing path, and nested JSON null;
- preserve the distinction between scalar, object, and array;
- maintain identical behavior for negative operators (negative operator is equivalent to
NOT(positive), including type mismatches); - not create a root-level JSON
nulldistinct from field null.
NOTE: Some adapters (e.g. Prisma with postgresql) may have limitations in implementing negative operators exactly according to the NOT(positive) contract for all edge cases (like non-array values in array_not_contains). See implementation tests for details.
If the database or driver distinguishes between database NULL and root JSON null, the adapter must hide this difference at the GraphQL API level:
GraphQL metadata: null
means: API-level field null
may map to: database NULL
must not expose: separate root JSON null stateIf the database already contains legacy data with root-level JSON null, the adapter must treat it as API-level field null when reading and filtering.
If the adapter cannot correctly implement an operator, it should explicitly return a support error rather than changing the semantics.
This version of the API does not support:
- JSONPath strings
- wildcard paths
- recursive descent
- filtering object key values inside arrays by predicate
- array_starts_with
- array_ends_with
- case-insensitive string mode
- partial JSON update
- database-specific JSON expressions
- raw SQL / Mongo expressions
- separate root JSON null distinct from field null
- required JSON fieldsquery {
allUsers(where: { metadata: null }) {
id
}
}Equivalent:
query {
allUsers(where: { metadata_match: { exists: false } }) {
id
}
}query {
allUsers(where: { metadata_not: null }) {
id
}
}Equivalent:
query {
allUsers(where: { metadata_match: { exists: true } }) {
id
}
}query {
allUsers(where: { metadata_match: { path: ["profile", "country"], equals: "DE" } }) {
id
}
}query {
allUsers(where: { metadata_match: { path: ["profile", "country"], exists: false } }) {
id
}
}This also matches rows where metadata itself is null.
query {
allUsers(where: { metadata_match: { path: ["profile", "country"], equals: null } }) {
id
}
}This does not match rows where metadata is null or where profile.country is missing.
query {
allUsers(where: { metadata_match: { path: ["profile", "age"], number_gte: 18 } }) {
id
}
}query {
allUsers(where: { metadata_match: { path: ["tags"], array_contains: "beta" } }) {
id
}
}query {
allUsers(where: { metadata_match: { path: ["tags"], array_not_contains: "beta" } }) {
id
}
}This also matches users where metadata is null, tags is missing, or tags is not an array.
query {
allUsers(
where: {
AND: [
{ metadata_match: { path: ["tags"], exists: true } }
{ metadata_match: { path: ["tags"], array_not_contains: "beta" } }
]
}
) {
id
}
}query {
allUsers(
where: {
AND: [
{
OR: [
{ metadata_match: { path: ["profile", "country"], equals: "DE" } }
{ metadata_match: { path: ["profile", "country"], equals: "FR" } }
]
}
{ metadata_match: { path: ["profile", "age"], number_gte: 18 } }
{ metadata_match: { path: ["profile", "email"], string_not_contains: "spam" } }
{ metadata_match: { path: ["tags"], array_contains: "beta" } }
]
}
) {
id
}
}Final contract:
JsonField:
nullable: true
required: false
field_null:
graphql_input: metadata: null
meaning: whole JSON field is null
separate_root_json_null: not supported
JsonMatchInput:
path:
omitted: apply operator to whole JSON field
provided: apply operator to selected JSON path
format: array of safe path segments
validation: regex + allowedMatchFilterPaths
normalization:
enabled: false
field_null:
means: whole JSON field is null
metadata_match_exists_false: true
metadata_match_equals_null_without_path: true
nested_paths: missing
nested_null:
null_is_value: true
null_is_not_missing: true
filter_with: path + equals null
empty_object:
value_is_preserved: true
exists: true
can_be_filtered_with_equals: true
empty_array:
value_is_preserved: true
exists: true
can_be_filtered_with_equals: true
positive_operators:
with_path:
missing_path_matches: false
field_null_matches: false
without_path:
applies_to_whole_field: true
see_operator_specific_semantics: true
negative_operators:
with_path:
missing_path_matches: true
field_null_matches: true
without_path:
applies_to_whole_field: true
see_operator_specific_semantics: true
array_contains:
requires_existing_array: true
comparison: deep equality against array element
array_not_contains:
missing_path_matches: true
field_null_matches: true
non_array_matches: true
comparison: no deep-equal array element