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.
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
| Judgment | Meaning | Action |
|---|---|---|
SUPPORTED | The language read it and it was fully loaded into a common expression | None |
UNSUPPORTED | It was read, but the construction is outside the scope of the language. Named as a token | Rewrite by removing that construction |
UNPARSEABLE | The grammar could not read it. Line/column position is provided | Find the typo |
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
CASE without ELSEThis 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 rejectionWhen 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
NOT is the meaningIt 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
| Alignment | Meaning |
|---|---|
ALIGN TO CALENDAR | Aligns to the calendar grid. A time zone can be specified |
ALIGN TO FIRST EVENT | The 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.
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
| View | Meaning |
|---|---|
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 keyIf 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
)
| Position | What |
|---|---|
PARTITION BY | Match separately per key |
MEASURES | Values to extract from match |
PATTERN | Row pattern — Sequence (Concatenation) · | (Alternation) · Quantifiers * + ? {n} {n,m} |
DEFINE | Conditions for each variable |
AFTER MATCH SKIP | Where 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.
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]
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.
forWITHIN 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>.
| Function | Category | Rule |
|---|---|---|
count(*) · count(x) | Aggregation | count(x) does not count nulls. Never null (0) |
sum avg min max | Aggregation | Ignore nulls; if all are null, return null |
first(x) · last(x) | Aggregation | First · Last non-null value in window |
stddev(x) | Aggregation | Sample standard deviation (n-1). Reject non-numeric arguments |
median(x) | Aggregation | Median. Reject non-numeric arguments |
coalesce(a, b, …) | Scalar | First non-null. Type is widened only — reject mixed numeric/non-numeric |
cast(x, <type>) | Scalar | Comma format only. Null → Null |
to_int to_long to_float to_double | Scalar | String → Numeric |
concat(a, b, …) · a || b | Scalar | Variable arguments. Null if any argument is null |
current_timestamp() | Time | Processing time (epoch ms) |
prev(n, x) · prior(n, x) | Window Position | n-th preceding event in window |
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
| Item | Why |
|---|---|
| Implement all languages of other CEP products | This is not a compatibility project |
| Templating (Template + Parameters) | Forcing types into logic creates problems later |
| Per-sentence backend routing | One per boot. Routing was the source of double-publishing defects |
| Backend fallback | Fallback blurs «Which engine produced this result?» |
CREATE STREAM-style DDL | The 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 · RETRACT | EQL streams are append-only |
ALERT · EMIT clauses | Alarms 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
| Feature | Status |
|---|---|
CONTEXT | UNPARSEABLE |
| Named Window · Variable · Subquery | UNPARSEABLE |
every-distinct | UNPARSEABLE |
percentile · to_bigdecimal | UNSUPPORTED / function:<name> |
WATERMARK · Allowed Lateness | None — late events are summed into open buckets |
WINDOW SLIDING (크기, 슬라이드) (HOP) | Blocked as a syntax error |
SESSION Window | Outside 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.
Related Documents
- Backends — Where each construction runs
- Limits and Rejection Tokens
- Event Type Registration
- EQL — Event Query Language Help — General EQL usage in the console