Skip to content

wirecodex/SimpleQuery

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SimpleQuery

Alpha — v0.1.0. This module is in early testing. The API may change before a stable release. Feedback and bug reports are welcome.

A WireQL-powered query engine for ProcessWire that makes building API endpoints effortless. Execute explicit CRUD operations, apply collection modifiers, sanitize field output, and cross-reference query results — all in a single expressive request.

Features

  • Explicit Operations: read, create, update, delete, count — clear intent, no inference
  • Collection Modifiers: Sort, filter, slice, and shuffle WireArray results with chainable modifiers
  • Multiple Operations: Execute several operations in one request with automatic sequential execution
  • Cross-references: Reference data from previous operations using @alias.path notation
  • Smart Sanitizers: Built-in formatters and sanitizers applied per-field with argument support
  • Field Aliasing: Rename output fields on-the-fly with colon syntax
  • Nested Fields: Deep traversal of page relationships and nested data
  • Sub-filtering: Apply ProcessWire selectors and modifiers to nested WireArray fields
  • Pagination Support: Automatic summary + items response when results are paginated
  • Type-aware Resolution: Handles PageArray, WireData, Pageimages, Pagefiles, SelectableOptionArray, and stdClass
  • Security First: XSS protection with automatic entity encoding by default, plus field name sanitization
  • Warning System: Non-fatal issues reported in warnings[] without halting execution
  • Zero Dependencies: Pure ProcessWire, no external libraries required

Installation

Install SimpleQuery through ProcessWire Admin → Modules → Site → SimpleQuery.

The simplequery() and query() global functions are available automatically after installation.

Quick Access

// Via the shorthand function (recommended)
$result = simplequery()->execute($query);

// Via the global query() helper
$result = query()->execute($query);

// Via the ProcessWire API variable
$result = wire()->simplequery->execute($query);

// Via SimpleWire suite facade (if SimpleWire is installed)
$result = simplewire()->query()->execute($query);

Query Language (WireQL)

Root Structure

A query is a block of named operations. Each operation has an explicit keyword, a selector, and optional modifiers and props.

{
    alias: operation(selector)[modifiers] {
        props
    },
    alias: operation(selector)
}
Element Syntax Required
alias customers: No (defaults to operation name)
operation read, create, update, delete, count Yes
selector (template=customer, limit=10) Yes
modifiers [shuffle, slices=10] No
props { id, title, email } No

Syntax Components

Component Syntax Description
Alias alias: prop Custom output key name (colon separator)
Selector (key=value, key=value) ProcessWire selector string
Modifiers [func, func=arg, func=arg|arg] WireArray method calls
Props { field, field, field } Fields to capture
Sanitizers <func, func=arg, func=arg|arg> Output formatters applied to scalars
References @alias.field Cross-reference a previous operation result

Operations

READ

Fetch pages matching a selector and return their field values. Props and modifiers are optional.

{
    alias: read(selector)[modifiers] {
        props
    }
}

Simple read:

{
    customers: read(template=customer, limit=10) {
        id,
        title,
        email<email>
    }
}

Read a single page:

{
    customer: read(id=18283) {
        id,
        first_name,
        last_name,
        email<email>
    }
}

With modifiers:

{
    featured: read(template=product, limit=20)[shuffle, slices=5] {
        id,
        title,
        price<currency>
    }
}

No props — returns all scalar fields:

{
    customer: read(id=18283)
}

CREATE

Add a new page. The selector must contain template and parent. Props are the field values to set.

{
    alias: create(template=tpl, parent=/path/) {
        field = value,
        field = value
    }
}
{
    newCase: create(template=case, parent=/cases/) {
        title = 'Support request from John',
        body = 'The issue started yesterday evening.'
    }
}

Response:

{
    "success": true,
    "data": {
        "newCase": {
            "success": true,
            "id": 1042
        }
    }
}

UPDATE

Find a single page with get() and apply field assignments. Use a specific selector (e.g., id=N) — only the first matched page is updated.

{
    alias: update(selector) {
        field = value,
        field = value
    }
}
{
    updateCase: update(id=1042) {
        title = 'Support request — resolved',
        status = closed
    }
}

Response:

{
    "success": true,
    "data": {
        "updateCase": {
            "success": true,
            "id": 1042,
            "message": "Page updated successfully"
        }
    }
}

DELETE

Find all pages matching the selector and permanently delete each one. Returns a map of id → bool.

{
    alias: delete(selector)
}
{
    cleanup: delete(template=case, status=archived, modified<2024-01-01)
}

Response:

{
    "success": true,
    "data": {
        "cleanup": {
            "1038": true,
            "1039": true,
            "1041": false
        }
    }
}

COUNT

Return the number of pages matching a selector.

{
    alias: count(selector)
}
{
    openCases: count(template=case, status=open),
    totalCustomers: count(template=customer)
}

Response:

{
    "success": true,
    "data": {
        "openCases": 14,
        "totalCustomers": 382
    }
}

Multiple Operations in One Request

Operations execute sequentially in definition order. Each result is available to later operations via @alias references.

{
    customer: read(id=18283) {
        id,
        title,
        email<email>
    },
    orders: read(template=order, customer.id=@customer.id, limit=10, sort=-created) {
        id,
        total<currency>,
        status,
        date: created<date:Y-m-d>
    },
    openCount: count(template=order, customer.id=@customer.id, status=open)
}

Read Props

Simple Fields

{
    cases: read(template=case, limit=20) {
        id,
        title,
        body,
        status
    }
}

Field Aliasing

Use alias: fieldname to rename a field in the output.

{
    customers: read(template=customer, limit=10) {
        id,
        customerName: title,
        contactEmail: email<email>,
        companyName: company.title,
        joined: created<date:Y-m-d>
    }
}

Dot-notation

Access sub-properties of a related field directly.

{
    orders: read(template=order, limit=10) {
        id,
        thumbnail: image.url,
        companyName: company.title
    }
}

Nested Objects (WireData / Page fields)

{
    customer: read(id=18283) {
        id,
        title,
        company {
            name: title,
            industry,
            address {
                street,
                city,
                state<uppercase>
            }
        }
    }
}

Nested WireArray with Sub-selector and Modifiers

Props on WireArray fields support their own (selector) and [modifiers].

{
    customer: read(id=18283) {
        title,
        recentOrders: orders (status=open)[sort=-created, slices=5] {
            id,
            total<currency>,
            created<date:M d, Y>
        },
        certificates (status=active)[sort=expires] {
            title,
            number,
            expires<date:m/d/Y>
        }
    }
}

Modifiers

Modifiers are applied to WireArray results after the selector fetch. They are chainable and applied left-to-right.

[modifier, modifier=arg, modifier=arg|arg]
Modifier Maps to Description
first first() Return first item
last last() Return last item
pop pop() Remove and return last item
shift shift() Remove and return first item
reverse reverse() Reverse the order
shuffle shuffle() Randomize order
sort=field sort($field) Sort by field (prefix - for descending)
slice=start slice($start) Items from position N to end
slices=qty slices($qty) First N items
eq=index eq($index) Item at position N

Examples:

// Shuffle and return 5 random items
[shuffle, slices=5]

// Sort descending, skip first 10
[sort=-created, slice=10]

// Get item at position 3
[eq=3]

Note: Modifiers that return a single item (first, last, pop, shift, eq) wrap the result in a one-item array so the response structure stays consistent. Subsequent modifiers after a single-item result are skipped with a warning.


Sanitizers

Sanitizers format scalar field values. They are applied inside <...> after a field name, as a comma-separated list. Multiple sanitizers are applied left-to-right.

field<sanitizer>
field<sanitizer=arg>
field<sanitizer=arg|arg>
field<sanitizer, sanitizer=arg>

Available Sanitizers

Sanitizer Syntax Description
entities field<entities> HTML entity encoding (applied by default)
raw field<raw> Bypass all sanitization (use with caution)
string field<string> Cast to string
int / integer field<int> Cast to integer
float field<float=2> Float with N decimal places
email field<email> Validate and sanitize email address
uppercase / upper field<uppercase> Convert to uppercase
lowercase / lower field<lowercase> Convert to lowercase
capitalize field<capitalize> Capitalize each word
truncate field<truncate=50> Limit to N characters
date field<date=Y-m-d> Format date/timestamp with PHP date format
currency / money field<currency> Format as currency (default: $1,234.56)
concat field<concat= USD> Append a string to the value

Currency arguments: <currency=€|3> — first arg is symbol, second is decimal places.

Sanitizer Examples

{
    orders: read(template=order, limit=5) {
        orderNumber<uppercase>,
        customerEmail<email>,
        total<currency>,
        totalEuro: total<currency=|2>,
        created<date=F d, Y>,
        notes<entities, truncate=100>,
        quantity<int>,
        discount<float=2>
    }
}

Default Entity Encoding

When no sanitizer is specified, all string values are automatically encoded with entities to prevent XSS. Use <raw> to opt out.

{
    posts: read(template=post, limit=10) {
        title,           // automatically sanitized with entities
        body<raw>        // raw HTML — only when you trust the source
    }
}

Cross-references

Operations execute sequentially. Use @alias.path to inject a value from any previously resolved operation into a selector or assignment value.

{
    profile: read(id=18283) {
        id,
        title,
        company {
            id
        }
    },
    orders: read(template=order, customer.id=@profile.id, limit=20) {
        id,
        total<currency>,
        status
    },
    companyOrders: read(template=order, company.id=@profile.company.id) {
        id,
        total<currency>
    }
}

If a reference cannot be resolved, it is replaced with an empty string and a warning is added. Unresolved references never halt execution.


Response Structure

Flat Response (no pagination)

{
    "success": true,
    "warnings": [],
    "data": {
        "customers": [
            { "id": 1, "title": "Jane Doe", "email": "jane@example.com" },
            { "id": 2, "title": "John Smith", "email": "john@example.com" }
        ]
    }
}

read() always returns an array of items, even when the result is a single page. This keeps the client-side contract predictable.

Paginated Response

When the selector includes limit=N and the total result exceeds that limit, the response wraps items in a summary + items structure.

{
    "success": true,
    "data": {
        "customers": {
            "summary": {
                "count": 50,
                "total": 247,
                "start": 0,
                "limit": 50,
                "selector": "template=customer, limit=50",
                "pagination": true,
                "previous": false,
                "next": true,
                "pager_count": "Page 1 of 5",
                "pager_items": "Items 1–50 of 247"
            },
            "items": [
                { "id": 1, "title": "Jane Doe" },
                ...
            ]
        }
    }
}

Pagination also applies to nested WireArray props when they have a limit and are paginated.

Response with Warnings

Individual warnings do not stop execution. They are collected and returned alongside the data.

{
    "success": true,
    "warnings": [
        {
            "field": "price",
            "message": "Selector/modifiers ignored on scalar value"
        }
    ],
    "data": {
        "products": [...]
    }
}

Error Response

Parse errors or validation failures return success: false.

{
    "success": false,
    "error": {
        "message": "Query must start with '{'",
        "code": "QUERY_ERROR"
    }
}
{
    "success": false,
    "error": {
        "message": "Template 'user' is protected and cannot be queried",
        "code": "VALIDATION_ERROR",
        "all": ["Template 'user' is protected and cannot be queried"]
    }
}

Special Field Types

Pageimages

Returns an array of image maps. Pass children props to filter returned keys.

{
    product: read(id=55) {
        title,
        images {
            url,
            width,
            height,
            description
        }
    }
}

Available keys: url, width, height, description, alt, basename.

Pagefiles

Returns an array of file maps. Same child-filtering as images.

{
    document: read(id=88) {
        title,
        attachments {
            url,
            basename,
            filesize,
            ext
        }
    }
}

Available keys: url, basename, description, filesize, ext.

SelectableOptionArray

Behavior adapts to single vs. multi-select.

Single selection → flat map (direct key access):

{
    case: read(id=42) {
        status,            // → { "value": "open", "title": "Open" }
        priority { value } // → { "value": "high" }
    }
}

Multiple selections → array of maps:

{
    product: read(id=55) {
        tags // → [{ "value": "sale", "title": "On Sale" }, { "value": "new", "title": "New" }]
    }
}

Default keys returned: value and title. Request id explicitly if needed: field { id, value, title }.

User fields

Without explicit children props, returns id, name, email. Add children to select specific props.

{
    case: read(id=42) {
        title,
        assignee {
            id,
            name,
            email
        }
    }
}

API Endpoint Template

<?php
// /site/templates/api.php
namespace ProcessWire;

// Handle API requests — reads POST body / query param automatically
simplequery()->handleApiRequest();

// Or with manual handling:
$body = file_get_contents('php://input');
$data = json_decode($body, true);
$query = $data['query'] ?? wire('input')->post('query');

if (empty($query)) {
    header('Content-Type: application/json');
    echo json_encode(['success' => false, 'error' => ['message' => 'No query provided']]);
    exit;
}

$result = simplequery()->execute($query);
header('Content-Type: application/json');
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

Request Structure

HTTP Method

Always use POST. GET has URL length limitations that break complex queries, and POST is standard for APIs that may write data.

Content Type

Send Content-Type: application/json with a JSON body.

{
    "query": "{ customers: read(template=customer, limit=10) { id, title, email } }"
}

PHP Examples

$customerId = (int) $input->get('id');

$query = "
{
    customer: read(id=$customerId) {
        id,
        fullName: title,
        email<email>,
        company {
            name: title
        }
    },
    orders: read(template=order, customer.id=@customer.id, limit=10, sort=-created) {
        id,
        total<currency>,
        status,
        date: created<date:M d, Y>
    }
}
";

$response = client('https://domain.com/api')
    ->post('/', ['query' => $query]);

$result = $response->json();

if ($result['success']) {
    $customer = $result['data']['customer'][0];
    $orders   = $result['data']['orders'];
    // or, if orders are paginated:
    // $summary = $result['data']['orders']['summary'];
    // $orders  = $result['data']['orders']['items'];
}

JavaScript — Fetch

const query = `
{
    products: read(template=product, limit=20) {
        id,
        title,
        price<currency>,
        thumbnail: image.url
    }
}
`;

fetch('https://domain.com/api', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query })
})
.then(r => r.json())
.then(result => {
    if (result.success) {
        const products = result.data.products;
        // Check for pagination
        const items = products.items ?? products;
    }
});

JavaScript — Axios

const { data: result } = await axios.post('https://domain.com/api', { query });

if (result.success) {
    result.data.products.forEach(p => console.log(p.title, p.price));
}

cURL

curl -X POST https://domain.com/api \
  -H "Content-Type: application/json" \
  -d '{"query": "{ total: count(template=customer) }"}'

Security

Default XSS Protection

All scalar field values are automatically sanitized with entities unless you specify a sanitizer. The first explicit sanitizer you provide replaces the default — add entities to the chain if you still want it alongside other sanitizers.

{
    comments: read(template=comment, limit=20) {
        author,             // → entities applied automatically
        text,               // → entities applied automatically
        body<raw>,          // → no sanitization — only use with trusted content
        excerpt<truncate=200> // → truncate only, entities not applied
    }
}

Field Name Sanitization

All field names in prop lists are sanitized with $sanitizer->fieldName() before being used to access page properties. This strips any characters that are invalid in ProcessWire field names.

Template Access Control

Configure allowed and protected templates in the module settings, or validate in code:

$validation = simplequery()->validate($query);

if (!$validation['valid']) {
    http_response_code(400);
    echo json_encode(['success' => false, 'errors' => $validation['errors']]);
    exit;
}

Module Configuration

Navigate to Modules → Site → SimpleQuery → Configure to set global defaults.

Security Settings

Setting Default Description
Protected Templates [] Templates that cannot be queried (blacklist)
Allowed Templates [] Only these templates can be queried (whitelist)
Enable Write Operations true Allow create, update, delete

Query Limits

Setting Default Description
Max Depth 5 Maximum nesting depth for props
Max Fields 100 Maximum total props per request
Default Limit 50 Limit added when selector has none
Max Limit 1000 Cap for any explicit limit=N
Max Execution Time 5 Seconds before timeout

Feature Settings

Setting Default Description
Always Apply Entities true Auto entity-encode all scalar strings
Enable Cross-references true Allow @alias.path references
Enable Query Cache true Cache read results
Query Cache Expire 3600 Cache TTL in seconds
Enable Rate Limit true Per-IP rate limiting
Max Queries Per Hour 1000 Rate limit ceiling
Enable Cache Headers true Send Cache-Control and ETag headers

Per-request Overrides

$result = simplequery()->execute($query, [
    'maxDepth'              => 3,
    'maxFields'             => 50,
    'enableWriteOperations' => false,
    'alwaysEntitiesTransform' => false,
]);

Configuration Profiles

Public read-only API:

Allowed Templates: product, category, article
Enable Write Operations: ✗
Max Depth: 4 | Default Limit: 20 | Max Limit: 100

Internal admin API:

Protected Templates: user, role, permission
Enable Write Operations: ✓
Max Depth: 6 | Default Limit: 50 | Max Limit: 2000

API Reference

Query (facade)

Method Returns Description
execute(string $query, array $options = []) array Parse, validate, and run a query
executeJson(string $query, bool $pretty = false) string Execute and return JSON
parse(string $query) array Parse into AST without executing
validate(string $query) array Parse and validate without executing
handleApiRequest(?string $fallback = null) void HTTP endpoint helper (reads POST body, writes JSON response, exits)
getParser() QueryParser Access the parser instance
getProcessor() QueryProcessor Access the processor instance
getDefaults() array Default configuration values

QueryParser

Method Returns Description
parse(string $query) array Parse WireQL string into AST
getWarnings() array Warnings from the last parse call

QueryProcessor

Method Returns Description
execute(array $ast) array Execute a parsed AST
setConfig(array $config) void Replace processor config
getConfig() array Return current config

Advanced Examples

Customer Portal

{
    profile: read(id=18283) {
        id,
        fullName: title,
        email<email>,
        phone,
        status,
        company {
            name: title,
            industry,
            address {
                street,
                city,
                state<uppercase>,
                zip
            }
        }
    },
    orders: read(template=order, customer.id=@profile.id, limit=20, sort=-created) {
        id,
        orderNumber<uppercase>,
        date: created<date:M d, Y>,
        total<currency>,
        status,
        items {
            product {
                name: title<truncate=50>,
                sku<uppercase>
            },
            quantity<int>,
            price<currency>
        }
    },
    openTickets: count(template=ticket, customer.id=@profile.id, status=open)
}

Product Catalog with Pagination

{
    products: read(template=product, category=electronics, limit=24, start=0, sort=title) {
        id,
        title<truncate=60>,
        slug: name,
        price<currency>,
        salePrice: sale_price<currency>,
        thumbnail: image.url,
        rating<float=1>,
        stock<int>,
        category {
            name: title
        }
    },
    totalProducts: count(template=product, category=electronics)
}

Dashboard Stats

{
    newCustomers: count(template=customer, created>=1704067200),
    openOrders: count(template=order, status=open),
    recentOrders: read(template=order, limit=10, sort=-created) {
        id,
        customer { name: title },
        total<currency>,
        status,
        date: created<date:Y-m-d>
    },
    topProducts: read(template=product, limit=5, sort=-sales) {
        title<truncate=40>,
        sales<int>,
        revenue<currency>
    }
}

Create and Immediately Read

{
    newCase: create(template=case, parent=/cases/) {
        title = 'Issue with order #1042',
        priority = high,
        status = open
    },
    allOpen: read(template=case, status=open, limit=5, sort=-created) {
        id,
        title,
        priority,
        created<date:Y-m-d>
    }
}

Best Practices

Always Use Explicit Limits

// Good — explicit limit
{ cases: read(template=case, status=open, limit=50) { id, title } }

// Risky — relies on config defaultLimit
{ cases: read(template=case, status=open) { id, title } }

Sanitize Interpolated Values

$customerId = (int) $input->get('customer_id');
$limit      = min((int) $input->get('limit', 10), 100);
$search     = $sanitizer->text($input->get('q'));

$query = "
{
    results: read(template=customer, title~=$search, limit=$limit) {
        id,
        title,
        email
    }
}
";

Use Cross-references to Avoid Duplicate Queries

// Good
{
    customer: read(id=123) { id },
    orders: read(template=order, customer.id=@customer.id) { id, total }
}

// Wasteful — fetches customer twice
{
    customer: read(id=123) { id, title },
    orders: read(template=order, customer.id=123) { id, total }
}

Handle Paginated Responses on the Client

$data = $result['data']['products'];

// Check for paginated structure
if (isset($data['summary'])) {
    $summary  = $data['summary'];
    $products = $data['items'];
    echo "Showing {$summary['count']} of {$summary['total']}";
} else {
    $products = $data;
}

Log Warnings in Development

$result = simplequery()->execute($query);

if (!empty($result['warnings'])) {
    foreach ($result['warnings'] as $w) {
        wire('log')->warning("SimpleQuery [{$w['field']}]: {$w['message']}");
    }
}

Validate Before Executing in Critical Paths

$validation = simplequery()->validate($query);

if (!$validation['valid']) {
    return ['success' => false, 'errors' => $validation['errors']];
}

$result = simplequery()->execute($query);

Troubleshooting

Inspect the AST

$ast = simplequery()->parse($query);
bd($ast); // ProcessWire debug output

Validate Without Executing

$validation = simplequery()->validate($query);

foreach ($validation['errors'] as $error) {
    echo "Error: $error\n";
}
foreach ($validation['warnings'] as $w) {
    echo "Warning [{$w['field']}]: {$w['message']}\n";
}

Common Errors

Error Cause Fix
Query must start with '{' Missing opening brace Wrap query in { ... }
Unknown operation: 'name' Missing or misspelled operation keyword Use read, create, update, delete, or count
create() requires template and parent Incomplete selector Add template=X, parent=/path/ to the selector
Page not found update() selector matched nothing Check selector — update uses get() on first match
Template 'X' is protected Template in protected list Remove from protected list or use a different template
Limit capped to maximum allowed (N) Warning, not error The requested limit exceeded maxLimit config

License

This module is released under the MIT License.

About

GraphQL-like query engine for ProcessWire pages with caching, rate limiting, and write support

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages