Skip to content

Instantly share code, notes, and snippets.

@julesfern
Created May 13, 2011 13:59
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save julesfern/970584 to your computer and use it in GitHub Desktop.
Save julesfern/970584 to your computer and use it in GitHub Desktop.
spah-readme.mdown

Spah: Single-Page Application Helper

Contents

  1. Introduction
  2. The server
  3. The client
  4. Querying the state with SpahQL
  1. Document logic
  1. TODO: Templating with Mustache
  • TODO: Extending the template helpers
  1. TODO: Building your markup with ruby (erb, haml, templating, url builders)
  2. TODO: Reacting to state changes with Javascript
  3. Forcing links and forms to load synchronously
  4. TODO: Default client behaviours (automatically update doc title from /title path, automatically add async behaviour, automatically manage history)
  5. TODO: Eager client state changes (mark parts of the tree as "expected to get new data" to trigger load notifications. automatically revert to previous state on load failure and dispatch failure event)
  6. TODO: Examples: Example Rails application
  7. TODO: Examples: Example Sinatra application
  8. Appendix A: SpahQL expression spec
  9. Appendix B: SpahQL query execution spec

Introduction

Spah is both a server-side rubygem and a client-side javascript library. Together, the two parts are used to build dynamic single-page web applications that:

  • Don't break the web, and keep the document as king. With Spah, every resource may be curled, scraped and indexed just as with a static site.
  • Cold boot into full-fledged javascript applications from any of your application's URLs.
  • Provide graceful continuation of interface state between pages when an action absolutely must be performed synchronously.

The central concept in Spah is the state. Spah manages the state of your application as a hash which may contain more hashes, booleans, strings and arrays. You, as the developer of your application, define the overall structure of the state. As an example, the state for this documentation page might be something like:

 {
   "class": "Spah", // Currently viewing the Spah class
   "method": null, // No specific method selected
   "search-query": "" // No search term entered in search box
 }

On each request to the server, Spah will attach the state in JSON form to the request. The server modifies the state and returns a new JSON object containing the updates. The Spah client merges the updates into the state and dispatches events to which you may subscribe.

The server

A Spah application is assumed to be a RESTish ruby application - in our examples we'll be using Rails. As always, all validation and data persistence is handled by your models. All routing by the router, and all request logic in the controllers. Where a Spah app differs is in the view layer.

Figure 1: A crap approach to single-page apps

Instead of hiding away template logic in server-side ruby, template logic is moved to a place where both the client AND the server have access to it - the document itself.

A Spah HTML render takes the state and a prototype layout and gloms to the two together. The HTML layout contains logic encoded in HTML5 data attributes, and Spah will pre-process the document before sending it down the wire. This way, any view can be cold-rendered by a request to the appropriate URL, or warm-rendered by an asynchronous state update from the server. In a sense, Spah allows your server-side app to behave like a browser and boot your client-side application to the point of usefulness, before letting the user's actual browser carry out the rest of the startup process.

Figure 2: A better approach.

For partials, Spah uses Mustache templates. All .mustache files in your server-side application are indexed by path on startup and made available to the rendering chain on both the client and server. Spah automatically injects the templates into your HTML layout using script tags:

language:html

<script type="text/mustache" id="views/my/partial">
  {{name}}
</script>

These are semantically neutral in nature and do not alter your document's content. Spah will also add a hidden state input to the end of each form in your document containing the current state, and append the state to link URLs within the current domain - this allows for graceful state handover in no-js environments.

In environments that support JS, the state in forms and links is replaced with the current state on submission/activation.

The client

The Spah client handles things at the browser end. Its primary tasks are:

  1. Ensuring that links and forms are submitted asynchronously and that they attach the state when activated (you may also prevent some links and forms from acting asynchronously)
  2. Ensuring that async requests are submitted with a content-accept header of application/state+json, allowing the server-side application to determine that this is a warm request and should be responded to with an updated state
  3. Re-evaluating and processing any document logic whenever the state is modified
  4. Raising path-specific events whenever the state is modified

Figure 3: A better approach, with added progressive enhancement

This is achieved by embedding template logic within the markup using HTML5 data attributes, thus making the same template logic available to both client and server. When the state is updated by a response from the server, elements with embedded display logic are re-evaluated automatically and the display updated accordingly.

You may also bind more advanced behaviours to changes in the state using jQuery responders and state queries

For cleanliness, Spah keeps all of its functions, classes and behaviour within the top level Spah object. Initialising Spah is simple:

 $(document).ready(function() { 
     Spah.init(); 
 });

SpahQL

SpahQL Core concepts

SpahQL is an execution-safe query language designed to let you ask complex questions of basic JSON constructs. SpahQL is designed to work against your application's state, but it will work happily against any combination of native hashes, arrays, strings, booleans and numbers.

SpahQL allows two kinds of query: Selection queries are those which retrieve a set of results from your object, while Assertion queries are those that return a boolean result.

SpahQL is biased towards value equality over key equality. It is assumed that the keys in your object are known, curated namespace with arbitrary values. For this reason SpahQL's matching is designed to focus on comparing values, not keys.

All SpahQL queries are read-only; there is no semantic for updating the state.

Furthermore, SpahQL uses set arithmetic for comparisons. Rather than providing an imperative syntax with complex logic, SpahQL provides set operations that allow you to build the equivalent of the AND and OR operations from other query languages using a safe, declarative syntax.

Selection queries

To select items from the state, use the basic syntax:

var results = Spah.state.select(myQuery);

The basic constituent of a selection query is a path selector. With an example object:

{
    myHash: {foo: "bar", bar: "baz", mySubHash: {foo: "bar", bar: "I'M IN A HASH"}},
    myArr: [0,1,2],
    myPrimitives: [true, false, null, "foo", 3]
}

It's easy to make selections based on a known path. To pull up the contents of the key "myHash" in the root, use the following syntax and remember that paths always begin with a slash.

results = Spah.state.select("/myHash");

Or to pull up the contents of the subhash:

results = Spah.state.select("/myHash/mySubHash");

Recursion is supported with a double-slash anywhere in the path:

// Find all entries with the key "foo"
results = Spah.state.select("//foo");

// Find all entries inside myHash with the key "foo"
results = Spah.state.select("/myHash//foo");

The results are always returned as a set of Spah.State.QueryResult objects, which each have properties path and value:

results = Spah.state.select("/myHash//foo"); //-> [QueryResult, QueryResult]
results[0].path //-> "/myHash/foo"
results[0].value //-> "bar"
results[1].path //-> "/myHash/mySubHash/foo"
results[1].value //-> "bar"

Keys in arrays are treated just like keys in hashes:

// Grab the first item in /myArr
results = Spah.state.select("/myArr/0")

You may also use * as a wildcard in paths:

// Grab ALL items in /myArr
results = Spah.state.select("/myArr/*")
results[0].path //-> "/myArr/0"
results[0].value //-> 0

Path queries are reductive. When executed, the first part of the query is run against the top level of the object being queried, and the remaining results are handed to the next part of the path to be reduced further. This loop continues until we're either out of results or out of path segments.

Using these reductive characteristics, we can perform advanced queries with filter queries. Filter queries are contained in square brackets, and can be used to further reduce the results before the query runner moves on to the next path segment.

// Grab /myHash, but only if it contains a key "foo" with any 
// non-false value:
results = Spah.state.select("/myHash[/foo]")

// Grab all items that contain a key "foo" with value "bar"
// This is an identical query to "//*[/foo == 'bar']"
results = Spah.state.select("//[/foo == 'bar']")

// Grab all root-level items that have the key "foo" with value "bar"
// either on themselves or on any of their descendants.
// This is an identical query to "/*[//foo == 'bar']"
results = Spah.state.select("/[//foo == 'bar']")

// Grab all items anywhere that have key "foo" with value "bar"
// either on themselves or on any of their descendants.
// This is an identical query to "//*[//foo == 'bar']"
results = Spah.state.select("//[//foo == 'bar']")

// Grab all items containing the value "bar" on themselves:
// This is an identical query to "//*[/* == 'bar']"
results = Spah.state.select("//[/* == 'bar']")

// Grab all items containing the value "bar" on themselves
// or any of their descendants.
// This is an identical query to "//*[//* == 'bar']"
results = Spah.state.select("//[//* == 'bar']")

There are a few things to note about the query inside the [] brackets. Firstly, it has access to the full query syntax. Secondly, it runs within the scope of the current path segment. Take this query for example:

// Returns no results - myArr is on the root, and the filter query 
// runs in the context of /myHash, which has no key "myArr".
results = Spah.state.select("/myHash[/myArr]")

If you want to lookup data on the root from inside a filter query, use the $ symbol to force a path to execute on the root context:

// Returns /myHash only if it contains a value with the same contents as 
// /myArr on the root.
results = Spah.state.select("/myHash[/* == $/myArr]")

I'm sure by now that you've noticed the filter queries contain comparison operators. Any query containing a comparison operator is automatically executed as an assertion query:

  • A selection query:

      /myHash
    
  • An assertion query:

      /myHash == /someOtherHash
    
  • A selection query containing an assertion query as a filter:

      /myHash[/foo == 'baz']
    
  • An assertion query containing two selection queries, one of which contains an assertion query as a filter:

      /myHash[/foo == 'baz'] == /myArr
    

Assertion queries

We've already seen how assertion queries can be used as filters in selection queries. Assertion queries are also used heavily in Spah's document logic features.

Assertion queries are run through the assert method on the state:

result = Spah.state.assert(myQuery) //-> true or false.

The assert method accepts both selection and assertion queries - if the given query is a selection query, the result will be true if the query returns one or more results with non-false (i.e. not false or null) values.

As discussed in core concepts, all comparisons in SpahQL use set arithmetic instead of traditional imperative logic and equality. SpahQL comparisons allow you to determine if:

  • One set is exactly equal to another set
  • One set contains the same values as another set
  • One set is a subset of another set
  • One set is a superset of another set

Assertion queries: Literals and sets

SpahQL does support literals - strings, integers, floats, true, false and null may all be used directly in SpahQL queries:

3 // the number 3
"foo" // the string "foo"
'foo' // the string "foo"
true // boolean true
false // boolean false
null // null

All comparisons in SpahQL are set comparisons. A query like /foo returns a set of results. A literal like 3 or "somestring" is considered to be a set containing a single entity. Literals may be wrapped in {} mustaches and seperated with commas to create in-place sets:

// a set containing the string "3" and the number 3.
{"3",3}

Sets may also be built from a combination of literals and queries:

// a set containing the string "3", all the results from the 
// query "/bar" and the string "foo".
{"3",/bar,"foo"}
// note that the set is flattened - the results from "/bar"
// are appended to the set in-place. If /bar returned values "a" and "b",
// this set would look like: {"3","a","b","foo"}

You may also create ranges:

{"a".."c"} // a set containing "a", "b" and "c"
{0..3} // a set containing 0, 1, 2 and 3.
{"a"..9} // COMPILER ERROR - sets must be composed of objects of the same type.
{"a"../foo/bar} // COMPILE ERROR - ranges do not support path lookup.

Comparison operators

SpahQL's set arithmetic uses simple operators:

  • Set equality ==

    Asserts that both the left-hand and right-hand sets have a 1:1 relationship between their values. The values may come from different paths in the state, or be literals. The values do not have to be in the same order. Each value has its equality checked based on its type. See Object equality.

      // Always true:
      Spah.state.assert("1 == 1"); // integers
      Spah.state.assert("'foo' == 'foo'"); // strings
      Spah.state.assert("true"); // boolean
      Spah.state.assert("/myHash == /myHash"); // queries
      Spah.state.assert("{1,2,3} == {1..3}"); // set literals
      
      // Always false:
      Spah.state.assert("1 == '1'"); // mixed types
      Spah.state.assert("true == false"); // boolean comparison
      Spah.state.assert("false"); // boolean
      Spah.state.assert("/myHash == /myArr"); // queries
      Spah.state.assert("{1,2,3} == {'a'..'z'}"); // set literals
    
  • Set inequality !=

    Asserts that the sets are not identical under the rules of the == operator.

  • Subset of }<{

Asserts that the left-hand set is a subset of the right-hand set. All values present in the left-hand set must have a matching counterpart in the right-hand set.

  • Superset of }>{

    Asserts that the left-hand set is a superset of the right-hand set. All values present in the right-hand set must have a matching counterpart in the left-hand set.

  • Joint set }~{

    Asserts that the left-hand set contains one or more values that are also present in the right-hand set.

  • Disjoint set }!{

    Asserts that the left-hand set contains no values that are also present in the right-hand set.

  • Rough equality ~=

    Asserts that one or more values from the left-hand set are roughly equal to one or more values from the right-hand set. See Object equality.

  • Greater than (or equal to) >= and >

    Asserts that one or more values from the left-hand set is greater than (or equal to) one or more values from the right-hand set.

  • Less than (or equal to) <``= and <

    Asserts that one or more values from the left-hand set is less than (or equal to) one or more values from the right-hand set.

Object equality

The equality of objects is calculated based on their type. Firstly, for two objects to be equal under strict equality (==) they must have the same base type.

  • Object equality: The objects being compared must contain the same set of keys, and the value of each key must be the same in each object. If the value is an object or an array, it will be evaluated recursively.
  • Array equality: The arrays must each contain the same values in the same order. If any value is an array or object, it will be evaluated recursively.
  • Number, String, Bool, null: The objects must be of equal type and value.

Under rough equality (~=) the rules are altered:

  • Strings are evaluated to determine if the left-hand value matches the right-hand value, evaluating the right-hand value as a regular expression e.g. "bar" ~= "^b" returns true but "bar" ~= "^a" returns false
  • Numbers are evaluated with integer accuracy only.
  • Arrays and Objects behave as if compared with the superset operator.
  • Booleans and null are evaluated based on truthiness rather than exact equality. false ~= null is true but true ~= false is false.

When using inequality operators <, =<, >, >=:

  • Strings are evaluated based on alphanumeric sorting. "a" <= "b" returns true but "z" >= "a" returns false.
  • Numbers are evaluated, as you'd expect, based on their native values.
  • Arrays, Objects, Booleans, null are not compatible with these operators and will automatically result in false being returned.

Properties

Properties are like imaginary paths on objects in your state. Each property uses the .propertyName syntax and may be used in any path query:

  • .type Returns the object type as 'Object', 'Array', 'String', 'Number', 'Boolean' or 'null'

      results = Spah.state.select("/myArr/.type"); //-> [QueryResult]
      results[0].path //-> "/myArr/.type"
      results[0].value //-> 'Array'
      
      // Find all arrays everywhere. 
      results = Spah.state.select("//[/.type == 'Array']")
      results[0].path //-> "/myArr"
      results[1].path //-> "/myPrimitives"
    
  • .size Returns the object's size if it is a String (number of characters), Array (number of items) or Object (number of keys)

      // Number of keys in the root
      results = Spah.state.select("/.size");
      results[0].path //-> "/.size"
      results[0].value //-> 3
      
      // Find all strings longer than 3 characters
      results = Spah.state.select("//[/.type == 'String'][/.size > 3]")
      results[0].path //-> "/myHash/mySubHash/bar"
    
  • .explode Returns the object broken into a set that may be compared to other sets. Strings are exploded into a set of characters. Arrays and objects are exploded into sets of their values without keys.

      // Does an array contain all of these values?
      results = Spah.state.assert("/myArr/.explode }>{ {1,2,3}")
      // Does a string contain all these characters?
      results = Spah.state.assert("/myStr/.explode }>{ {'a','b','c'}")
    

Document logic

In a Spah application, document logic (show this, hide that, populate this with that) is moved away from in-place Erb views and into a place where both the client and the server will have access to it - the document itself. In doing this, we want to avoid doing anything silly like adding new tags or attributes that break HTML validity, or dirtying up your markup with billions of extra nested div elements.

Spah handles document logic with HTML5 data attributes. Elements may query the state and declare how they should be altered when the query result is returned. When cold-booting your application from the server, the document logic is evaluated and run in-place before sending the HTML down the wire - this is what allows Spah to respond to HTML requests with valid, useful documents. On the client side, updates to the state cause any embedded document logic to be re-evaluated automatically. All document logic is achieved using State Queries. Conditions such as if statements use truthiness queries, as seen in the state query documentation.

Spah provides the following operations on document logic:

Hide or show an element

Show/hide of an element is achieved by appending display: none; to the element's style attribute if it should be hidden.

language:html

<form class="add-tags" data-show-if="/user-authenticated">
  This form will only show if the query "/user-authenticated" returns true
</form>

<form class="add-tags" data-show-unless="/user-anonymous">
  An alternative way of expressing a similar query using 
  the "data-*-unless" syntax. All conditions support both 
  the -if and -unless syntaxes.
</form>

Add or remove element classes

Use the data-class-[classname]-if or -unless attribute to specify that a specific class should be appended to the element's class attribute. language:html

<li class="item" data-class-current-if="/items[0]/important">
  This list item will have the class "important" if the first item in the 
  array at "/items" has the "important" property
</li>

Set element ID

Use the data-id-[id]-if or -unless to specify that an element should be given a new id under certain circumstances. If the condition causes Spah to write the ID on an element, Spah will automatically remove the ID from any other elements that previously possessed it.

language:html

<li class="item" data-id-current_item-if="/items.last/id == 3">
  This list item will have the id "current_item" if the last object 
  in the items array has the property "id" with value 3.
</li>

Stash and unstash element content

Stashing content allows you to render semantically-null empty elements into your document, with their content stowed in a data attribute for later use. Stashing only occurs if there is no content already stashed on the element - thus stashed content may be used for state-toggling. See example.

language:html

<div  class="new-message-notification" 
      data-stash-unless="/messages[/unread]/.size > 0">

      You have new messages!
  
      (This text will be moved into the "data-stashed-content" attribute 
      on the containing DIV element if the user has no new messages. 
      This way, your markup won't contain a hidden, but erroneous, 
      new message notification.)
</div>

<!-- When the content is stashed, the element will look like this: -->
<div  class="new-message-notification" 
      data-stash-unless="/messages[/unread]/.size > 0" 
      data-stashed-content="You%20have%20new%20messages%21....">
</div>

Populate element from a template

Spah can use your shared Mustache templates to render state data into a containing element. On the client side, the Spah client will automatically add a jQuery responder to the path referenced in the data-populate-with attribute, ensuring that the populated content is updated when the state is modified.

language:html

<ul class="users" data-populate-if="/users/.length > 0" 
                  data-populate-with="/users" 
                  data-populate-template="/views/users/single">
                 
  This text will be replaced with the results of running the template contained
  in an element with ID "/views/users/single" through Mustache.js using data from
  the state at /users as the payload.
  
  If the data found at /users is an array, the array will be wrapped in an object 
  so that Mustache can make it available: {"items": [content, of, array]}
</ul>

Using multiple operations on a single element

All of the above operations may be combined on a single element. The order in which the operations will run is strictly defined in order of the type of operation. -if operations always run before -unless operations.

  1. data-show-if and data-show-unless
  2. data-class-[classname]-if, data-class-[classname]-unless
  3. data-id-[id]-if, data-id-[id]-unless
  4. data-stash-if, data-stash-unless
  5. data-populate-if, data-populate-unless

Advanced document logic example

Let's make an advanced example: We want a list of users to default to an "empty" state, becoming populated with users if there is a non-empty list of users in the state. If the user list in the state is emptied, then the "empty" state should be restored on the element

<ul class="users"
    data-class-empty-if="/users.length < 1">
    data-stash-if="/users.length > 0"
    data-populate-if="/users.length > 0"
    data-populate-with="/users"
    data-populate-template="/views/users/single">
    
    <li class="empty">
      No users found.
    </li>
</ul>

Because stash operations run before populate operations, the following chain of events will occur:

  1. The element will render in the "empty" state, with class "empty" applied.
  2. If the /users array is empty, it will remain in the empty state
  3. If the /users array becomes non-empty:
  4. The "No users found" element will be stashed
  5. The ul element will be populated using the template
  6. The "empty" class will be removed from the ul
  7. If the /users array reverts to an empty state:
  8. The "empty" class will be removed from the ul
  9. The "No users found" element will be unstashed into the ul, overwriting the rendered template content.

Templating with Mustache

You can still write your markup with Ruby

Responding to state changes with Javascript

Forcing links and forms to load synchronously

To prevent Spah from adding asynchronous behaviour, add the data-async="false" attribute to the link or form element:

language:html

<a href="/login" data-async="false">Log in</a>

<form action="/login" method="POST" data-async="false">
  ...
</form>

Default client behaviour

Eager client state changes

Example Rails application

Example Sinatra application

Appendix: SpahQL Expression spec

Syntax:

// ATOM_FOO = "foo" - token matches an exact string
// ATOM_BAR = /.../ - token matches a pattern
// TOKEN_FOOBAR = ATOM_FOO[,ATOM_BAR] - atom_foo optionally followed by atom bar
// TOKEN_FOOBARBAR = (ATOM_FOO[,ATOM_BAR])+ - one or more repetitions of the group (atom_foo optionally followed by atom bar)

State machine:

// The expression parser is assumed to be a tokenizer. We will define the following states:
STATE_NULL = null
STATE_PATH_QUERY = "path"
STATE_KEY_NAME = "key"
STATE_PROPERTY = "prop"
STATE_FILTER_QUERY = "filt"
STATE_STRING_LITERAL = "str"
STATE_NUMERIC_LITERAL = "num"
STATE_BOOLEAN_LITERAL = "bool"
STATE_SET_LITERAL = "set"

// We will also assume that the tokenizer maintains a state stack - every time
// a state is entered, it is pushed onto the STATE_STACK. When the state exits,
// the STATE_STACK is popped and control is returned to the previous state.

// The state machine begins in the null state. An empty state stack means you are
// in the null state.

Tokens and compiler behaviour:

Basic literals
Symbol name Symbol value Compiler behaviour
ATOM_QUOTE /("|')/ Enters STATE_STRING_LITERAL. In this state, all special atoms and tokens are ignored and simply appended to the current token until the closing quote is encountered. The string state may be entered at any point.
ATOM_ESCAPE "\" Only legal in STATE_STRING_LITERAL. Causes the compiler to treat the next character as an ordinary character within a string, appending it to the token without exiting the string state.
TOKEN_STRING_LITERAL /(ATOM_QUOTE).*($1)/ A complete string literal, wrapped in identical closing quotes. This token is legal in the null state and within the STATE_FILTER_QUERY and STATE_SET_LITERAL state.
TOKEN_NUMERIC_LITERAL /(\d)+?(\.(\d+))/ An integer or floating-point numeric literal. The value is ascertained using the runtime environment's parseFloat function. Legal in the null state and in the STATE_SET_LITERAL state. Encountering a numeric character in either of these states enters the STATE_NUMERIC_LITERAL state, which terminates upon encountering a non-numeric character or a second period character.
TOKEN_BOOLEAN_LITERAL /true|false/ An alias for any boolean literal.
TOKEN_PRIMITIVE_LITERAL (TOKEN_STRING_LITERAL|TOKEN_NUMERIC_LITERAL|TOKEN_BOOLEAN_LITERAL) An alias for any string or numeric literal.
Set literals
Symbol name Symbol value Compiler behaviour
ATOM_SET_START "{" Announces the start of a set literal. This is legal in the null state only. The parser discriminates between this character and any comparison operators that include this character. Enters the STATE_SET_LITERAL state.
ATOM_SET_END "}" Exits the STATE_SET_LITERAL state. Only valid in this state.
ATOM_SET_DELIMITER "," When encountered in the STATE_SET_LITERAL state, prepares the parser to append a new entry to the set. Only legal in the STATE_SET_LITERAL state. If the separator is immediately followed by ATOM_SET_END, a compiler error is thrown.
ATOM_SET_RANGE_DELIMITER ".." Declares a range between the preceding literal and the following literal. The preceding literal must be the first literal in the set, and the following literal must be the last literal in the set. Both literals must be of the same primitive type. This token does not alter the state, allowing the parser to remain in STATE_SET_LITERAL, but it is expected to set flags preventing the parser from breaking the above rules.
TOKEN_SET_RANGE_LITERAL_STRING ATOM_SET_START,TOKEN_STRING_LITERAL,ATOM_SET_RANGE_DELIMITER,TOKEN_STRING_LITERAL,ATOM_SET_END An alias for a valid string range e.g. {"a".."z"}
TOKEN_SET_RANGE_LITERAL_NUMERIC ATOM_SET_START,TOKEN_NUMERIC_LITERAL,ATOM_SET_RANGE_DELIMITER,TOKEN_NUMERIC_LITERAL,ATOM_SET_END
TOKEN_SET_LITERAL_MIXED ATOM_SET_START,((TOKEN_PRIMITIVE_LITERAL|TOKEN_SELECTION_QUERY)[,ATOM_SET_DELIMITER,(TOKEN_PRIMITIVE_LITERAL|TOKEN_SELECTION_QUERY)]*),ATOM_SET_END
TOKEN_SET_LITERAL TOKEN_SET_RANGE_LITERAL_STRING|TOKEN_SET_RANGE_LITERAL_NUMERIC|TOKEN_SET_LITERAL_MIXED An alias for any valid set literal
Path queries
Symbol name Symbol value Compiler behaviour
ATOM_PATH_DELIMITER /\/{1,2}/ Describes a valid join between two paths. One slash instructs the runner to execute this part of the query at the top-level of the scope object only, while two slashes instructs the runner to recurse down the scope to find all matching keys. When encountered while the parser is already in STATE_PATH_QUERY, the atom instructs the parser to close the current path fragment and to begin a new one.
ATOM_PATH_ROOT "$" Forces the subsequent path query to act on the root context, regardless of the current query's execution scope. Must be followed by ATOM_PATH_DELIMITER or else a compiler error will be thrown.
TOKEN_PATH_START [ATOM_PATH_ROOT,]ATOM_PATH_DELIMITER Describes a valid opener for a path query. Encountering this atom whilst in the null or STATE_SET_LITERAL states will throw the parser into the STATE_PATH_QUERY state.
TOKEN_PATH_KEYNAME /\*|[a-zA-Z0-9-_]*/ A wildcard * or an optional alphanumeric path. Underscores and dashes are also supported in key names.
ATOM_FILTER_QUERY_START "[" Instructs the parser to enter the STATE_FILTER_QUERY state within the current path fragment. During the STATE_FILTER_QUERY state, the parser reads ahead until the matching ATOM_FILTER_QUERY_END bracket is found. The collected token is then parsed as a separate query and attached to the current path fragment. The tokenizer should then resume at the character immediately following the ATOM_FILTER_QUERY_END token. **Note this means that special atoms and tokens encountered in the STATE_FILTER_QUERY state are ignored by the current parser context.**
ATOM_FILTER_QUERY_END "]" Causes the parser to exit the STATE_FILTER_QUERY state and return control to the previous state. Only allowed within STATE_FILTER_QUERY. Encountering this token in any other state (except STATE_STRING_LITERAL) should cause a compiler error.
TOKEN_PATH_PROPERTY /\.\w+/ Instructs the parser to attach a property modifier to the current path component. Only allowable in STATE_PATH_QUERY.
TOKEN_FILTER_QUERY ATOM_FILTER_QUERY_START,TOKEN_ASSERTION_QUERY,ATOM_FILTER_QUERY_END An alias for any valid filter query token.
TOKEN_PATH_COMPONENT (TOKEN_PATH_PROPERTY|[TOKEN_PATH_KEY][,TOKEN_FILTER_QUERY]*) An alias for any valid path component. A property, or a key followed by any number of filter queries.
TOKEN_SELECTION_QUERY TOKEN_PATH_START[,TOKEN_PATH_COMPONENT[,ATOM_PATH_DELIMITER,TOKEN_PATH_COMPONENT]+]* An alias for any valid selection query. A path opener, followed by any number of path components separated with the ATOM_PATH_DELIMITER.
Comparisons and assertions
Symbol name Symbol value Compiler behaviour
TOKEN_SET_COMPARISON_OPERATOR /(\}[!><]{1}\{)|([<>~!=]=)/ Any of the set comparison operators detailed in [Comparison operators][comparison_operators]. Only allowable in the null state and the STATE_PATH_QUERY state. Encountering this token in the STATE_PATH_QUERY state will cause the path query to exit.
TOKEN_ASSERTION_QUERY (TOKEN_SELECTION_QUERY|TOKEN_SET_LITERAL)[, TOKEN_COMPARISON_OPERATOR, (TOKEN_SELECTION_QUERY|TOKEN_SET_LITERAL)] An alias for any valid assertion query. At least one selection query or set literal optionally followed by a comparison operator and another set or selection query.

Appendix: SpahQL query execution spec

SpahQL selection queries are, fundamentally, reductive. At the start of execution, a selection query is given the root data context against which it will run. As the execution moves between the path segments, the data is reduced (and possibly forked) before being passed to the next path segment:

data = {foo: {bar: {baz: "str"}}}
query = "/foo/bar/baz"

At each point in the above query:

  1. The root data object is handed to the first path component, which selects the key foo.
  2. The resulting data {bar: {baz: "str"}} is handed to the next path component which selects the key bar
  3. The resulting data {baz: "str"} is handed to the final path segment, which selects the key baz
  4. The key "baz" is a string with value "str". This is returned as a result set with one item.

If at any point a query runs out of data, the execution is aborted and an empty result set is returned:

data = {foo: {bar: {baz: "str"}}}
query = "/foo/NOTBAR/baz"

In this case, the query exits and returns [] when it is unable to find any matching data for the NOTBAR portion of the query.

Recursive paths force the query runner to fork the execution:

data = {foo: {bar: {baz: "str", bar: "inner-bar"}}}
query = "/foo//bar/baz"

In this instance:

  1. The root data object is handed to the first path component, which selects the key foo.
  2. The remaining data {bar: {baz: "str", bar: "inner-bar"}} is handed to the next path query, which recursively searches for the key bar.
  3. The recursive search returns results from two paths: /foo/bar, which contains a hash, and /foo/bar/bar which is a value within a sub-hash.
  4. The two result sets are handed down to the baz portion of the query.
  5. The baz key appears in only one of the previous data constructs, and this result is added to the final result set.

And so we can see that the overall progression is:

data -> reduce -> array of result sets -> reduce -> array of result sets -> reduce -> finalise

The finalisation step flattens the returned resultsets as a set of QueryResult objects. The final result set is a union of each of the final result sets made unique by result path.

In the case of filters, an additional reduce step is introduced into the path segment specifying the filter:

data = {foo: {bar: {baz: "str", bar: "inner-bar"}}}
query = "/foo/[//baz == 'str']"

In this case:

  1. The root data object is handed to the first path segment, which retrieves the key foo.
  2. The resulting data is handed to the next path segment, which specifies no key - therefore all keys are acceptable.
  3. All keys in the resulting data have the filter query //baz =='str' run against their values. Those keys for which the filter query returns true are added to the result set for this path segment.
  4. The query ends - the results (all values defined directly on /foo that may be recursed to find a key baz with value str) are flattened and returned as the query result.

Example execution flow:

Properties act like special keys on paths:

data = {foo: {bar: {baz: "str", bar: "inner-bar"}}}
query = "/.size" // returns the number of keys on the root object
query = "//baz/.size" // returns the sizes of all keys named "baz"

There is no other special behaviour for properties - they simply act like key names.

TODO: comparisons

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment