Your brain wasn't meant to juggle 47 database constraints at once. Here's how Answer Set Programming turns Firestore's query rules into executable logic.
Rather than start with the usual Knight's Tour problem found in every Answer Set Programming (ASP) tutorial or textbook which leaves you thinking "Neat! It's a fun toy!", we're going to just Start. Solving. Problems.I use Firestore (Google's NoSQL document database) extensively in my work, and one of its most challenging aspects is juggling all the constraints in your head while developing features. Query constraints, security rules, data validation — they all interact in complex ways that are difficult to reason about simultaneously.This isn't a Firestore tutorial. Instead, we'll use Firestore's query constraints as a real-world example of taking documentation and transforming it into a formal model. The goal is to demonstrate how ASP can help manage cognitive load by making constraints explicit and checkable.
The Hard Part: Domain Modeling
Let's acknowledge something up front: the hardest part of constraint modeling isn't the ASP syntax — it's figuring out how to represent your domain. What are the key concepts? How do they relate? What constraints actually matter?There are various formal approaches to domain modeling, but sometimes the best way to start is to just … start. Look at your documentation. Find the nouns (things) and verbs (relationships). Write down some facts. Add constraints as you discover them. Refactor when patterns emerge.For Firestore queries, we'll walk through this process step by step, starting with the most basic concepts and building up. Don't worry about getting it perfect — focus on capturing one constraint at a time and let the model evolve naturally.
Examples
When starting domain modeling, it's helpful to look at both documentation and concrete examples. For Firestore queries, we can examine the TypeScript / JavaScript SDK to see how queries are actually constructed:
These examples make the core concepts more concrete. We can see that queries use field paths ("state", "population"), operators ("==", "<"), and values ("CA", 100000). That seems like a good place to start.Note: Throughout this document, we assume the existence of a parser that converts Firestore TypeScript/JavaScript queries into ASP facts. This parser handles details like converting array values in in clauses into simple value counts that capture the number of disjunctions they represent.
operator("<", "Less than") .
operator("<=", "Less than or equal to") .
operator("==", "Equal to") .
operator(">", "Greater than") .
operator(">=", "Greater than or equal to") .
operator("!=", "Not equal to") .
operator("array-contains", "Array contains") .
operator("array-contains-any", "Array contains any") .
operator("in", "Within array") .
operator("not-in", "Not in array") .
{
operator/2
"!="
"Not equal to"
"<"
"Less than"
"<="
"Less than or equal to"
"=="
"Equal to"
">"
"Greater than"
">="
"Greater than or equal to"
"array-contains-any"
"Array contains any"
"array-contains"
"Array contains"
"in"
"Within array"
"not-in"
"Not in array"
}
ASP: At first glance — especially if you've written in languages like JavaScript or Python — you might think that operator is a function that maps symbols to their descriptions. In ASP, however, operator/2 defines a relationship between two things: a symbol and its meaning. The /2 indicates that this relationship takes two arguments (this is called the arity). When we write operator("<", "Less than"), we're stating a fact that relates the symbol < with the phrase "Less than". This distinction between functions and relationships is subtle but important — it shifts our thinking from computing values to declaring knowledge. We can also create an "alias" to make it easier to get the unique set of operators:
operator(X) ← operator(X, _) .
{
operator/1
"!="
"<"
"<="
"=="
">"
">="
"array-contains-any"
"array-contains"
"in"
"not-in"
}
ASP: Variables in ASP start with either an underscore (_) or upper case letter (A...Z). The underscore by itself (_) is the anonymous variable — a special case that says "there exists some value here but we don't care what it is." When we write operator(X, _), we're saying "there exists a pair where X is the first element and we don't care about the second element." The rule operator(X) ← operator(X, _) demonstrates how predicates with different arities can relate to each other. Here, operator/1 and operator/2 are distinct predicates despite sharing the same name. The rule creates a new single-argument fact operator(X) for each unique first argument in the two-argument operator(X, _) facts. This is a common pattern for extracting a subset of information from a more detailed relationship.
Convenience Predicates
Let's define some convenience predicates that group related operators together. This makes the code more maintainable and self-documenting while also making it easier to express constraints later.
Range Comparison Operators
The range comparison operators are: <, <=, >, >=
range_comparison_op("<" ; "<=" ; ">" ; ">=") .
{
range_comparison_op/1
"<"
"<="
">"
">="
}
Not-In Operators
The not equals operators are: !=, not-in
not_equals_op("!=" ; "not-in") .
{
not_equals_op/1
"!="
"not-in"
}
Array Operators
The array operators are: array-contains, array-contains-any
ASP: In ASP, the semicolon (;) is used to pool multiple values into a single rule or fact. Writing range_comparison_op("<" ; "<=" ; ">" ; ">=") is equivalent to writing four separate facts:
This pooling syntax makes the code more concise and visually groups related items together. It's particularly useful when defining sets of related values like we're doing with these operator groupings.The convention I follow is to use pooling when the items form a natural set (like comparison operators) and separate facts when the relationships are more incidental or derived from different sources.
Queries
In Firestore, queries are containers that hold various constraints such as where, orderBy, and limit clauses. Rather than diving into all the query complexities at once, we'll start with a minimal representation that captures just what we need: a way to identify distinct queries.
query(ex1) .
This simple fact establishes ex1 as a query identifier that we can reference when adding clauses. While real Firestore queries have many more properties, starting with just an ID lets us focus on modeling the relationships between clauses.The query identifier acts as a "parent" that groups related clauses together. When we add a where clause such as:
where(ex1, path1, "==", valueA) .
The first argument ex1 links this constraint to its parent query. This pattern of using a shared identifier to group related facts is common in ASP modeling — it's similar to how foreign keys work in relational databases.As we build up our model, we'll add more query properties when needed to express specific constraints. This incremental approach lets us manage complexity by introducing concepts only when they become relevant to the rules we're encoding.
where | Query Constraint
Looking at the query examples, we need to encode the relationship between a field path, operator and value found in the where() function. The most basic form of this is a simple query that has a single constraint. For example, where("state", "==", "CA") is a clause that finds all documents where the field path "state" equals "CA". We can express this relationship in ASP without worrying about the specific details of paths and values yet:
where(example, path, "<", value) .
{where(example,path,"<",value)}
This establishes the basic structure of a where/4 constraint. It relates four things: a parent query, a path to a field in the document, an operator, and a value to compare against. The specific rules about what constitutes valid paths and values will be layered on top of this foundation.
Restricting Valid Operators
Let's layer in our first constraint. A where clause must use one of our defined operators — it wouldn't make sense to allow arbitrary strings as operators. In ASP, we express this by declaring what is invalid rather than exhaustively listing what is valid:
⊥ ← where(_, _, X, _), not operator(X) .
ASP: The constraint above uses several key ASP operators: ⊥ (false), ← (if), , (and), and not (negation). Reading from left to right, it states "it cannot be that there exists a where clause with operator X where X is not a valid operator." This is how ASP enforces business rules — by stating what combinations are invalid rather than listing all valid ones.In most references and textbooks you would find (11) written as:
:- where(_, _, X, _), not operator(X) .
Since it's not 1990 any more, we can use unicode and replace :- with ← and #false with ⊥. I personally always put a leading ⊥ to emphasize that I am writing a constraint (the leading #false is implied in (12)).
A similar constraint can be used to ensure that that the where clause matches a query/1:
⊥ ← where(Q, _, _, _), not query(Q) .
Unit Tests
One of the most powerful aspects of ASP is that we can incrementally build and validate our understanding. Rather than writing a complete specification up front, we can encode facts and rules as we encounter them, testing at each step to ensure the model matches our understanding.For example, having defined valid operators, we can now verify that our model correctly accepts or rejects different where clauses:
Query ex1 and the not-in operator are valid / known:
where(ex1, path1, "not-in", valueA) .
{
where/4
ex1
path1
"not-in"
valueA
}
The not-equal operator is unknown:
where(ex1, path1, "not-equal", valueA) .
{Unsatisfiable}
ASP: When ASP returns {unsatisfiable}, it means that no solution exists that satisfies all of the constraints. In this case, the constraint ⊥ ← where(_, _, X, _), not operator(X) states that it's impossible for a where clause to use an operator that isn't in our defined set. Since "not-equal" wasn't defined as a valid operator, adding where(path1, "not-equal", valueA) creates a logical contradiction — there can't be any valid solution that includes this fact. This is one of ASP's key features: it fails fast when constraints are violated rather than silently accepting invalid data.
The query my_query is unknown:
where(my_query, path1, "==", valueA) .
{Unsatisfiable}
Provide Error Messages
While the constraint relation (⊥ ← …) enforces rules by eliminating invalid solutions, it doesn't tell us why something failed. For debugging and user feedback, it's often more useful to generate explanatory messages rather than just constraining the result set.Instead of (11), we can create an error/1 relation that captures both the invalid condition and a human-readable explanation:
error((Q, " is not a known query")) ←
where(Q, _, X, _), not query(Q) .
error((X, " is not a valid operator")) ←
where(_, _, X, _), not operator(X) .
ASP: A tuple combines multiple values into a single unit, similar to how a chord combines multiple notes. When we write error((X, " is not a valid operator")), we're creating a tuple that pairs the invalid operator X with the explanatory text. The outer parentheses define the argument to error/1, while the inner parentheses create the tuple.
Unit Tests
A choice rule allows us to explore combinations of valid and invalid where clauses. In this case, we'll choose up to 2 clauses from a set containing:
ASP: Choice rules provide a concise way to generate combinations of facts. The syntax m { … } n means "choose at least m items and at most n items from this set." Like dealing cards from a deck — you might say "deal 2 cards from {ace♠, king♥, joker}", which could give you {ace♠,king♥}, {ace♠,joker}, or {king♥,joker}. Choice rules combined with constraints let us explore the space of valid solutions while automatically rejecting invalid combinations.The clean separation between generating possibilities (choice rules) and filtering invalid cases (constraints) is a key strength of ASP. Rather than having to explicitly code every valid combination, we can state the general pattern and let ASP handle the details.
This gives us the best of both worlds. We can keep the informative error messages while still constraining the solution space to only valid combinations by adding "it cannot be that there is an error/1":
⊥ ← error(_) .
{}{
where/4
ex1
path2
"=="
valueB
}{
where/4
ex1
path1
"not-in"
valueA
}{
where/4
ex1
path1
"not-in"
valueA
ex1
path2
"=="
valueB
}
During development, we often want to focus specifically on the error cases. We can invert our previous constraint to only show combinations that generate errors. This is particularly useful when testing new constraints or debugging complex rule interactions. Think of it like a filter that removes all the valid cases, letting us zoom in on potential problems. "It cannot be that there is not an error/1":
⊥ ← not error(_) .
{
error/1
(my_query," is not a known query")
where/4
my_query
path3
"!="
valueC
}{
error/1
(my_query," is not a known query")
where/4
ex1
path2
"=="
valueB
my_query
path3
"!="
valueC
}{
error/1
(my_query," is not a known query")
where/4
ex1
path1
"not-in"
valueA
my_query
path3
"!="
valueC
}
We can also do the same thing for warnings:
⊥ ← not warning(_) .
Test Data
For all rules below, the following test data will be useful:
{ where(ex1, P, Op, V) :
P = (path1 ; path2),
operator(Op),
V = (valueA) } = 2 .
{
where/4
ex1
path1
"<"
valueA
ex1
path1
"<="
valueA
}{
where/4
ex1
path1
"!="
valueA
ex1
path1
"<"
valueA
}{
where/4
ex1
path1
"!="
valueA
ex1
path1
"<="
valueA
}{
where/4
ex1
path1
"<"
valueA
ex1
path1
"=="
valueA
}{
where/4
ex1
path1
"<="
valueA
ex1
path1
"=="
valueA
}{
where/4
ex1
path1
"<"
valueA
ex1
path1
">="
valueA
}{
where/4
ex1
path1
"<="
valueA
ex1
path1
">="
valueA
}{
where/4
ex1
path1
"<"
valueA
ex1
path2
">="
valueA
}{
where/4
ex1
path1
"<="
valueA
ex1
path2
">="
valueA
}{
where/4
ex1
path1
"<"
valueA
ex1
path2
">"
valueA
}{
where/4
ex1
path1
"<="
valueA
ex1
path2
">"
valueA
}{
where/4
ex1
path1
"<"
valueA
ex1
path2
"=="
valueA
}
...
ASP: The rule { where(ex1, P, Op, V) : P = (path1 ; path2), operator(Op), V = (valueA) } = 2 has several key components:
{ … } = 2 specifies that we want exactly 2 facts that match the pattern (it's equivalent to writing 2 { … } 2);
where(ex1, P, Op, V) is the template for the facts we're generating;
After the : are the conditions that define valid values:
P = (path1 ; path2) means P can be either "path1" or "path2"
operator(Op) means Op must be a valid operator as defined earlier
V = (valueA) means V must be "valueA"
The ; operator in (path1 ; path2) represents alternatives - it's like saying "choose one from this list". This is different from the , operator which means "and" - all conditions joined by commas must be true.So in plain English, this rule says "generate exactly 2 where clauses, each using either path1 or path2 as the path, any valid operator, and valueA as the value."This concise way of generating test data lets us explore how our constraints handle different combinations of valid inputs. Rather than manually specifying every possible test case, we let ASP systematically generate them.
The approach used throughout this document is to test based on (22). Each test does not include other constraint rules — it focuses solely on the latest constraint for maximum visibility.
where | Operator Constraints #1
Can't Combine
"You can't combine not-in with in, array-contains-any, or or in the same query." Also "You can't combine array-contains with array-contains-any in the same disjunction." Also "You can't combine not-in and != in a query."
("array-contains-any","not-in"," cannot be combined in the same query")
where/4
ex1
path1
"array-contains-any"
valueA
ex1
path1
"not-in"
valueA
}{
error/1
("array-contains-any","not-in"," cannot be combined in the same query")
where/4
ex1
path1
"array-contains-any"
valueA
ex1
path2
"not-in"
valueA
}{
error/1
("array-contains-any","not-in"," cannot be combined in the same query")
where/4
ex1
path2
"array-contains-any"
valueA
ex1
path2
"not-in"
valueA
}{
error/1
("array-contains-any","not-in"," cannot be combined in the same query")
where/4
ex1
path1
"not-in"
valueA
ex1
path2
"array-contains-any"
valueA
}{
error/1
("in","not-in"," cannot be combined in the same query")
where/4
ex1
path2
"in"
valueA
ex1
path2
"not-in"
valueA
}
...
ASP: When a predicate appears multiple times in a rule with different variables, it's like creating multiple instances of the same pattern that must all be satisfied simultaneously. In (23) the two cant_combine_op predicates work together — the first finds an operator Op1 from set S, and the second finds a different operator Op2 from the same set S (constrained by Op1 < Op2). Similarly, the two where predicates check if both operators actually appear in query clauses.This is different from traditional programming where you might write nested loops. Instead, ASP looks for all ways to make these patterns true simultaneously. It's like solving a system of equations — each predicate adds another constraint that must be satisfied.This pattern of using the same predicate multiple times with different variables is particularly useful when looking for relationships between elements, like finding pairs of incompatible operators or checking for duplicate values. The variables act as placeholders that ASP will fill in with all possible valid combinations.While it may take you a bit to wrap your head around it, this declarative style — stating what relationships must hold true rather than how to compute them — is what makes ASP so powerful for encoding constraints.
At Most One
"Only a single not-in or != is allowed per query."Additionally "You can use at most one array-contains clause per disjunction."
only_one_op("in" ; "not-in" ; "array-contains") .
error((Op, " cannot be in more than one where clause")) ←
only_one_op(Op),
C = #count{ M,N : where(Q, M, Op, N) }, C > 1,
query(Q) .
{
error/1
("not-in"," cannot be in more than one where clause")
where/4
ex1
path1
"not-in"
valueA
ex1
path2
"not-in"
valueA
}{
error/1
("in"," cannot be in more than one where clause")
where/4
ex1
path1
"in"
valueA
ex1
path2
"in"
valueA
}{
error/1
("array-contains"," cannot be in more than one where clause")
where/4
ex1
path1
"array-contains"
valueA
ex1
path2
"array-contains"
valueA
}
ASP: The #count aggregate and its placement illustrate two important ASP concepts. Let's break them down:The aggregate #count{ M,N : where(M, Op, N) } counts unique combinations of M and N that satisfy where(Q, M, Op, N) for a given operator Op. Think of it like a SQL GROUP BY — we're counting occurrences grouped by operator.The placement of only_one_op(Op)outside the aggregate means "for each of these specific operators, count their occurrences". If we had instead written:
C = #count{ M,N,Op : where(Q, M, Op, N), only_one_op(Op) }
we would be counting all occurrences of any listed operator together. It's the difference between:"Each of these operators can appear at most once" (current)vs"Only one occurrence of any of these operators total" (alternative)This subtle distinction in aggregate placement lets us express different kinds of counting constraints. The current placement aligns with Firestore's actual rules — each operator has its own independent limit rather than sharing a combined limit.
ASP: We could have written (24) in the same way we wrote (23):
only_one_op("in" ; "not-in" ; "array-contains" ; "array-contains-any") .
error((Op, " cannot be in more than one where clause")) ←
only_one_op(Op),
where(Q, P1, Op, _),
where(Q, P2, Op, _),
P1 < P2.
However, using #count is more idiomatic ASP and clearer in intent — we're directly expressing "there cannot be more than one occurrence" rather than looking for pairs of occurrences.The #count aggregate is also more flexible — if we later need to change the limit or check for other cardinality constraints, it's a simple numeric comparison rather than restructuring the logic to look for specific patterns of occurrences.This illustrates a common pattern in ASP modeling: start with the simplest expression of the constraint (looking for pairs), then refactor to more general forms (counting occurrences) as the model evolves.
where | Operator Warnings
"Not-equal (!=) and not-in queries exclude documents where the given field does not exist."
warning(("Using ", Op, " will exclude documents where ", P, " doesn't exist")) ←
where(_, P, Op, _),
not_equals_op(Op) .
{
warning/1
("Using ","!="," will exclude documents where ",path1," doesn't exist")
where/4
ex1
path1
"!="
valueA
ex1
path1
"<"
valueA
}{
warning/1
("Using ","!="," will exclude documents where ",path1," doesn't exist")
where/4
ex1
path1
"!="
valueA
ex1
path1
"<="
valueA
}{
warning/1
("Using ","not-in"," will exclude documents where ",path2," doesn't exist")
where/4
ex1
path1
"<"
valueA
ex1
path2
"not-in"
valueA
}{
warning/1
("Using ","not-in"," will exclude documents where ",path2," doesn't exist")
where/4
ex1
path1
"<="
valueA
ex1
path2
"not-in"
valueA
}{
warning/1
("Using ","!="," will exclude documents where ",path2," doesn't exist")
where/4
ex1
path1
"<"
valueA
ex1
path2
"!="
valueA
}
…
limit Clause
There's no explicit limit on limit clauses but it seems logical that there is at most one limit clause specified.
error(("Only one limit clause may be specified")) ←
#count{ X : limit(Q, X), query(Q) } > 1 .
This could also be expressed as:
error(("Only one limit clause may be specified")) ←
limit(Q, X), limit(Q, Y), X != Y .
Unit Test
A query cannot have multiple limit clauses. Here we test that combining limit(1) and limit(10) with a where clause generates an error:
There cannot be an encoding that includes two orderBy/4's with the same index for the same query:
error(("Cannot have more than one orderBy with the same index")) ←
orderBy(Q, X, P1, _),
orderBy(Q, X, P2, _),
P1 != P2 .
Unit Test
Testing that orderBy clauses cannot share the same index (1). We try to create two orderBy clauses both with index 1 but different paths and directions:
There cannot be the same path with more than one sort direction for the same query:
error((P, "has more than one sort direction")) ←
orderBy(Q, _, P, D1),
orderBy(Q, _, P, D2),
D1 != D2 .
Unit Test
The same path cannot be ordered in different directions. Here we test that using path1 with both ascending and descending directions generates an error:
"If you include a filter with a range comparison (<, <=, >, >=), your first ordering must be on the same field"
error((P, " must be first be first ordering since ", Op, " is used")) ←
where(Q, P, Op, _),
orderBy(Q, I, P, _), I != 1,
range_comparison_op(Op) .
Unit Test
Testing that when using range comparison operators ("<", ">="), the field must appear in the first orderBy clause. Here we test combinations of where clauses using range operators with orderBy clauses at different positions, expecting errors when the range-compared field isn't ordered first:
(path1," must be first be first ordering since ","<"," is used")
orderBy/4
ex1
2
path1
"asc"
where/4
ex1
path1
"<"
valueA
}{
error/1
(path1," must be first be first ordering since ",">="," is used")
orderBy/4
ex1
2
path1
"asc"
where/4
ex1
path1
">="
valueA
}
Operator Warnings
!= or not-in Without Limit Warning
"A != query clause might match many documents in a collection. To control the number of results returned, use a limit clause or paginate your query.". "A not-in query clause might match many documents in a collection. To control the number of results returned, use a limit clause or paginate your query."
Testing that using a != operator without a limit clause generates a warning. Here we test combinations of equality and not-equals clauses with and without limit clauses, expecting warnings only when != is used without a limit:
Testing that using a not-in operator without a limit clause generates a warning. Here we test combinations of equality and not-in clauses with and without limit clauses, expecting warnings only when not-in is used without a limit:
So far, we've focused on validating individual query clauses. But real-world Firestore queries often combine multiple conditions using AND and OR operations:
Are state capitals in either the west or midwest regions
Tree Structure vs. Flat References
Our earlier model used direct references between where clauses and queries:
query(q1) .
where(q1, "state", "==", "CA") .
But compound queries create a tree structure where where clauses might be nested several levels deep. Instead of direct references, we need to track parent-child relationships throughout the tree:
query((1,0)) .
and((1,0), 2, 3) . % Top AND
where((2,1), "state", "==", "CA") .
or((3,1), 4, 5) . % Main OR
where((4,3), "population", ">", 1000000) .
and((5,3), 6, 7) . % Nested AND
where((6,5), "capital", "==", true) .
or((7,5), 8, 9) . % Region OR
where((8,7), "region", "==", "west") .
where((9,7), "region", "==", "midwest") .
Each node is identified by an (ID,ParentID) tuple that captures its position in the tree. The ParentID component lets us traverse up the tree, while explicit child references in and and or nodes let us traverse down.
{
(1,"AND")
(2,("state","==","CA"))
(3,"OR")
(4,("population",">",1000000))
(5,"AND")
(6,("capital","==",true))
(7,"OR")
(8,("region","==","west"))
(9,("region","==","midwest"))
}
Query Tree Navigation
To apply query-wide constraints (e.g. "at most one != operator"), we need to find all where clauses that belong to a query, regardless of nesting depth. The following descendant relationship handles this:
Now instead of checking direct references like where(Q, _, "!=", _), we can find all descendant where clauses of a query root. For example, to enforce "at most one != operator", we write:
only_one_op("in" ; "not-in" ; "array-contains") .
error((Op, " cannot be in more than one where clause")) ←
only_one_op(Op),
C = #count{ M,N : where(ID, M, Op, N), descendant(Q, ID) }, C > 1,
query(Q) .
Unit Test
Testing that array-contains operator cannot appear more than once in a compound query, even when nested. Here we create a query tree with two array-contains clauses at different nesting levels, expecting an error because they belong to the same logical query:
("array-contains"," cannot be in more than one where clause")
}
Computing DNF Terms
Firestore limits queries to "30 disjunctions in disjunctive normal form." Rather than expanding queries by hand, we can track how terms combine through logical operations:
WHERE clauses count as 1 term
OR operations add terms
AND operations multiply terms
For a deep dive into why DNF matters and how it works, see our companion guide on Disjunctive Normal Form: A Practical Guide. We'll just take those learnings and apply them here:
Base case: The base case for counting DNF terms requires special handling because in clauses in Firestore are actually syntactic sugar for multiple OR conditions. For example, where("state", "in", ["CA", "NY", "TX"]) is equivalent to or(where("state", "==", "CA"), where("state", "==", "NY"), where("state", "==", "TX")). Rather than explicitly transforming these clauses into OR nodes, we account for this in our counting:
Regular where clauses count as 1 disjunction, while in clauses count as multiple disjunctions based on their array size. This approach lets us accurately count DNF terms without needing to modify the query structure.
Note: We've modified our earlier query/1 and where/3 predicates to use a tuple (ID,Parent) as its first argument rather than a simple query identifier. This lets us track relationships between clauses in compound expressions.The beauty of this approach is that it handles arbitrary nesting while maintaining a clear connection to the original query structure. When Firestore tells us to "limit disjunctions in DNF", we now have a mechanical way to verify compliance without getting lost in the combinatorial explosion of expanding complex queries by hand.This demonstrates one of ASP's key strengths — it lets us focus on declaring the rules of our domain (how DNF terms combine) rather than getting bogged down in the procedural details of counting them. The solver handles all the complex bookkeeping, letting us focus on expressing the constraints correctly.
DNF Limit: Maximum 30 Disjunctions
"Cloud Firestore limits a query to a maximum of 30 disjunctions in disjunctive normal form."
error(("Query exceeds maximum of 30 disjunctions (found ", N, ")")) ←
dnf_count(N), N > 30 .
Unit Test
Following the example from the Firestore documentation:
% For our array values, we track how many elements they contain
value_count(array_a, 5) . % [1, 2, 3, 4, 5]
value_count(array_b, 10) . % [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
% The query structure matching the example
query((1,0)) .
and((1,0), 2, 3) .
where((2,1), "a", "in", array_a) . % 5 values = 5 disjunctions
where((3,1), "b", "in", array_b) . % 10 values = 10 disjunctions
{
error/1
("Query exceeds maximum of 30 disjunctions (found ",50,")")
}
Array Operator Constraints - Redux!
Earlier, we handled array operator constraints at the query level. However, Firestore's actual constraints are more nuanced:
"You can use at most one array-contains clause per disjunction."
"You can't combine array-contains with array-contains-any in the same disjunction."
Notice that these constraints apply per disjunction, not per query. Just as we tracked DNF terms through the query tree, we need a similar mechanism for tracking operator usage in each potential disjunction.
Tracking Array Operations
Just as we grouped operators for query-level constraints, we define operator groups that can't coexist within a single disjunction:
The first group enforces the "at most one array-contains" rule, while the second prevents mixing array-contains with array-contains-any.Rather than expanding queries into every possible combination (which explodes quickly), we'll reuse our DNF counting pattern to track operators through the tree. Sometimes the most elegant solution comes from just reusing what already works!
Base case: a WHERE clause contributes 1 to its operator's group count if it uses that operator, and 0 otherwise:
OR case: OR nodes take the maximum count from their children since each disjunctive term comes from exactly one branch of the OR. This reflects that when we expand to DNF, each term follows only one path through OR nodes:
AND case: AND nodes sum their children's counts since operators from both branches appear together in the resulting disjunctive terms. When we expand to DNF, all conditions joined by AND must appear in the same term:
What you should notice here is that operator counts follow the same pattern as DNF terms:
WHERE clauses contribute directly to their operator groups
OR nodes take the maximum from children (since each disjunction follows one path)
AND nodes sum their children (since all conditions appear together)
Enforcing Per-Disjunction Constraints
With operator tracking in place, we can now enforce the per-disjunction constraints:
error(("More than one array-contains in disjunction")) ←
result_group_count("array-contains", Count),
Count > 1 .
error(("Cannot combine array-contains with array-contains-any in disjunction")) ←
result_group_count("array-ops", Count),
Count > 1 .
Unit Tests
Invalid — Multiple array-contains in same disjunction branch:
{
(1,"AND")
(2,("tags","array-contains","hiking"))
(3,("categories","array-contains","outdoor"))
}
query((1,0)) .
and((1,0), 2, 3) . % Single AND group
where((2,1), "tags", "array-contains", "hiking") . % First array-contains
where((3,1), "categories", "array-contains", "outdoor") . % Second array-contains - should error
{
error/1
"Cannot combine array-contains with array-contains-any in disjunction"
"More than one array-contains in disjunction"
}
Valid — array-contains in different OR branches
{
(1,"OR")
(2,("tags","array-contains","hiking"))
(3,"OR")
(4,("categories","array-contains","outdoor"))
(5,("price",">",100))
}
query((1,0)) .
or((1,0), 2, 3) . % First OR group
or((3,1), 4, 5) . % Second OR group
where((2,1), "tags", "array-contains", "hiking") . % In first disjunction
where((4,3), "categories", "array-contains", "outdoor") . % In second disjunction
where((5,3), "price", ">", 100) . % In second disjunction
{Unsatisfiable}
Invalid — array-contains combined with array-contains-any in same disjunction through AND
"Cannot combine array-contains with array-contains-any in disjunction"
}
Range Field Constraints
"Cloud Firestore limits the number of range or inequality fields to 10"Rather than simply counting where clauses directly, we need to track range comparisons through the entire query tree structure:
range_field_count(Q, C) ←
query(Q),
C = #count{ P : where((ID,_), P, Op, _),
descendant(Q, (ID,_)),
range_comparison_op(Op)
} .
This leverages our existing tree traversal mechanism (descendant/2) to find all range comparisons regardless of nesting depth.
error(("Query exceeds maximum of 10 range fields (found ", C, ")")) ←
range_field_count(Q, C), C > 10 .
Unit Tests
Invalid — Exceeds maximum range fields through deep nesting:
("Query exceeds maximum of 10 range fields (found ",11,")")
}
Valid — Multiple range comparisons on same field count only once:
query((1,0)) .
and((1,0), 2, 3) . % First AND connects field1 clauses
and((3,1), 4, 5) . % Second AND connects more field1 and field2
where((2,1), "field1", "<", 100) . % These three count as ONE range field
where((3,1), "field1", "<=", 200) . % since they're all on "field1"
where((4,3), "field1", ">", 300) .
where((5,3), "field2", ">=", 400) . % Second range field
{
range_field_count/2
(1,0)
2
}
From Constraints to Conversations
You know what's beautiful about this whole process? We started with documentation — dry, technical documentation about database queries — and through the lens of Answer Set Programming, we transformed it into something alive, something that thinks. We took all those scattered "thou shalt nots" of Firestore and turned them into a coherent system that doesn't just enforce rules but understands them.This is what creativity looks like in the technical realm. It's not about writing the most clever code or using the fanciest algorithms. It's about finding ways to make complexity manageable, to turn scattered constraints into clear patterns that reveal their own inner logic.What we've built here isn't just a validator. It's a tool for thinking. Each constraint we encoded isn't just a rule; it's a piece of knowledge made explicit, made workable. When you're deep in development, juggling a dozen different query conditions, this system becomes like a trusted colleague who taps you on the shoulder and says, "Hey, remember that thing about array operators? Let me help you think that through."And here's the really exciting part: this is just the beginning. Once you've encoded knowledge this way, you can start asking new questions. What kinds of queries are possible given these constraints? What patterns emerge when you push against these limitations? The system becomes not just a validator but an explorer, helping you understand the shape of what's possible.That's the real magic of Answer Set Programming. It lets us move from "Does this work?" to "What could work?" It turns constraints from walls into windows, letting us see new possibilities within the boundaries we've defined.So yes, there's more we could encode, more edge cases we could handle. But I hope you're starting to see something bigger here: a way of thinking that turns technical requirements into creative opportunities. Because sometimes the most profound creativity comes not from complete freedom, but from understanding your constraints so well that they become tools for discovery.Keep building. Keep exploring. And remember: every constraint is just another chance to find something new.
Answer Set Programming reveals the elegant logical relationships that underpin music, transforming abstract musical concepts into clear rules where scales, chords, and harmonies naturally emerge.22 January 2025
Skip the theory and see how Answer Set Programming helps you analyze real-world query complexity in systems such as Firestore. Dive in and build tools, not just theorems.4 January 2025