SELSimple Expression Language
GitHub

Complex data, in memory

Some questions have no share a database can take. The support desk's SLA report below pulls incident numbers out of ticket subjects with a regular expression, finds each ticket's first answer by searching the event log, compares it with a target that depends on the customer's plan and the ticket's priority, and summarises per team. The planner finds no step of it SQL can answer — so the database's job is to hand over the tables, and SEL does the rest.

This page runs that report twice:

  • from PostgreSQL, where SQL loads the tables and SEL computes; and
  • with no database at all, over the same rows generated in memory by a SEL program.

Both print the same report.

The data, generated

The dataset is itself a SEL program: tickets-generate.sel builds teams, agents, customers on three plans, SLA targets per plan and priority, 48 tickets and 168 events, every "random" choice arithmetic on an id, so every host builds the same rows. tools/gen-usage-seed.mjs renders exactly this into the PostgreSQL seed, and the gate fails if the seed is stale.

sel
# A support desk, generated: teams, agents, customers on three plans, SLA
# targets per plan and priority, tickets and the replies and notes on them.
#
# Deterministic -- every "random" choice is arithmetic on an id -- so every
# host builds the same data, and tools/gen-usage-seed.mjs renders exactly this
# into examples/sql-complex/seed.postgresql.sql. examples/memory-complex runs
# the report over this program's result; examples/sql-complex runs it over the
# same rows loaded from PostgreSQL. Times are minutes from the start of the
# quarter.
#
# The result is one record: TEAMS, AGENTS, CUSTOMERS, SLA, TICKETS, EVENTS.

N_TICKETS = 48;
IDS = SPLIT(REPEAT(",", N_TICKETS - 1), ",") .> MAP(_K);
PLANS = LIST("free", "pro", "enterprise");
PRIORITIES = LIST("low", "normal", "urgent");
TOPICS = LIST("Invoice does not match the quote", "Login loops back to the start",
              "API calls time out after deploy", "CSV export never finishes",
              "Cannot add seats to the plan", "Webhook retries flood the endpoint");

TEAMS = LIST(RECORD("team_id", 1, "team", "Billing"),
             RECORD("team_id", 2, "team", "Platform"),
             RECORD("team_id", 3, "team", "Onboarding"));

AGENTS = LIST("Ada", "Borys", "Celia", "Dmitri", "Emre", "Fatima")
  .> MAP(RECORD("agent_id", _K, "team_id", _K % 3 + 1, "agent", _));

CUSTOMERS = LIST("Acme", "Borealis", "Cobalt", "Dunmore", "Eastlake", "Fjord",
                 "Granite", "Halcyon", "Ivory", "Juniper", "Kestrel", "Lumen")
  .> MAP(RECORD("customer_id", _K, "customer", _ & " Ltd", "plan", PLANS[_K * 5 % 3 + 1]));

SLA = LIST();
ALL(PLANS, P, ALL(PRIORITIES, Q, (
  SLA[COUNT(SLA) + 1] = RECORD(
    "plan", P, "priority", Q,
    "respond_within", COND(Q $== "urgent", 30, Q $== "normal", 240, 1440)
                      / COND(P $== "enterprise", 2, 1),
    "resolve_within", COND(Q $== "urgent", 480, Q $== "normal", 2880, 7200)
                      / COND(P $== "enterprise", 2, 1));
  TRUE)));

TICKETS = IDS .> MAP(I, RECORD(
  "ticket_id", I,
  "customer_id", I * 7 % 12 + 1,
  "team_id", I * 5 % 3 + 1,
  "priority", PRIORITIES[(I * I + 3 * I) % 3 + 1],
  "subject", IF(I % 4 == 0, "[INC-" & (4000 + I * 3) & "] ", "") & TOPICS[I * 11 % 6 + 1],
  "opened_at", I * 173 % 9000,
  "closed_at", IF(I % 7 == 0, NULL, I * 173 % 9000 + (I * 89 % 40 + 1) * 60)));

EVENTS = LIST();
ALL(TICKETS, T, (
  N = T["ticket_id"];
  ALL(SPLIT(REPEAT(",", N % 4 + 1), ","), (
    SEQ = _K;
    EVENTS[COUNT(EVENTS) + 1] = RECORD(
      "event_id", COUNT(EVENTS) + 1,
      "ticket_id", N,
      "seq", SEQ,
      "at", T["opened_at"] + SEQ * (N * 37 % 300 + 5) + (N * 13 % 7) * 60 * (SEQ - 1),
      "kind", IF((N + SEQ) % 5 == 0, "note", "reply"),
      "actor", IF(SEQ % 2 == 1 AND N % 7 != 3, "agent:" & ((N + T["team_id"]) % 6 + 1), "customer"));
    TRUE));
  TRUE));

RECORD("TEAMS", TEAMS, "AGENTS", AGENTS, "CUSTOMERS", CUSTOMERS, "SLA", SLA,
       "TICKETS", TICKETS, "EVENTS", EVENTS)

The report

tickets-report.sel:

sel
# The support desk's SLA report, per team: how many tickets, how many were
# answered late or not at all, how many are still open, the slowest first
# answer, and which incidents the team handled.
#
# It reads TEAMS, CUSTOMERS, SLA, TICKETS and EVENTS. examples/sql-complex runs
# it over rows loaded from PostgreSQL, examples/memory-complex over rows
# generated in memory; both print the same report. No step of it has SQL:
# the incident number is pulled out with RGROUPS, and every ticket's first
# answer is a search of the event log -- so the planner leaves it in memory
# whole, and the database's only job is to hand over the tables.

FIRST_ANSWERS = EVENTS
  .> FILTER(_["kind"] $== "reply" AND LEFT(_["actor"], 6) $== "agent:")
  .> SORT_BY(_["at"])
  .> BUCKET(_["ticket_id"], RECORD("of_ticket", _K, "at", _[1]["at"]));

TICKETS
  .> LINK(CUSTOMERS, T, C, T["customer_id"] == C["customer_id"])
  .> LINK(SLA, X, S, X["plan"] $== S["plan"] AND X["priority"] $== S["priority"])
  .> LINK_LEFT(FIRST_ANSWERS, Y, A, Y["ticket_id"] == A["of_ticket"])
  .> MAP(R, RECORD(
       "team_id", R["team_id"],
       "ticket", R["ticket_id"],
       "incident", IF(RMATCH('^\[INC-[0-9]+\]', R["subject"]),
                      RGROUPS('^\[(INC-[0-9]+)\]', R["subject"])["2"], ""),
       "answered_in", IF(IS_NULL(R["A"]["at"]), NULL, R["A"]["at"] - R["opened_at"]),
       "late", IS_NULL(R["A"]["at"]) OR R["A"]["at"] - R["opened_at"] > R["respond_within"],
       "open", IS_NULL(R["closed_at"])))
  .> BUCKET(_["team_id"], RECORD(
       "team_id", _K,
       "tickets", COUNT(_),
       "late", COUNT(FILTER(_, _["late"])),
       "open", COUNT(FILTER(_, _["open"])),
       "slowest", FILTER(_, IS_NOT_NULL(_["answered_in"]))
                    .> TOP_BY(_["answered_in"], "DESC", 1)
                    .> MAP("#" & _["ticket"] & " (" & _["answered_in"] & " min)") .> JOIN(""),
       "incidents", FILTER(_, _["incident"] $!= "") .> MAP(_["incident"]) .> JOIN(" ")))
  .> LINK(TEAMS, G, M, G["team_id"] == M["team_id"])
  .> MAP(RECORD("team", _["team"], "tickets", _["tickets"], "late", _["late"],
                "open", _["open"], "slowest", _["slowest"], "incidents", _["incidents"]))
  .> SORT_BY(_["team"])

A helper (FIRST_ANSWERS) and a pipeline with three joins, a per-row computation, a group with nested filters and a top-1, and a final join and sort. It reads five tables, and dependencies() says which.

From PostgreSQL: SQL loads, SEL computes

The same relation bindings as every other page — and the report, planned for PostgreSQL, comes back pure_memory. The application then loads the tables the report reads, with SELECTs that SEL writes too, from one-step pipelines over the same bindings, and runs the report over them:

python
SCHEMA = {
    'TEAMS':     relation('teams', 'g', team_id='NUM', team='TEXT'),
    'CUSTOMERS': relation('customers', 'c', customer_id='NUM', customer='TEXT', plan='TEXT'),
    'SLA':       relation('sla', 's', plan='TEXT', priority='TEXT', respond_within='NUM',
                          resolve_within='NUM'),
    'TICKETS':   relation('tickets', 't', ticket_id='NUM', customer_id='NUM', team_id='NUM',
                          priority='TEXT', subject='TEXT', opened_at='NUM', closed_at='NUM'),
    'EVENTS':    relation('events', 'e', event_id='NUM', ticket_id='NUM', seq='NUM', at='NUM',
                          kind='TEXT', actor='TEXT'),
}
KEYS = {'TEAMS': 'team_id', 'CUSTOMERS': 'customer_id', 'SLA': 'plan',
        'TICKETS': 'ticket_id', 'EVENTS': 'event_id'}

report = compile(read('tickets-report.sel'))
plan = plan_hybrid(report, 'postgresql', SCHEMA)
print('1. the report, planned for PostgreSQL')
print('   plan       ', 'pure_memory' if plan.pure_memory else 'pushed down')
print('   reads      ', ', '.join(plan.source_tables))

print('2. so SQL only loads the tables it reads')
conn = connect('postgresql')
tables = Value.none()
for name in report.dependencies():
    load = compile(f'{name} .> SORT_BY(_["{KEYS[name]}"])')
    sql = Sql.translate_statement(load, 'postgresql', SCHEMA).as_statement()
    tables.set(name, query(conn, sql))
    print(f'   {name:<10}  {tables.get(name).size():>3} rows  {sql}')

print('3. and SEL computes the report over them')
result = report.run(tables)
print(render(result, '   | '))
text
1. the report, planned for PostgreSQL
   plan        pure_memory
   reads       events, tickets, customers, sla, teams
2. so SQL only loads the tables it reads
   CUSTOMERS    12 rows  SELECT "c".* FROM "customers" "c" ORDER BY "c"."customer_id" ASC
   EVENTS      168 rows  SELECT "e".* FROM "events" "e" ORDER BY "e"."event_id" ASC
   SLA           9 rows  SELECT "s".* FROM "sla" "s" ORDER BY CAST("s"."plan" AS TEXT) COLLATE "C" ASC
   TEAMS         3 rows  SELECT "g".* FROM "teams" "g" ORDER BY "g"."team_id" ASC
   TICKETS      48 rows  SELECT "t".* FROM "tickets" "t" ORDER BY "t"."ticket_id" ASC
3. and SEL computes the report over them
   | team=Billing  tickets=16  late=4  open=2  slowest=#39 (1104 min)  incidents=INC-4036 INC-4072 INC-4108 INC-4144
   | team=Onboarding  tickets=16  late=8  open=2  slowest=#19 (564 min)  incidents=INC-4012 INC-4048 INC-4084 INC-4120
   | team=Platform  tickets=16  late=9  open=2  slowest=#29 (1254 min)  incidents=INC-4024 INC-4060 INC-4096 INC-4132
   over the generated rows: same report

The last line runs the report once more over the generated data and compares: the rows that went through PostgreSQL and the rows that never left memory give the same report.

Without a database: generated, in memory

The same report over the generated rows, with nothing to connect to. The plan is still available — planning needs the schema, not a server — and asked about MariaDB this time, it agrees:

python
data = evaluate(read('tickets-generate.sel'))
print('1. generated in memory')
for name in data.keys():
    print(f'   {name:<10}  {data.get(name).size():>3} rows')

report = compile(read('tickets-report.sel'))
print('2. the report')
print(render(report.run(data), '   | '))
text
1. generated in memory
   TEAMS         3 rows
   AGENTS        6 rows
   CUSTOMERS    12 rows
   SLA           9 rows
   TICKETS      48 rows
   EVENTS      168 rows
2. the report
   | team=Billing  tickets=16  late=4  open=2  slowest=#39 (1104 min)  incidents=INC-4036 INC-4072 INC-4108 INC-4144
   | team=Onboarding  tickets=16  late=8  open=2  slowest=#19 (564 min)  incidents=INC-4012 INC-4048 INC-4084 INC-4120
   | team=Platform  tickets=16  late=9  open=2  slowest=#29 (1254 min)  incidents=INC-4024 INC-4060 INC-4096 INC-4132
3. planned for MariaDB, without connecting
   plan        pure_memory
   reads       events, tickets, customers, sla, teams

This is also how such a report is tested: the generator is the fixture, the report is the code under test, and the database is one more way to deliver the same rows.

View this page's Markdown on GitHub