Skip to main content

EQL Language

EQL (PlantPulse CEP SQL) is the streaming query language for PlantPulse. It is based on SQL-92 and extended only with what is necessary for streaming.

All examples on this page are taken from the Golden Corpus (sentences where contract tests for the product have fixed the answers). There are no invented examples.

Sentence Structure​

[ INSERT INTO <stream> ]
SELECT [DISTINCT] <식> [AS <별칭>] [, ...]
FROM <소스> [ <조인> ... ]
[ WHERE <condition> ]
[ GROUP BY <식> [, ...] ]
[ HAVING <condition> ]
[ OUTPUT LAST|FIRST|ALL|SNAPSHOT EVERY <기간> ]
[ ORDER BY <식> [ASC|DESC] [NULLS FIRST|NULLS LAST] [, ...] ]
[ LIMIT <n> [OFFSET <n>] ]

The order of clauses is fixed. It is case-insensitive.

«Unit is required» for time periods

Written as 1 sec · 30 min · 10 SECONDS. The leading number is not read at all.

There is a scar in this rule — there was a case where a number without a unit was read as another unit, turning a 30-second window into an 8-hour window.

There are three types of judgments​

JudgmentMeaningAction
SUPPORTEDThe language read it and it was fully loaded into a common expressionNone
UNSUPPORTEDIt was read, but the construction is outside the scope of the language. Named as a tokenRewrite by removing that construction
UNPARSEABLEThe grammar could not read it. Line/column position is providedFind the typo
«The language reads it» and «The engine runs it» are different questions

A sentence that is SUPPORTED can still be rejected by the booted backend. In that case, it is output as a token indicating which construction caused it → Limits and Rejection Tokens

Which backend accepts what is listed in the table in Backends.


SQL-92 Core

SELECT · WHERE · GROUP BY​

SELECT site_id, COUNT(*) AS c FROM Point.win:time_batch(1 sec) GROUP BY site_id
SELECT tag_id, value
FROM Point.win:time(1 sec)
WHERE tag_id LIKE '%.TEMP'

If there is an aggregation, it is calculated once over the content when the window closes; if there is no aggregation, one row is produced per event.

CASE · Arithmetic​

SELECT tag_id, latency,
CASE WHEN latency > 1000 THEN 'HIGH'
WHEN latency > 500 THEN 'WARN'
ELSE 'OK' END AS band
FROM Point.win:length(1)
SELECT alarm_config_id, tag_id, asset_id, priority,
off_timestamp - timestamp AS duration_ms
FROM Alarm.win:length(1)
WHERE is_on = 'N'
AND off_timestamp - timestamp > 86400000
Default backends reject CASE without ELSE

This is expr:case-without-else. If the sentence does not specify what to produce when a value remains, different engines produce different answers.

DISTINCT​

SELECT DISTINCT site_id, type FROM Point.win:time_batch(1 sec)
DISTINCT has conditions and one discrepancy that cannot be removed by rejection

When used with GROUP BY · HAVING · calculated projections · already attached windows, it is rejected by name.

And one remaining difference — deduplication in the default backend removes duplicates across the entire lifetime of the stream, while SQL's DISTINCT removes duplicates within the result set. In an infinite stream, these two are not the same.

ORDER BY · LIMIT · OFFSET​

SELECT id, count(*) AS c FROM Point.win:time_batch(1 sec)
GROUP BY id
ORDER BY c DESC
LIMIT 10 OFFSET 5

The position of nulls is also determined by the sentence — ORDER BY c DESC NULLS LAST.

JOIN​

SELECT p.id, o.name
FROM Point.win:length(10) AS p
INNER JOIN Other.win:length(10) AS o ON p.id = o.id

Use INNER JOIN and LEFT [OUTER] JOIN together with a ON predicate. Joins are folded from the left.

Unary NOT​

SELECT a FROM Point.win:length(10) WHERE NOT a = 1
The «position» of NOT is the meaning

It is above comparison and below AND. Therefore, NOT a = 1 AND b = 2 is (NOT a = 1) AND b = 2.

Both readings are parsed and select different events — therefore, this is maintained by tests, not comments.

NOT LIKE · NOT IN · NOT BETWEEN · IS NOT NULL are still individual operators, and the unary NOT does not take their NOT.

Negative Numbers​

Negative numbers are signed literals, not unary minuses. A unary minus would require determining priority relative to * and binary -, and since answers vary by engine, an error would change the meaning of -a * b rather than whether it is parsed.

Subtraction remains as is — a - 1 is subtraction.


CEP Extensions

WINDOW TUMBLING — Wall-clock aligned buckets​

SELECT id, count(*) AS c
FROM Point
WHERE quality = 0
WINDOW TUMBLING (1 min) ALIGN TO CALENDAR AT TIME ZONE 'Asia/Seoul'
GROUP BY id
SELECT count(*) AS c FROM Point WINDOW TUMBLING (30 sec) ALIGN TO FIRST EVENT
AlignmentMeaning
ALIGN TO CALENDARAligns to the calendar grid. A time zone can be specified
ALIGN TO FIRST EVENTThe first event is the start of the grid

Time zones are resolved in the order: as specified in the sentence → PP_TZ → rejection, and the registration response provides the actually used time zone and its source.

Reject if DST transition is not a multiple of bucket size

If the transition width cannot divide the bucket, the grid shifts. A silently shifted grid makes all counts plausibly incorrect.

WINDOW SLIDING — Recent «size», per event​

SELECT site_id, avg(latency) AS avg_latency
FROM Point
WINDOW SLIDING (5 min)
GROUP BY site_id

Views the recent interval based on event time and recalculates every time an event arrives.

View Chain — Legacy window notation​

SELECT id FROM Point(type = 'int').std:groupwin(id).win:time_batch(1 sec) GROUP BY id
ViewMeaning
win:time(<interval>)Recent n-hour interval (sliding)
win:length(<n>)Recent n counts (sliding)
win:time_batch(<interval>)Batch broken every n hours
win:length_batch(<n>)Batch closed when n counts are reached
win:ext_timed_batch(<시간필드>, <interval>)Use field within event as time
std:groupwin(<key>)Own window per key
std:unique(<key>)Only the most recent per key
std:lastevent() · std:firstevent()Last · First event

Filters belong to the source, and the view chain is attached afterward. The chain is a sequence, not a set.

std:groupwin and GROUP BY must use the same key

If they mismatch, the window is split one way and the aggregation is grouped another way — a «correct» answer is produced for a sentence no one would write. Therefore, it is rejected rather than silently picking one.

OUTPUT … EVERY — Output Rate​

SELECT current_timestamp() AS timestamp, opc_id,
COALESCE(MIN(latency), 0) AS min_latency,
COALESCE(MAX(latency), 0) AS max_latency,
COALESCE(AVG(latency), 0) AS avg_latency
FROM Point.std:groupwin(opc_id).win:time_batch(10 sec)
GROUP BY opc_id
OUTPUT LAST EVERY 60 SEC
SELECT line_id, AVG(oee) AS avg_oee, MIN(oee) AS worst_oee
FROM OEE.win:time_batch(1 min)
GROUP BY line_id
OUTPUT LAST EVERY 1 MIN

There are four policies: LAST · FIRST · ALL · SNAPSHOT. Rows created between ticks are held until the next tick — this is a rate limit, not a decoration.

INSERT INTO — Derived Streams​

INSERT INTO PointTimed
SELECT tag_id, AVG(latency) AS avg_latency
FROM Point.win:time_batch(1 min)
GROUP BY tag_id

Exports result rows to a named stream.


Row Patterns

MATCH_RECOGNIZE — SQL:2016 Standard​

SELECT tag, n FROM Point MATCH_RECOGNIZE (
PARTITION BY tag_id
MEASURES first(h.tag_id) AS tag, count(h.latency) AS n
AFTER MATCH SKIP TO NEXT ROW
PATTERN (h{3})
DEFINE h AS h.latency > 100
)
PositionWhat
PARTITION BYMatch separately per key
MEASURESValues to extract from match
PATTERNRow pattern — Sequence (Concatenation) · | (Alternation) · Quantifiers * + ? {n} {n,m}
DEFINEConditions for each variable
AFTER MATCH SKIPWhere to start searching for the next match

The ? at the end of a quantifier is reluctant (as short as possible).

pattern[...] — Legacy notation​

SELECT a.machineId AS machine, b.severity AS sev
FROM pattern [
every a=AlarmEvent(severity >= 3)
-> b=AlarmEvent(machineId = a.machineId, severity >= 4)
where timer:within(10 sec)
]

The ladder goes from the loose side: -> · or · and · until · every/not · Guard · Atom.

Having two surfaces is intentional and comes with a trade-off

MATCH_RECOGNIZE is the standard, and pattern[...] is the notation used by existing sentences. This means the same concept can be written with two notations, so which one to use depends on what the backend accepts — see the table in Backends.

Absence — 「Did not arrive」​

SELECT a.machineId AS machine
FROM pattern [every a=AlarmEvent -> not ClearEvent for 30 min]
SELECT 'gap' AS gap
FROM pattern [not AssetData(assetId = 'A-1') for 30 min]
Period is part of the operator — it cannot be removed

All other pattern operators end with «arrival», but absence has no arrival. Therefore, the sentence must specify when it is acceptable to conclude that something «did not arrive». Otherwise, the match may never end or end at a moment no one specified.

The grammar continues reading not without a period — and then rejects it by naming it pattern:not:unbounded. If it were removed from the grammar, it would become «there is a typo», but the parser read the sentence correctly.

Reason why the word is for

WITHIN already exists in this language as where timer:within(t) and has opposite polarity — a deadline that a match «must beat» versus a duration that non-arrival «must endure». Combining them into one word would make two sentences look identical when they fire on different sets of events, and that difference is invisible at runtime.


Functions

The language knows exactly eighteen functions. Any name not listed here is rejected as function:<name>.

FunctionCategoryRule
count(*) · count(x)Aggregationcount(x) does not count nulls. Never null (0)
sum avg min maxAggregationIgnore nulls; if all are null, return null
first(x) · last(x)AggregationFirst · Last non-null value in window
stddev(x)AggregationSample standard deviation (n-1). Reject non-numeric arguments
median(x)AggregationMedian. Reject non-numeric arguments
coalesce(a, b, …)ScalarFirst non-null. Type is widened only — reject mixed numeric/non-numeric
cast(x, <type>)ScalarComma format only. Null → Null
to_int to_long to_float to_doubleScalarString → Numeric
concat(a, b, …) · a || bScalarVariable arguments. Null if any argument is null
current_timestamp()TimeProcessing time (epoch ms)
prev(n, x) · prior(n, x)Window Positionn-th preceding event in window
One-line null rule for aggregations

Aggregations ignore nulls, and return null if nothing remains. The only exception is count, which returns 0.


Out of Scope

What we decided not to do​

ItemWhy
Implement all languages of other CEP productsThis is not a compatibility project
Templating (Template + Parameters)Forcing types into logic creates problems later
Per-sentence backend routingOne per boot. Routing was the source of double-publishing defects
Backend fallbackFallback blurs «Which engine produced this result?»
CREATE STREAM-style DDLThe source of truth for event types is API registration. Accepting DDL within sentences would create two sources of truth
Per-sentence EVENT TIME <열>Event time is determined by type registration. If clocks differ per sentence, two sentences of the same type cannot be compared
UPDATE · DELETE · RETRACTEQL streams are append-only
ALERT · EMIT clausesAlarms are «names of sinks», not clauses of the language — topics are created per sentence, and the side subscribing to the topic determines the alarm

What is not yet implemented​

FeatureStatus
CONTEXTUNPARSEABLE
Named Window · Variable · SubqueryUNPARSEABLE
every-distinctUNPARSEABLE
percentile · to_bigdecimalUNSUPPORTED / function:<name>
WATERMARK · Allowed LatenessNone — late events are summed into open buckets
WINDOW SLIDING (크기, 슬라이드) (HOP)Blocked as a syntax error
SESSION WindowOutside the migration set

🔴 Do not put «Not doing» and «Not yet» in the same column. The former requires seeking alternatives, while the latter can be requested.