Type System & Advanced
PlazaQL's type inference, chain ordering rules, expression language, structural joins, narrowing, and attribute access.
Type system
Section titled “Type system”PlazaQL has a fully inferred type system. You never declare types — the compiler tracks them through your chain and catches errors.
How types flow
Section titled “How types flow”search(node, amenity: "cafe") // → PointSet .around(500, point(38.9, -77.0)) // → PointSet (spatial filter preserves type) .centroid() // → PointSet (centroid of points = points) .limit(count: 10); // → PointSetsearch(amenity: "cafe") // → GeoSet (no type = mixed) .within(geometry: $berlin) // → GeoSet .buffer(50) // → PolygonSet (buffer always → polygons) .simplify(10); // → PolygonSetChain ordering
Section titled “Chain ordering”Methods are grouped into three ordering categories:
| Group | Methods | Rule |
|---|---|---|
| Freely orderable | spatial (.within(), .around(), .bbox(), …), .filter(), transforms (.buffer(), .simplify(), .centroid()), joins (.member_of(), .has_member()), computed columns (.elevation(), .distance(), …), output shape (.fields(), .precision(), …) | Can appear in any order relative to each other |
| Narrowing | .first(), .last(), .index() | Converts GeoSet → GeoElement; must come after spatial/join filters |
| Ordering | .sort(), .limit(), .offset() | Must come after all freely orderable methods |
| Output mode | .count(), .ids(), .tags(), .skel(), .geom() | Must be last in chain, max one |
| Aggregation | .sum(), .avg(), .min(), .max(), .group_by().count() | Terminal — must be last in chain |
Spatial filters, tag filters, transforms, computed columns, and output shape methods can be freely mixed and reordered. This means all of these are valid:
// Buffer before spatial filtersearch(amenity: "school") .buffer(50) .within(geometry: $city);
// Filter after transformsearch(node, amenity: "cafe") .buffer(50) .filter(cuisine: "italian");
// Computed column before transformsearch(node, amenity: "cafe") .within(geometry: $berlin) .elevation() .buffer(50);Additional constraints:
.limit()must come before.offset()
Error messages
Section titled “Error messages”PlazaQL gives precise, actionable errors with source locations:
error: .buffer() (transform) cannot follow .limit() (ordering) — ordering methods must come after all other methods --> query:3:5 | 3 | .limit(10).buffer(50) | ^^^^^^^^^^^ transform after ordering | = hint: move .buffer(50) before .limit(10)error: .count() is terminal — no methods can follow it --> query:4:5 | 4 | .count().limit(10) | ^^^^^^^^^ not allowed after terminal | = hint: remove .limit(10) or move it before .count()Keyword vs positional arguments
Section titled “Keyword vs positional arguments”All functions support both styles. You cannot mix them in a single call:
// Keyword (preferred — self-documenting).around(distance: 500, geometry: point(lat: 38.9, lng: -77.0))
// Positional (concise).around(500, point(38.9, -77.0))Expressions
Section titled “Expressions”PlazaQL has a full expression language for computing values from tags, element properties, and geometry. Expressions are used in .filter(), aggregation functions (.sum(), .avg()), and .group_by().
Tag access — t["key"]
Section titled “Tag access — t["key"]”Access any tag value with t["key"]. Tag values are always strings in OSM:
t["name"] // → "Starbucks"t["amenity"] // → "cafe"t["building:levels"] // → "5"t["capacity"] // → "200"Property accessors
Section titled “Property accessors”Built-in functions that return element metadata:
| Function | Returns | Example |
|---|---|---|
id() | OSM ID (integer) | id() > 1000000 |
type() | Element type string | type() == "way" |
lat() | Latitude (float, nodes only) | lat() > 48.0 |
lon() | Longitude (float, nodes only) | lon() < 2.5 |
Geometry functions
Section titled “Geometry functions”Compute geometric properties of each feature:
| Function | Returns | Description |
|---|---|---|
length() | meters (float) | Geometric length of a way. Returns 0 for nodes. |
area() | square meters (float) | Area of a closed way or relation. Returns 0 for open ways and nodes. |
is_closed() | boolean | Whether a way’s first and last nodes are the same. |
length() is always geometric length in meters. For string length, use size().
Type coercion — number()
Section titled “Type coercion — number()”Tag values are strings. Use number() to cast them for arithmetic:
number(t["capacity"]) // "200" → 200number(t["height"]) // "12.5" → 12.5number(t["lanes"]) // "3" → 3number() is required for arithmetic on tag values. Without it, t["capacity"] > 50 compares strings, not numbers.
is_number() tests whether a tag value can be parsed as a number (returns boolean):
// Only include elements where population is a valid number.filter(is_number(t["population"]))String functions
Section titled “String functions”| Function | Description | Example |
|---|---|---|
starts_with(str, prefix) | True if str starts with prefix | starts_with(t["name"], "St") |
ends_with(str, suffix) | True if str ends with suffix | ends_with(t["name"], "Park") |
str_contains(str, substr) | True if str contains substr | str_contains(t["cuisine"], "italian") |
size(str) | String length (characters) | size(t["name"]) > 3 |
str_contains() is used instead of contains() to avoid conflict with the spatial .contains() method. size() is used instead of length() to avoid conflict with geometric length().
Operators
Section titled “Operators”Arithmetic (requires number() for tag values):
| Operator | Description | Example |
|---|---|---|
+ | Addition | number(t["lanes"]) + 1 |
- | Subtraction | number(t["height"]) - 3 |
* | Multiplication | number(t["width"]) * number(t["length"]) |
/ | Division | area() / 1000 |
Comparison:
| Operator | Description | Example |
|---|---|---|
> | Greater than | number(t["capacity"]) > 50 |
< | Less than | length() < 100 |
>= | Greater or equal | number(t["height"]) >= 10 |
<= | Less or equal | area() <= 500 |
== | Equal | type() == "way" |
!= | Not equal | t["name"] != "Unknown" |
Logical:
| Operator | Description | Example |
|---|---|---|
&& | Logical AND | number(t["lanes"]) > 2 && length() > 500 |
|| | Logical OR | t["amenity"] == "cafe" || t["amenity"] == "restaurant" |
! | Logical NOT | !is_closed() |
Operator precedence (highest to lowest)
Section titled “Operator precedence (highest to lowest)”!(unary NOT)*,/+,->,<,>=,<===,!=&&||
Use parentheses to override: (a || b) && c.
Expression examples
Section titled “Expression examples”// Roads longer than 1km with more than 2 lanessearch(way, highway: *) .within(geometry: $city) .filter(length() > 1000 && number(t["lanes"]) > 2);
// Large buildings (area over 1000 sq meters)search(way, building: *) .bbox(40.7, -74.0, 40.8, -73.9) .filter(area() > 1000);
// Closed ways that aren't tagged as buildingssearch(way) .within(geometry: $area) .filter(is_closed() && t["building"] == !*);
// Restaurants with valid capacity over 100search(amenity: "restaurant") .within(geometry: $city) .filter(is_number(t["capacity"]) && number(t["capacity"]) > 100);
// Total lane-kilometers of roads in a citysearch(way, highway: *) .within(geometry: $city) .filter(is_number(t["lanes"])) .sum(number(t["lanes"]) * length() / 1000);Structural joins
Section titled “Structural joins”Structural joins let you navigate the membership graph — relations contain members (nodes, ways, other relations), and ways contain nodes. Two methods express this relationship from opposite directions.
.member_of() — filter to members of a source
Section titled “.member_of() — filter to members of a source”Keep only elements that are members of the given source set. The first argument is a variable reference or an inline search() expression. Use the optional role: keyword argument to filter by membership role.
// Bus stops that belong to route 42$$ = search(node, highway: "bus_stop") .member_of(search(relation, route: "bus", ref: "42"), role: "stop");.member_of() works with relations (which have typed members) and ways (which contain ordered nodes).
.has_member() — filter to elements containing a source
Section titled “.has_member() — filter to elements containing a source”The reverse of .member_of(). Keep only elements that contain the given source elements as members.
// All subway lines that pass through a specific station$my_stop = search(node, railway: "station") .around(distance: 500, lat: 40.75, lng: -73.99).first();
$$ = search(relation, route: "subway").has_member($my_stop);Narrowing
Section titled “Narrowing”Narrowing methods transform a set into a single element (GeoSet → GeoElement). This distinction matters for the type system — see Attribute access below.
.first() — first element
Section titled “.first() — first element”$stop = search(node, highway: "bus_stop") .around(distance: 500, lat: 40.75, lng: -73.99).first();.last() — last element
Section titled “.last() — last element”$end = search(node, highway: "bus_stop") .within(geometry: $route).last();.index(n) — nth element (1-indexed)
Section titled “.index(n) — nth element (1-indexed)”$second = search(node, amenity: "cafe") .around(distance: 1000, geometry: point(48.85, 2.35)).index(2);Attribute access
Section titled “Attribute access”Extract tag values from variables using bracket syntax: $var[attr].
// Get the ref tag from $stop$stop[ref]
// Use as a filter valuesearch(node, highway: "bus_stop", ref: $stop[ref]);The type of the variable determines how the attribute value is used in SQL:
- On a
GeoElement(after.first(),.last(), or.index()): produces a scalar — exact match with=in SQL. - On a
GeoSet(no narrowing): produces a value set — multi-value match withANY(...)in SQL.
Example queries
Section titled “Example queries”// Bus stops on route 42$$ = search(node, highway: "bus_stop") .member_of(search(relation, route: "bus", ref: "42"), role: "stop");
// Subway transfer map — all stations on lines through a nearby stop$my_stop = search(node, railway: "station") .around(distance: 500, lat: 40.75, lng: -73.99).first();$lines = search(relation, route: "subway").has_member($my_stop);$$ = search(node, railway: "station").member_of($lines);
// Attribute join — all stops sharing the same ref as a nearby stop$stop = search(node, highway: "bus_stop") .around(distance: 500, lat: 40.75, lng: -73.99).first();$$ = search(node, highway: "bus_stop", ref: $stop[ref]) .bbox(40.7, -74.0, 40.8, -73.9);
// Cross-source — route ref matched against a custom dataset$route = search(relation, route: "bus").bbox(40.7, -74.0, 40.8, -73.9).first();$$ = search(point, dataset: "my_transit", route_ref: $route[ref]);Resources
Section titled “Resources”- Playground — Try PlazaQL interactively at plaza.fyi/playground
- API Reference — Full endpoint documentation in the API Reference
- GitHub — Language spec, grammars, and examples at github.com/plazafyi/plazaql