can-query-logic
Perform data queries and compare queries against each other. Provides logic useful for data caching and real-time behavior.
new QueryLogic( [schemaOrType] [,options] )
The can-query-logic
package exports a constructor function that builds query logic
from:
- an optional schema or type argument, and
- an optional
options
argument used to convert alternate parameters to the expected Query format.
For example, the following builds query logic from a can-define/map/map:
import {DefineMap, QueryLogic} from "can";
const Todo = DefineMap.extend({
id: {
identity: true,
type: "number"
},
name: "string",
complete: "boolean"
});
const todoQueryLogic = new QueryLogic(Todo);
const filter = todoQueryLogic.filterMembers({
filter: {
complete: false
},
sort: "-name",
page: {start: 0, end: 19}
},[
{id: 1, name: "do dishes", complete: false},
{id: 2, name: "mow lawn", complete: true},
// ...
]);
console.log( filter ); //-> [{id: 1, name: "do dishes", complete: false}]
Once a query logic instance is created, it can be used to perform actions using queries. For example, the following might select 20 incomplete todos from a list of todos:
import {DefineMap, QueryLogic} from "can";
const Todo = DefineMap.extend({
id: {
identity: true,
type: "number"
},
name: "string",
complete: "boolean"
});
const todoQueryLogic = new QueryLogic(Todo);
const filter = todoQueryLogic.filterMembers({
filter: {
complete: false
},
sort: "-name",
page: {start: 0, end: 19}
},[
{id: 1, name: "do dishes", complete: false},
{id: 2, name: "mow lawn", complete: true},
// ...
]);
console.log( filter ); //-> [{id: 1, name: "do dishes", complete: false}]
By default can-query-logic
supports queries represented by the Query
format. It supports a variety of operators and options. It looks like:
import {QueryLogic} from "can";
import {Todo} from "//unpkg.com/can-demo-models@5";
const todoQueryLogic = new QueryLogic(Todo);
// Perform query logic:
const filter = todoQueryLogic.filterMembers({
// Selects only the todos that match.
filter: {
complete: false
},
// Sort the results of the selection
sort: "-name",
// Selects a range of the sorted result
page: {start: 0, end: 19}
},[
{id: 1, name: "do dishes", complete: false},
{id: 2, name: "mow lawn", complete: true},
// ...
]);
console.log( filter ); //-> [{id: 1, name: "do dishes", complete: false}]
Parameters
- schemaOrType
{function(options)|can-reflect/schema}
:Defines the behavior of keys on a Query. This is done with either:
- A constructor function that supports can-reflect.getSchema. Currently, can-define/map/map supports the
can.getSchema
symbol:
import {DefineMap, QueryLogic} from "can"; const Todo = DefineMap.extend({ id: { identity: true, type: "number" }, name: "string", complete: "boolean" }); const todoQueryLogic = new QueryLogic(Todo); const filter = todoQueryLogic.filterMembers({ filter: { complete: false }, sort: "-name", page: {start: 0, end: 19} },[ {id: 1, name: "do dishes", complete: false}, {id: 2, name: "mow lawn", complete: true}, // ... ]); console.log( filter ); //-> [{id: 1, name: "do dishes", complete: false}]
A schema object that looks like the following:
import {QueryLogic, MaybeNumber, MaybeString, MaybeBoolean} from "can"; const queryLogic = new QueryLogic({ // keys that uniquely represent this type identity: ["id"], keys: { id: MaybeNumber, name: MaybeString, complete: MaybeBoolean } }); const result = queryLogic.filterMembers({ filter: {complete: false}}, [ {id: "1", name: "Justin", complete: "truthy"}, {id: "2", name: "Paula", complete: ""}, {id: "3", name: "Kevin", complete: true} ]); console.log( result );
Note that if a key type (ex:
name: MaybeString
) is not provided, filtering by that key will still work, but there won't be any type coercion. For example, the following might not produce the desired results:import {QueryLogic} from "can"; const queryLogic = new QueryLogic(); const unionized = queryLogic.union( {filter: {age: 7}}, {filter: {age: "07"}} ); console.log( JSON.stringify( unionized ) ); //-> "{'filter':{'age':{'$in':[7,'07']}}}"
Use types like can-data-types/maybe-number/maybe-number if you want to add basic type coercion:
import {QueryLogic, MaybeNumber} from "can"; const queryLogic = new QueryLogic({ identity: ["id"], keys: {age: MaybeNumber} }); const unionized = queryLogic.union( {filter: {age: 7}}, {filter: {age: "07"}} ); console.log( JSON.stringify( unionized ) ); //-> {filter: {age: 7}}
If you need even more special key behavior, read defining properties with special logic.
By default, filter properties like
status
in{filter: {status: "complete"}}
are used to create to one of the Comparison Operators likeGreaterThan
. A matching schema key will overwrite this behavior. How this works is explained in the Defining filter properties with special logic section below. - A constructor function that supports can-reflect.getSchema. Currently, can-define/map/map supports the
Purpose
can-query-logic
is used to give CanJS an understanding of what the parameters used to
retrieve a list of data represent. This awareness helps other libraries like
can-connect and can-fixture provide real-time, caching and other behaviors.
The parameters used to retrieve a list of data?
In many applications, you request a list of data by making a fetch
or XMLHTTPRequest
to a url like:
/api/todos?filter[complete]=true&sort=name
The values after the ?
are used to control the data that comes back. Those values are
deserialized into
a query object look like this:
{
filter: {complete: true},
sort: "name"
}
This object represents a Query. This specific query is for requesting completed todos and have the todos sorted by their name.
A QueryLogic
instance understands what a Query
represents. For example, it can filter records
that match a particular query:
import {QueryLogic} from "can";
const todos = [
{ id: 1, name: "learn CanJS", complete: true },
{ id: 2, name: "wash the car", complete: false },
{ id: 3, name: "do the dishes", complete: true }
];
const queryLogic = new QueryLogic();
const result = queryLogic.filterMembers({
filter: {complete: true},
sort: "name",
}, todos);
console.log( result ); //-> [
// { id: 3, name: "do the dishes", complete: true },
// { id: 1, name: "learn CanJS", complete: true }
//]
The filterMembers method allows QueryLogic
to be used similar to a database. QueryLogic
instances methods help solve other problems too:
- real-time - isMember returns if a particular item belongs to a query and index returns the location where that item belongs.
- caching - isSubset can tell you if you've already loaded data you are looking for. difference can tell you what data you need to load that already isn't in your cache.
In fact, can-query-logic
's most unique ability is to be able to directly compare
queries that represent sets of data instead of having to compare
the data itself. For example, if you already loaded all completed todos,
can-query-logic
can tell you how to get all remaining todos:
import {QueryLogic} from "can";
const completedTodosQuery = {filter: {complete: false}};
const allTodosQuery = {};
const queryLogic = new QueryLogic();
const remainingTodosQuery = queryLogic.difference(allTodosQuery, completedTodosQuery);
console.log( JSON.stringify( remainingTodosQuery ) ); //-> "{'filter':{'complete':{'$ne':false}}}"
Use
There are two main uses of can-query-logic
:
- Configuring a
QueryLogic
instance to match your service behavior. - Using a
QueryLogic
instance to create a new can-connect behavior.
Configuration
Most people will only ever need to configure a
QueryLogic
logic instance. Once properly configured, all can-connect behaviors will
work correctly. If your service parameters match the default query structure,
you likely don't need to use can-query-logic
directly at all. However, if your service parameters differ from
the default query structure or they need additional logic, some configuration will be necessary.
Matching the default query structure
By default, can-query-logic
assumes your service layer will match a default query structure
that looks like:
import {QueryLogic} from "can";
const queryLogic = new QueryLogic()
const filter = queryLogic.filterMembers({
// Selects only the todos that match.
filter: {
complete: {$in: [false, null]}
},
// Sort the results of the selection
sort: "-name",
// Selects a range of the sorted result
page: {start: 0, end: 19}
},
[
{id: 1, name: "do dishes", complete: false},
{id: 2, name: "mow lawn", complete: true},
// ...
]);
console.log( filter ); //-> [{id: 1, name: "do dishes", complete: false}]
This structures follows the Fetching Data JSONAPI specification.
There's:
- a filter property for filtering records,
- a sort property for specifying the order to sort records, and
- a page property that selects a range of the sorted result. The range indexes are inclusive.
NOTE: can-connect does not follow the rest of the JSONAPI specification. Specifically can-connect expects your server to send back JSON data in a different format.
If you control the service layer, we encourage you to make it match the default
Query. The default query structure also supports the following Comparison Operators: $eq
, $gt
, $gte
, $in
, $lt
, $lte
, $ne
, $nin
.
If you support the default structure, it's very likely the entire configuration you need to perform will
happen on the data type you pass to your can-connect connection. For example,
you might create a Todo
data type and pass it to a connection like this:
import {DefineMap, DefineList, realtimeRestModel} from "can";
import {Todo, todoFixture} from "//unpkg.com/can-demo-models@5";
// creates a mock todo api
todoFixture(1);
Todo.List = DefineList.extend("TodoList", {
"#": {Type: Todo}
});
Todo.connection = realtimeRestModel({
url: "/api/todos/{id}",
Map: Todo
});
Todo.getList().then(todos => {
todos.forEach(todo => {
console.log(todo.name); // logs todos
});
});
Internally, realTimeRest
is using Todo
to create and configure a QueryLogic
instance for you. The previous example is equivalent to:
import {DefineMap, DefineList, realtimeRestModel, QueryLogic} from "can";
import {Todo, todoFixture} from "//unpkg.com/can-demo-models@5";
// creates a mock todo api
todoFixture(1);
Todo.List = DefineList.extend("TodoList", {
"#": {Type: Todo}
});
const todoQueryLogic = new QueryLogic(Todo);
Todo.connection = realtimeRestModel({
url: "/api/todos/{id}",
Map: Todo,
queryLogic: todoQueryLogic
});
Todo.getList().then(todos => {
todos.forEach(todo => {
console.log(todo.name); // logs todos
});
});
If your services don't match the default query structure or logic, read on to see how to configure your query to match your service layer.
Changing the query structure
If the logic of your service layer matches the logic of the default query, but the form
of the query parameters is different, the easiest way to configure the QueryLogic
is to
translate your parameter structure to the default query structure.
For example, to change queries to use where
instead of filter
so that queries can be
made like:
import {DefineMap, DefineList, realtimeRestModel, QueryLogic} from "can";
import {Todo, todoFixture} from "//unpkg.com/can-demo-models@5";
// creates a mock todo api
todoFixture(5);
Todo.List = DefineList.extend("TodoList", {
"#": {Type: Todo}
});
const todoQueryLogic = new QueryLogic(Todo);
Todo.connection = realtimeRestModel({
url: "/api/todos/{id}",
Map: Todo,
});
Todo.getList({filter: {complete: true}}).then(todos => {
todos.forEach(todo => {
console.log(todo.name); // logs completed todos
});
});
You can use the options
' toQuery
and toParams
functions
to set the filter
property value to the passed in where
property value.
import {DefineMap, QueryLogic, realtimeRestModel} from "can";
import {Todo, todoFixture} from "//unpkg.com/can-demo-models@5";
todoFixture(5);
// CREATE YOUR QUERY LOGIC
const todoQueryLogic = new QueryLogic(Todo, {
// Takes what your service expects: {where: {...}}
// Returns what QueryLogic expects: {filter: {...}}
toQuery(params){
const where = params.where;
delete params.where;
params.filter = where;
return params;
},
// Takes what QueryLogic expects: {filter: {...}}
// Returns what your service expects: {where: {...}}
toParams(query){
const where = query.filter;
delete query.filter;
query.where = where;
return query;
}
});
Todo.List = DefineList.extend("TodoList", {
"#": {Type: Todo}
});
// PASS YOUR QueryLogic TO YOUR CONNECTION
Todo.connection = realtimeRestModel({
url: "/api/todos/{id}",
Map: Todo,
queryLogic: todoQueryLogic
});
Todo.getList({filter: {complete:true}}).then(todos => {
todos.forEach(todo => {
console.log(todo.name); // shows FILTERED todos
});
});
Defining filter properties with special logic
If the logic of the default query is not adequate to represent
the behavior of your service layer queries, you can define special classes called SetType
s to
provide the additional logic.
Depending on your needs, this can be quite complex or rather simple. The following sections provide configuration examples in increasing complexity.
Before reading the following sections, it's useful to have some background information on
how can-query-logic
works. We suggest reading the How it works section.
Built-in special types
can-query-logic
comes with functionality that can be used to create special logic. For example,
the makeEnum method can be used to build a Status
type that contains ONLY the
enumerated values:
import {QueryLogic, DefineMap} from "can";
const Status = QueryLogic.makeEnum(["new","assigned","complete"]);
const Todo = DefineMap.extend({
id: "number",
status: Status,
complete: "boolean",
name: "string"
});
const todoLogic = new QueryLogic(Todo);
const unionQuery = todoLogic.union(
{filter: {status: ["new","assigned"] }},
{filter: {status: "complete" }}
)
console.log( unionQuery ); //-> {}
NOTE:
unionQuery
is empty because if we loaded all todos that are new, assigned, and complete, we've loaded every todo.
The{}
query would load every todo.
Custom types that work with the comparison operators
If a number or string can represent your type, then you can create a SetType
class
that can be used with the comparison operators.
The SetType
needs to be able to translate back and forth from
the values in the query to a number or string.
For example, you might want to represent a date with a string like:
{
filter: {date: {$gt: "Wed Apr 04 2018 10:00:00 GMT-0500 (CDT)"}}
}
The following creates a DateStringSet
that translates a date string to a number:
import {DefineMap, QueryLogic} from "can";
class DateStringSet {
constructor(value){
this.value = value;
}
// used to convert to a number
valueOf(){
return new Date(this.value).getTime();
}
[Symbol.for("can.serialize")](){
return this.value;
}
}
const DateString = {
[Symbol.for("can.new")]: function(v){ return v; },
[Symbol.for("can.SetType")]: DateStringSet
};
const Todo = DefineMap.extend({
id: {type: "number", identity: true},
name: "string",
date: DateString
});
const queryLogic = new QueryLogic(Todo);
const filter = queryLogic.filterMembers(
{filter: {date: {$gt: "Wed Apr 04 2018 10:00:00 GMT-0500 (CDT)"}}},
[{id: 1, name: "Learn CanJS", date: "Thurs Apr 05 2017 10:00:00 GMT-0500 (CDT)"},
{id: 2, name: "grab coffee", date: "Wed Apr 03 2018 10:00:00 GMT-0500 (CDT)"},
{id: 3, name: "finish these docs", date: "Thurs Apr 05 2018 10:00:00 GMT-0500 (CDT)"}]
);
console.log(filter); //-> [{
// id: 2,
// name: "finish these docs",
// date: "Wed Apr 05 2018 10:00:00 GMT-0500 (CDT)"
// }]
These classes must provide:
constructor
- initialized with the the value passed to a comparator (ex:"Wed Apr 04 2018 10:00:00 GMT-0500 (CDT)"
).- valueOf - return a string or number used to compare (ex:
1522854000000
). Symbol.for("can.serialize")
- returns a string or number to compare against can-data-types for the query.
To configure a QueryLogic
to use a SetType
, it must be the can.SetType
property on a
schema's keys
object. This can be done directly like:
new QueryLogic({
keys: {
date: {[Symbol.for("can.SetType")]: DateStringSet}
}
});
More commonly, DateStringSet
is the can.SetType
symbol of a type like:
import {DefineMap, QueryLogic} from "can";
class DateStringSet {
constructor(value){
this.value = value;
}
// used to convert to a number
valueOf(){
return new Date(this.value).getTime();
}
[Symbol.for("can.serialize")](){
return this.value;
}
}
const DateString = {
[Symbol.for("can.new")]: function(v){ return v; },
[Symbol.for("can.SetType")]: DateStringSet
};
const Todo = DefineMap.extend({
id: {type: "number", identity: true},
name: "string",
date: DateString
});
const queryLogic = new QueryLogic(Todo);
const filter = queryLogic.filterMembers(
{filter: {date: {$gt: "Wed Apr 04 2018 10:00:00 GMT-0500 (CDT)"}}},
[{id: 1, name: "Learn CanJS", date: "Thurs Apr 05 2017 10:00:00 GMT-0500 (CDT)"},
{id: 2, name: "grab coffee", date: "Wed Apr 03 2018 10:00:00 GMT-0500 (CDT)"},
{id: 3, name: "finish these docs", date: "Thurs Apr 05 2018 10:00:00 GMT-0500 (CDT)"}]
);
console.log(filter); //-> [{
// id: 2,
// name: "finish these docs",
// date: "Wed Apr 05 2018 10:00:00 GMT-0500 (CDT)"
// }]
Then this DateString
is used to configure your data type like:
import {DefineMap, QueryLogic} from "can";
class DateStringSet {
constructor(value){
this.value = value;
}
// used to convert to a number
valueOf(){
return new Date(this.value).getTime();
}
[Symbol.for("can.serialize")](){
return this.value;
}
}
const DateString = {
[Symbol.for("can.new")]: function(v){ return v; },
[Symbol.for("can.SetType")]: DateStringSet
};
const Todo = DefineMap.extend({
id: {type: "number", identity: true},
name: "string",
date: DateString
});
const queryLogic = new QueryLogic(Todo);
const filter = queryLogic.filterMembers(
{filter: {date: {$gt: "Wed Apr 04 2018 10:00:00 GMT-0500 (CDT)"}}},
[{id: 1, name: "Learn CanJS", date: "Thurs Apr 05 2017 10:00:00 GMT-0500 (CDT)"},
{id: 2, name: "grab coffee", date: "Wed Apr 03 2018 10:00:00 GMT-0500 (CDT)"},
{id: 3, name: "finish these docs", date: "Thurs Apr 05 2018 10:00:00 GMT-0500 (CDT)"}]
);
console.log(filter); //-> [{
// id: 2,
// name: "finish these docs",
// date: "Wed Apr 05 2018 10:00:00 GMT-0500 (CDT)"
// }]
NOTE: Types like
DateString
need to be distinguished fromSetType
s likeDateStringSet
because types likeDateString
have different values. For example, aDateStringSet
might have a value like "yesterday", but this would not be a validDateString
.
Completely custom types
If you want total control over filtering logic, you can create a SetType
that
provides the following:
- methods:
can.isMember
- A function that returns if an object belongs to the query.can.serialize
- A function that returns the serialized form of the type for the query.
- comparisons:
union
- The result of taking a union of twoSetType
s.intersection
- The result of taking an intersection of twoSetType
s.difference
- The result of taking a difference of twoSetType
s.
The following creates a SearchableStringSet
that is able to perform searches that match
the provided text like:
import {QueryLogic} from "can";
const recipes = [
{id: 1, name: "garlic chicken"},
{id: 2, name: "ice cream"},
{id: 3, name: "chicken kiev"}
];
const queryLogic = new QueryLogic();
const result = queryLogic.filterMembers({
filter: {name: "chicken"}
}, recipes);
console.log( result ); //-> []
Notice how all values that match chicken
are returned.
import {canReflect, QueryLogic} from "can";
// Takes the value of `name` (ex: `"chicken"`)
function SearchableStringSet(value) {
this.value = value;
}
canReflect.assignSymbols(SearchableStringSet.prototype,{
// Returns if the name on a todo is actually a member of the set.
"can.isMember": function(value){
return value.includes(this.value);
},
// Converts back to a value that can be in a query.
"can.serialize": function(){
return this.value;
}
});
// Specify how to do the fundamental set comparisons.
QueryLogic.defineComparison(SearchableStringSet,SearchableStringSet,{
// Return a set that would load all records in searchA and searchB.
union(searchA, searchB){
// If searchA's text contains searchB's text, then
// searchB will include searchA's results.
if(searchA.value.includes(searchB.value)) {
// A:`food` ∪ B:`foo` => `foo`
return searchB;
}
if(searchB.value.includes(searchA.value)) {
// A:`foo` ∪ B:`food` => `foo`
return searchA;
}
// A:`ice` ∪ B:`cream` => `ice` || `cream`
return new QueryLogic.ValueOr([searchA, searchB]);
},
// Return a set that would load records shared by searchA and searchB.
intersection(searchA, searchB){
// If searchA's text contains searchB's text, then
// searchA is the shared search results.
if(searchA.value.includes(searchB.value)) {
// A:`food` ∩ B:`foo` => `food`
return searchA;
}
if(searchB.value.includes(searchA.value)) {
// A:`foo` ∩ B:`food` => `food`
return searchB;
}
// A:`ice` ∩ B:`cream` => `ice` && `cream`
// But suppose AND isn't supported,
// So we return `UNDEFINABLE`.
return QueryLogic.UNDEFINABLE;
},
// Return a set that would load records in searchA that are not in
// searchB.
difference(searchA, searchB){
// if searchA's text contains searchB's text, then
// searchA has nothing outside what searchB would return.
if(searchA.value.includes(searchB.value)) {
// A:`food` \ B:`foo` => ∅
return QueryLogic.EMPTY;
}
// If searchA has results outside searchB's results
// then there are records, but we aren't able to
// create a string that represents this.
if(searchB.value.includes(searchA.value)) {
// A:`foo` \ B:`food` => UNDEFINABLE
return QueryLogic.UNDEFINABLE;
}
// A:`ice` \ B:`cream` => `ice` && !`cream`
// If there's another situation, we
// aren't able to express the difference
// so we return UNDEFINABLE.
return QueryLogic.UNDEFINABLE;
}
});
const recipes = [
{id: 1, name: "garlic chicken"},
{id: 2, name: "ice cream"},
{id: 3, name: "chicken kiev"}
];
const queryLogic = new QueryLogic({ keys: {
name : {[Symbol.for("can.SetType")]: SearchableStringSet}
}});
const result = queryLogic.filterMembers({
filter: {name: "chicken"}
}, recipes);
console.log( result ); //-> [
// {id: 1, name: "garlic chicken"},
// {id: 3, name: "chicken kiev"}
// ]
To configure a QueryLogic
to use a SetType
, it must be the can.SetType
property on a
schema's keys
object. This can be done directly like:
import {canReflect, QueryLogic} from "can";
// Takes the value of `name` (ex: `"chicken"`)
function SearchableStringSet(value) {
this.value = value;
}
canReflect.assignSymbols(SearchableStringSet.prototype,{
// Returns if the name on a todo is actually a member of the set.
"can.isMember": function(value){
return value.includes(this.value);
},
// Converts back to a value that can be in a query.
"can.serialize": function(){
return this.value;
}
});
// Specify how to do the fundamental set comparisons.
QueryLogic.defineComparison(SearchableStringSet,SearchableStringSet,{
// Return a set that would load all records in searchA and searchB.
union(searchA, searchB){
// If searchA's text contains searchB's text, then
// searchB will include searchA's results.
if(searchA.value.includes(searchB.value)) {
// A:`food` ∪ B:`foo` => `foo`
return searchB;
}
if(searchB.value.includes(searchA.value)) {
// A:`foo` ∪ B:`food` => `foo`
return searchA;
}
// A:`ice` ∪ B:`cream` => `ice` || `cream`
return new QueryLogic.ValueOr([searchA, searchB]);
},
// Return a set that would load records shared by searchA and searchB.
intersection(searchA, searchB){
// If searchA's text contains searchB's text, then
// searchA is the shared search results.
if(searchA.value.includes(searchB.value)) {
// A:`food` ∩ B:`foo` => `food`
return searchA;
}
if(searchB.value.includes(searchA.value)) {
// A:`foo` ∩ B:`food` => `food`
return searchB;
}
// A:`ice` ∩ B:`cream` => `ice` && `cream`
// But suppose AND isn't supported,
// So we return `UNDEFINABLE`.
return QueryLogic.UNDEFINABLE;
},
// Return a set that would load records in searchA that are not in
// searchB.
difference(searchA, searchB){
// if searchA's text contains searchB's text, then
// searchA has nothing outside what searchB would return.
if(searchA.value.includes(searchB.value)) {
// A:`food` \ B:`foo` => ∅
return QueryLogic.EMPTY;
}
// If searchA has results outside searchB's results
// then there are records, but we aren't able to
// create a string that represents this.
if(searchB.value.includes(searchA.value)) {
// A:`foo` \ B:`food` => UNDEFINABLE
return QueryLogic.UNDEFINABLE;
}
// A:`ice` \ B:`cream` => `ice` && !`cream`
// If there's another situation, we
// aren't able to express the difference
// so we return UNDEFINABLE.
return QueryLogic.UNDEFINABLE;
}
});
const recipes = [
{id: 1, name: "garlic chicken"},
{id: 2, name: "ice cream"},
{id: 3, name: "chicken kiev"}
];
const queryLogic = new QueryLogic({ keys: {
name : {[Symbol.for("can.SetType")]: SearchableStringSet}
}});
const result = queryLogic.filterMembers({
filter: {name: "chicken"}
}, recipes);
console.log( result ); //-> [
// {id: 1, name: "garlic chicken"},
// {id: 3, name: "chicken kiev"}
// ]
More commonly, SearchableStringSet
is the can.SetType
symbol of a type like:
import {canReflect, DefineMap, QueryLogic} from "can";
// Takes the value of `name` (ex: `"chicken"`)
function SearchableStringSet(value) {
this.value = value;
}
canReflect.assignSymbols(SearchableStringSet.prototype,{
// Returns if the name on a todo is actually a member of the set.
"can.isMember": function(value){
return value.includes(this.value);
},
// Converts back to a value that can be in a query.
"can.serialize": function(){
return this.value;
}
});
// Specify how to do the fundamental set comparisons.
QueryLogic.defineComparison(SearchableStringSet,SearchableStringSet,{
// Return a set that would load all records in searchA and searchB.
union(searchA, searchB){
// If searchA's text contains searchB's text, then
// searchB will include searchA's results.
if(searchA.value.includes(searchB.value)) {
// A:`food` ∪ B:`foo` => `foo`
return searchB;
}
if(searchB.value.includes(searchA.value)) {
// A:`foo` ∪ B:`food` => `foo`
return searchA;
}
// A:`ice` ∪ B:`cream` => `ice` || `cream`
return new QueryLogic.ValueOr([searchA, searchB]);
},
// Return a set that would load records shared by searchA and searchB.
intersection(searchA, searchB){
// If searchA's text contains searchB's text, then
// searchA is the shared search results.
if(searchA.value.includes(searchB.value)) {
// A:`food` ∩ B:`foo` => `food`
return searchA;
}
if(searchB.value.includes(searchA.value)) {
// A:`foo` ∩ B:`food` => `food`
return searchB;
}
// A:`ice` ∩ B:`cream` => `ice` && `cream`
// But suppose AND isn't supported,
// So we return `UNDEFINABLE`.
return QueryLogic.UNDEFINABLE;
},
// Return a set that would load records in searchA that are not in
// searchB.
difference(searchA, searchB){
// if searchA's text contains searchB's text, then
// searchA has nothing outside what searchB would return.
if(searchA.value.includes(searchB.value)) {
// A:`food` \ B:`foo` => ∅
return QueryLogic.EMPTY;
}
// If searchA has results outside searchB's results
// then there are records, but we aren't able to
// create a string that represents this.
if(searchB.value.includes(searchA.value)) {
// A:`foo` \ B:`food` => UNDEFINABLE
return QueryLogic.UNDEFINABLE;
}
// A:`ice` \ B:`cream` => `ice` && !`cream`
// If there's another situation, we
// aren't able to express the difference
// so we return UNDEFINABLE.
return QueryLogic.UNDEFINABLE;
}
});
const SearchableString = {
[Symbol.for("can.SetType")]: SearchableStringSet
};
const Todo = DefineMap.extend({
id: {type: "number", identity: true},
name: SearchableString,
});
const todos = [
{id: 1, name: "important meeting"},
{id: 2, name: "fall asleep during meeting"},
{id: 3, name: "find out what important means"}
];
const queryLogic = new QueryLogic(Todo);
const result = queryLogic.filterMembers({
filter: {name: "important"}
}, todos);
console.log( result ); //->[{id: 1, name: "important meeting"},{id: 3, name: "find out what important means"}]
Then this SearchableString
is used to configure your data type like:
import {canReflect, DefineMap, QueryLogic} from "can";
// Takes the value of `name` (ex: `"chicken"`)
function SearchableStringSet(value) {
this.value = value;
}
canReflect.assignSymbols(SearchableStringSet.prototype,{
// Returns if the name on a todo is actually a member of the set.
"can.isMember": function(value){
return value.includes(this.value);
},
// Converts back to a value that can be in a query.
"can.serialize": function(){
return this.value;
}
});
// Specify how to do the fundamental set comparisons.
QueryLogic.defineComparison(SearchableStringSet,SearchableStringSet,{
// Return a set that would load all records in searchA and searchB.
union(searchA, searchB){
// If searchA's text contains searchB's text, then
// searchB will include searchA's results.
if(searchA.value.includes(searchB.value)) {
// A:`food` ∪ B:`foo` => `foo`
return searchB;
}
if(searchB.value.includes(searchA.value)) {
// A:`foo` ∪ B:`food` => `foo`
return searchA;
}
// A:`ice` ∪ B:`cream` => `ice` || `cream`
return new QueryLogic.ValueOr([searchA, searchB]);
},
// Return a set that would load records shared by searchA and searchB.
intersection(searchA, searchB){
// If searchA's text contains searchB's text, then
// searchA is the shared search results.
if(searchA.value.includes(searchB.value)) {
// A:`food` ∩ B:`foo` => `food`
return searchA;
}
if(searchB.value.includes(searchA.value)) {
// A:`foo` ∩ B:`food` => `food`
return searchB;
}
// A:`ice` ∩ B:`cream` => `ice` && `cream`
// But suppose AND isn't supported,
// So we return `UNDEFINABLE`.
return QueryLogic.UNDEFINABLE;
},
// Return a set that would load records in searchA that are not in
// searchB.
difference(searchA, searchB){
// if searchA's text contains searchB's text, then
// searchA has nothing outside what searchB would return.
if(searchA.value.includes(searchB.value)) {
// A:`food` \ B:`foo` => ∅
return QueryLogic.EMPTY;
}
// If searchA has results outside searchB's results
// then there are records, but we aren't able to
// create a string that represents this.
if(searchB.value.includes(searchA.value)) {
// A:`foo` \ B:`food` => UNDEFINABLE
return QueryLogic.UNDEFINABLE;
}
// A:`ice` \ B:`cream` => `ice` && !`cream`
// If there's another situation, we
// aren't able to express the difference
// so we return UNDEFINABLE.
return QueryLogic.UNDEFINABLE;
}
});
const SearchableString = {
[Symbol.for("can.SetType")]: SearchableStringSet
};
const Todo = DefineMap.extend({
id: {type: "number", identity: true},
name: SearchableString,
});
const todos = [
{id: 1, name: "important meeting"},
{id: 2, name: "fall asleep during meeting"},
{id: 3, name: "find out what important means"}
];
const queryLogic = new QueryLogic(Todo);
const result = queryLogic.filterMembers({
filter: {name: "important"}
}, todos);
console.log( result ); //->[{id: 1, name: "important meeting"},{id: 3, name: "find out what important means"}]
NOTE: Types like
SearchableString
need to be distinguished fromSetType
s likeSearchableStringSet
because types likeSearchableString
have different values. For example, aSearchableStringSet
might have a value like "yesterday", but this would not be a validSearchableString
.
Testing your QueryLogic
It can be very useful to test your QueryLogic
before using it with can-connect.
import {DefineMap, QueryLogic} from "can";
const Todo = DefineMap.extend({ ... });
const queryLogic = new QueryLogic(Todo, {
toQuery(params){ ... },
toParams(query){ ... }
});
unit.test("isMember", function(){
const result = queryLogic.isMember({
filter: {special: "SOMETHING SPECIAL"}
},{
id: 0,
name: "I'm very special"
});
assert.ok(result, "is member");
});
How it works
The following gives a rough overview of how can-query-logic
works:
1. Types are defined:
A user defines the type of data that will be loaded from the server:
import {DefineMap, QueryLogic} from "can";
const Todo = DefineMap.extend({
id: {
identity: true,
type: "number"
},
name: "string",
complete: "boolean"
});
const todoQueryLogic = new QueryLogic(Todo);
const unionization = todoQueryLogic.union(
{ filter: {name: "assigned"} },
{ filter: {name: "complete"} }
);
console.log( JSON.stringify(unionization) ); //-> "{'filter':{'name':{'$in':['assigned','complete']}}}"
2. The defined type exposes a schema:
can-define/map/maps expose this type information as a schema:
import {DefineMap, QueryLogic} from "can";
const Todo = DefineMap.extend({
id: {
identity: true,
type: "number"
},
name: "string",
complete: "boolean"
});
const todoQueryLogic = new QueryLogic(Todo);
const unionization = todoQueryLogic.union(
{ filter: {name: "assigned"} },
{ filter: {name: "complete"} }
);
console.log( JSON.stringify(unionization) ); //-> "{'filter':{'name':{'$in':['assigned','complete']}}}"
3. The schema is used by can-query-logic
to create set instances:
When a call to .filter()
happens like:
import {DefineMap, QueryLogic} from "can";
const Todo = DefineMap.extend({
id: {
identity: true,
type: "number"
},
name: "string",
complete: "boolean"
});
const todoQueryLogic = new QueryLogic(Todo);
const unionization = todoQueryLogic.union(
{ filter: {name: "assigned"} },
{ filter: {name: "complete"} }
);
console.log( JSON.stringify(unionization) ); //-> "{'filter':{'name':{'$in':['assigned','complete']}}}"
The queries (ex: { filter: {name: "assigned"} }
) are hydrated to SetType
s like:
const assignedSet = new BasicQuery({
filter: new And({
name: new Status[Symbol.for("can.SetType")]("assigned")
})
});
NOTE: hydrated is the opposite of serialization. It means we take a plain JavaScript object like
{ filter: {name: "assigned"} }
and create instances of types with it.
The following is a more complex query and what it gets hydrated to:
import {canReflect, QueryLogic} from "can";
//query
const queryLogic = new QueryLogic({
filter: {
age: {$gt: 22}
},
sort: "-name",
page: {start: 0, end: 9}
});
console.log( canReflect.getSchema(queryLogic) ); //-> {
// filter: {
// age: {$gt: 22}
// },
// sort: "-name",
// page: {start: 0, end: 9}
// }
// hydrated set types
new BasicQuery({
filter: new And({
age: new GreaterThan(22)
}),
sort: "-name",
page: new RealNumberRangeInclusive(0,9)
});
Once queries are hydrated, can-query/src/set
is used to perform the union:
set.union(assignedSet, completeSet);
set.union
looks for comparator functions specified on their constructor's
can.setComparisons
symbol property. For example, BasicQuery
has
a can.setComparisons
property and value like the following:
import {BasicQuery} from "can";
BasicQuery[Symbol.for("can.setComparisons")] = new Map([
[BasicQuery]: new Map([
[BasicQuery]: {union, difference, intersection}
[QueryLogic.UNIVERSAL]: {difference}
])
]);
Types like BasicQuery
and And
are "composer" types. Their
union
, difference
and intersection
methods perform
union
, difference
and intersection
on their children types.
can-query-logic
s methods reflect set theory
operations. That's why most types need a union
, intersection
, and difference
method. With that, other methods like isEqual
and isSubset
can be derived.
In this case, set.union
will call BasicQuery
's union with
itself. This will see that the sort
and page
results match
and simply return a new BasicQuery
with the union of the filters:
new BasicQuery({
filter: set.union( assignedSet.filter, completeSet.filter )
})
This will eventually result in a query like:
new BasicQuery({
filter: new And({
name: new Status[Symbol.for("can.SetType")]("assigned", "complete")
})
})
4. The resulting query is serialized:
Finally, this set will be serialized to:
{
filter: {
name: ["assigned", "complete"]
}
}
The serialized output above is what is returned as a result of the union.
Code Organization
On a high level, can-query-logic
is organized in four places:
src/set.js
- The core "set logic" functionality. For exampleset.isEqual
is built to derive from using underlyingdifference
andintersection
operators.src/types/*
- These are theSetType
constructors used to make comparisons between different sets or properties.src/serializers/*
- These provide hydration and serialization methods used to change the plain JavaScript query objects toSetType
s and back to plain JavaScript query objects.can-query-logic.js
- Assembles all the different types and serializers to hydrate a query object to a SetType instance, then usesset.js
's logic to perform the set logic and serialize the result.