Docs · SQL
SQL
Run SELECT statements over Vectro tables and datasets with sql(), and what SQL is and isn't supported.
sql(text, tables) runs one SELECT statement over your tables and returns a lazy query. The statement is compiled to the same plan the table methods build, so SQL gets every optimization: fused filters, parallel group-bys, joins, top-k, and chunk skipping on datasets bigger than memory.
import { Engine, sql } from 'vectrojs';
const engine = new Engine();
const orders = engine.table({
cust: [10, 20, 10, 30, 40, 20, 10, 30],
region: ['EU', 'US', 'EU', 'APAC', 'US', 'EU', 'US', 'APAC'],
price: [120, 80, 45, 300, 99, 15, 60, 210],
qty: [2, 1, 4, 1, 3, 10, 2, 1],
ts: ['2024-01-03', '2024-01-15', '2024-02-01', '2024-02-10',
'2024-02-20', '2024-03-05', '2024-03-06', '2024-03-30'].map((d) => new Date(d)),
});
const customers = engine.table({ cust: [10, 20, 30, 40], tier: [1, 2, 1, 2] });
console.log(sql(`
SELECT c.tier, COUNT(*) AS n, SUM(o.price * o.qty) AS revenue
FROM orders o JOIN customers c ON o.cust = c.cust
WHERE o.region <> 'US' AND o.ts >= DATE '2024-01-01'
GROUP BY c.tier HAVING COUNT(*) > 1
ORDER BY revenue DESC LIMIT 5`, { orders, customers }).toRows());
await engine.close();
The sql() function
sql(text, tables, options) // -> Query
tables is an object of names to tables or datasets: { orders, customers }, or { o: ordersTable } to call it o. Names are matched without regard to case. A name may also contain a schema ('sales.orders'); public.orders finds orders.
The result is a lazy query, so nothing runs until you ask:
| Method | Returns |
|---|---|
toRows(limit) |
plain objects; dates as Date objects |
collect() |
a table |
count() |
the number of rows |
agg(spec) |
totals over the rows, as on any lazy query |
explain() |
the plan |
Each has an ...Async version that runs on the worker threads. Errors in the SQL throw a SyntaxError whose message starts with vectro SQL: and says what to change.
Options:
| Option | Does |
|---|---|
types: true |
adds .columns to the query: [{ name, type }] with type 'int', 'float', 'bool', 'text', 'date' or 'day' (dates without a time), worked out from the tables’ columns |
foldCase: true |
reads unquoted names in lower case, as PostgreSQL does, and names unnamed columns as PostgreSQL would (count, sum) |
functions |
more functions: { name: { out: 'text' or 'number', f(...values) } }, run once per distinct set of values |
session |
values for functions such as version() and names such as current_user |
database |
a schema name that means the tables themselves, as public does |
The servers use types, foldCase and session to speak to BI tools; functions is the one you’ll most often want yourself.
import { Engine, sql } from 'vectrojs';
const engine = new Engine();
const users = engine.table({ email: ['[email protected]', '[email protected]', '[email protected]'], visits: [3, 5, 2] });
const q = sql(`
SELECT domain(email) AS domain, SUM(visits) AS visits
FROM users GROUP BY 1 ORDER BY visits DESC`,
{ users },
{ types: true, functions: { domain: { out: 'text', f: (e) => e.split('@')[1] } } });
console.log(q.columns);
console.log(q.toRows());
await engine.close();
Statement shape
[WITH name [(columns)] AS (query), ...]
SELECT [DISTINCT] items
[FROM table [AS] alias | (query) [AS] alias
[[INNER | LEFT | RIGHT | FULL [OUTER] | CROSS] JOIN table | (query) alias
[ON a.x = b.y [AND ...] | USING (x, ...)]]...]
[WHERE condition]
[GROUP BY expressions]
[HAVING condition]
[UNION [ALL] | INTERSECT | EXCEPT SELECT ...]...
[ORDER BY expression [ASC | DESC] [NULLS FIRST | NULLS LAST], ...]
[LIMIT n | FETCH FIRST n ROWS ONLY] [OFFSET m]
GROUP BYtakes columns, expressions,SELECTaliases or positions (GROUP BY 1).ORDER BYtakes the same, and aggregates (ORDER BY SUM(qty) DESC).SELECTwithoutFROMworks:SELECT 1 + 2 AS x.SELECT *andSELECT o.*expand to the columns; aUSINGkey appears once.- An unnamed result column is named after what it computes:
sum_price,count,avg_qty, orcol3for other expressions. - Names in double quotes (
"Order Date") or backticks may hold spaces and other characters;--and/* */comments are skipped.
Expressions
| Kind | Supported |
|---|---|
| Literals | numbers, 'text' ('' for a quote), E'...' with backslash escapes, NULL, TRUE, FALSE, DATE '2024-01-01', TIMESTAMP '2024-01-01 12:00:00', INTERVAL '7 days' |
| Arithmetic | + - * /, % (sign of the dividend, as in SQL) |
| Comparison | = <> != < <= > >=, also on text and on dates written as text (ts >= '2024-03-01') |
| Logic | AND OR NOT |
| Tests | BETWEEN, IN (...), LIKE, ILIKE, IS [NOT] NULL, IS [NOT] TRUE / FALSE, IS [NOT] DISTINCT FROM, regular expressions ~ ~* !~ !~* |
| Conditionals | CASE WHEN ... THEN ... ELSE ... END, CASE x WHEN ..., COALESCE, IFNULL, NVL, NULLIF |
| Math | ABS, SQRT, FLOOR, CEIL / CEILING, ROUND(x [, digits]), LEAST, GREATEST |
| Casts | CAST(x AS type) and x::type (see CAST) |
INTERVAL takes milliseconds, seconds, minutes, hours, days or weeks. Months and years vary in length, so they are refused; use DATE_TRUNC instead.
Aggregates
| Function | Notes |
|---|---|
COUNT(*), COUNT(x) |
rows; rows where x is present |
COUNT(DISTINCT x) |
exact, see distinct counts |
APPROX_COUNT_DISTINCT(x) |
HyperLogLog on split tables and streams, exact on tables |
SUM, AVG (or MEAN), MIN, MAX |
skip missing values; numbers only |
MEDIAN(x) |
|
PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY x) |
interpolated |
PERCENTILE_DISC(p) WITHIN GROUP (ORDER BY x) |
an actual value |
QUANTILE_CONT(x, p), QUANTILE_DISC(x, p), QUANTILE(x, p) |
DuckDB’s spellings |
Aggregates can be used anywhere in SELECT, HAVING and ORDER BY, inside expressions (SUM(price * qty) / COUNT(*), SUM(CASE WHEN ... END)), and with or without GROUP BY.
import { Engine, sql } from 'vectrojs';
const engine = new Engine();
const orders = engine.table({
region: ['EU', 'US', 'EU', 'APAC', 'US', 'EU', 'US', 'APAC'],
price: [120, 80, 45, 300, 99, 15, 60, NaN],
});
console.log(sql(`
SELECT region,
COUNT(*) AS n, COUNT(price) AS priced,
MEDIAN(price) AS median,
PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY price) AS p90,
SUM(CASE WHEN price >= 100 THEN 1 ELSE 0 END) AS big
FROM orders GROUP BY region ORDER BY region`, { orders }).toRows());
await engine.close();
Percentiles of a few partitions count values in buckets on every thread and select only within the buckets they need. MEDIAN per region over 10M rows takes 33.6 ms (DuckDB 71.7).
Joins
[INNER] JOIN, LEFT, RIGHT and FULL [OUTER] JOIN with ON (equalities joined by AND) or USING, and CROSS JOIN. Either side may be a subquery with an alias. Commas between tables are refused. Joins explains how they run, including how RIGHT and FULL joins find unmatched rows in the same pass.
Subqueries
- In
FROMandJOIN:FROM (SELECT ...) t(the alias is required). x IN (SELECT ...)andx NOT IN (SELECT ...).EXISTS (SELECT ...)andNOT EXISTS (SELECT ...).(SELECT one value)anywhere a value goes.- Correlated subqueries, which refer to the outer query, when the link is an equality:
WHERE o.cust = c.cust.
Every subquery becomes a join: IN and EXISTS join the subquery’s distinct keys, and a single-value subquery joins its one row, or its rows grouped by the correlation keys. The whole statement stays one plan with no row-by-row loops.
import { Engine, sql } from 'vectrojs';
const engine = new Engine();
const orders = engine.table({
cust: [10, 20, 10, 30, 40, 20, 10],
region: ['EU', 'US', 'EU', 'APAC', 'US', 'EU', 'US'],
price: [120, 80, 45, 300, 99, 15, 60],
});
const customers = engine.table({ cust: [10, 20, 30, 50], tier: [1, 2, 1, 3] });
const t = { orders, customers };
// orders from tier-1 customers
console.log(sql('SELECT COUNT(*) AS n FROM orders WHERE cust IN (SELECT cust FROM customers WHERE tier = 1)', t).toRows());
// customers with at least one order over 100
console.log(sql(`SELECT cust FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.cust = c.cust AND o.price > 100)`, t).toRows());
// orders above their region's average (correlated)
console.log(sql(`SELECT cust, region, price FROM orders o
WHERE price > (SELECT AVG(p.price) FROM orders p WHERE p.region = o.region)
ORDER BY price DESC`, t).toRows());
// a value per customer (correlated, in SELECT)
console.log(sql(`SELECT c.cust, (SELECT COUNT(*) FROM orders o WHERE o.cust = c.cust) AS orders
FROM customers c ORDER BY c.cust`, t).toRows());
await engine.close();
CTEs and set operations
WITH name AS (...) names a query for the rest of the statement; WITH name (a, b) AS (...) also names its columns. Later CTEs can use earlier ones.
UNION (distinct rows), UNION ALL, INTERSECT and EXCEPT combine queries with the same number of columns, matched by position. A final ORDER BY and LIMIT apply to the combined result.
import { Engine, sql } from 'vectrojs';
const engine = new Engine();
const orders = engine.table({ cust: [10, 20, 10, 30, 40, 20], qty: [2, 1, 4, 1, 3, 10] });
const customers = engine.table({ cust: [10, 20, 30, 50] });
const t = { orders, customers };
console.log(sql(`
WITH spend (cust, units) AS (SELECT cust, SUM(qty) FROM orders GROUP BY cust),
big AS (SELECT * FROM spend WHERE units >= 5)
SELECT * FROM big ORDER BY units DESC`, t).toRows());
console.log(sql('SELECT cust FROM orders UNION SELECT cust FROM customers ORDER BY cust', t).toRows());
console.log(sql('SELECT cust FROM customers EXCEPT SELECT cust FROM orders', t).toRows());
console.log(sql('SELECT cust FROM customers INTERSECT SELECT cust FROM orders ORDER BY 1', t).toRows());
await engine.close();
Window functions
f(...) OVER ([PARTITION BY ...] [ORDER BY ...] [ROWS | RANGE BETWEEN start AND end])
| Kind | Functions |
|---|---|
| Ranking | ROW_NUMBER, RANK, DENSE_RANK, PERCENT_RANK, CUME_DIST, NTILE(n) |
| Offsets | LAG(x [, n [, default]]), LEAD(x [, n [, default]]), FIRST_VALUE, LAST_VALUE, NTH_VALUE(x, n) |
| Aggregates | SUM, AVG, MIN, MAX, COUNT |
Frame bounds are UNBOUNDED PRECEDING, n PRECEDING, CURRENT ROW, n FOLLOWING and UNBOUNDED FOLLOWING. ROWS counts rows; RANGE measures by the order value, so RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW is a 7-day window on a date column. Without a frame, a window with ORDER BY runs from the partition’s start to the current row and its ties; without ORDER BY, it covers the whole partition, as in standard SQL.
Windows work over grouped results too: RANK() OVER (PARTITION BY region ORDER BY SUM(qty) DESC). Filter on a window’s result in an outer query, since WHERE runs before windows.
import { Engine, sql } from 'vectrojs';
const engine = new Engine();
const sales = engine.table({
day: ['2024-03-01', '2024-03-02', '2024-03-03', '2024-03-04', '2024-03-05', '2024-03-08']
.flatMap((d) => [new Date(d), new Date(d)]),
region: Array.from({ length: 12 }, (_, i) => (i % 2 ? 'US' : 'EU')),
units: [5, 3, 7, 4, 2, 6, 8, 1, 4, 9, 3, 5],
});
// running total, change from the day before, and a 3-day moving average, per region
console.log(sql(`
SELECT region, day, units,
SUM(units) OVER (PARTITION BY region ORDER BY day) AS running,
units - LAG(units) OVER (PARTITION BY region ORDER BY day) AS change,
AVG(units) OVER (PARTITION BY region ORDER BY day
RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW) AS avg3
FROM sales WHERE region = 'EU' ORDER BY day`, { sales }).toRows());
// the top 2 days per region
console.log(sql(`
SELECT region, day, units FROM (
SELECT region, day, units,
ROW_NUMBER() OVER (PARTITION BY region ORDER BY units DESC) AS rn
FROM sales) t
WHERE rn <= 2 ORDER BY region, units DESC`, { sales }).toRows());
await engine.close();
Each window costs one pass after its sort: running and moving sums use prefix sums, and moving MIN / MAX use a monotonic queue. A filter such as rn <= 3 on ROW_NUMBER or RANK keeps only the rows that can rank that high before sorting, picked on every thread. On a dataset, window functions and percentiles gather all their rows, so the columns they use must fit in memory.
Text functions
| Function | Does |
|---|---|
UPPER / UCASE, LOWER / LCASE |
change case |
TRIM, LTRIM, RTRIM (optional set of characters), TRIM(LEADING / TRAILING / BOTH 'x' FROM s) |
strip characters |
SUBSTRING / SUBSTR(s, start [, length]), SUBSTRING(s FROM a FOR b) |
part of a string; positions from 1 |
LEFT(s, n), RIGHT(s, n) |
the first or last n characters |
LENGTH / LEN / CHAR_LENGTH, OCTET_LENGTH / STRLEN |
characters; UTF-8 bytes |
REPLACE, REVERSE, REPEAT, LPAD, RPAD |
|
SPLIT_PART(s, delimiter, n) |
the nth piece (negative counts from the end) |
STRPOS / INSTR(s, sub), POSITION(sub IN s) |
position of sub, 0 if absent |
STARTS_WITH / PREFIX, ENDS_WITH / SUFFIX, CONTAINS |
tests |
CONCAT, CONCAT_WS(sep, ...) |
join strings, skipping missing values |
ASCII, CHR |
code point and back |
REGEXP_MATCHES / REGEXP_LIKE, REGEXP_REPLACE(s, pattern, replacement [, flags]), REGEXP_EXTRACT(s, pattern [, group]) |
regular expressions (JavaScript syntax) |
STRFTIME(ts, format) |
a date as text: %Y %m %d %H %M %S %j %a %A %b %B %F %T and more |
FORMAT(fmt, ...) |
PostgreSQL’s format with %s, %I, %L |
The || operator joins strings too, numbers included: city || ' #' || id. CASE, COALESCE and NULLIF work on text, and text compares with < and >. Positions count characters (not bytes) from 1, as in DuckDB.
Each text function runs once per distinct value, not once per row: a category column has few labels, and a million rows of region are a handful of calls. The answer is a category column again, so it groups, sorts and joins like any other. On 10M rows: GROUP BY UPPER(region) 30 ms (DuckDB 42), region || '-' || qty as a key 85 ms (92), STRFTIME(ts, '%Y-%m') 48 ms (61), ILIKE on a column 29 ms (46). A CASE whose results are all quoted labels is worked out as label codes inside the fused pass (CASE text + group: 5.7 ms vs 21).
import { Engine, sql } from 'vectrojs';
const engine = new Engine();
const people = engine.table({
id: [1, 2, 3, 4],
name: [' Ada Lovelace', 'bo chen', 'Cy Young ', 'Di'],
city: ['Seattle', 'Austin', 'San José', 'Paris'],
});
console.log(sql(`
SELECT id,
TRIM(name) AS name,
UPPER(SPLIT_PART(TRIM(name), ' ', 1)) AS first,
LENGTH(city) AS len,
LPAD(CAST(id AS VARCHAR), 3, '0') AS code,
city || ' #' || id AS label,
REGEXP_REPLACE(city, '[aeiou]', '*', 'g') AS masked
FROM people
WHERE name ILIKE '%o%' OR city ~ '^P'
ORDER BY id`, { people }).toRows());
await engine.close();
Date functions
Date columns hold milliseconds since 1970, in UTC.
| Function | Gives |
|---|---|
YEAR(x), MONTH(x), DAY(x) / DAYOFMONTH, HOUR(x), MINUTE(x), SECOND(x) |
parts as numbers |
DOW(x) / DAYOFWEEK(x) |
day of the week, 0 = Sunday |
ISODOW(x) / WEEKDAY(x) |
day of the week, 1 = Monday |
EXTRACT(part FROM x) |
any part above, or EPOCH (seconds since 1970) |
DATE_PART('part', x) |
the same, part as text |
DATE_TRUNC('year' / 'month' / 'week' / 'day' / 'hour', x) |
the start of that period, as a date |
STRFTIME(x, format) |
text, see above |
NOW(), CURRENT_TIMESTAMP |
the time the query was compiled |
import { Engine, sql } from 'vectrojs';
const engine = new Engine();
const events = engine.table({
ts: ['2024-01-03T09:30:00Z', '2024-01-15T18:05:00Z', '2024-02-01T07:00:00Z',
'2024-02-10T12:45:00Z', '2024-03-05T23:10:00Z'].map((s) => new Date(s)),
ms: [120, 340, 95, 210, 180],
});
console.log(sql(`
SELECT DATE_TRUNC('month', ts) AS month, STRFTIME(ts, '%b %Y') AS label,
COUNT(*) AS n, AVG(ms) AS avg_ms
FROM events
WHERE ts >= DATE '2024-01-01' AND ts < TIMESTAMP '2024-03-01 00:00:00'
GROUP BY 1, 2 ORDER BY 1`, { events }).toRows());
console.log(sql(`
SELECT EXTRACT(HOUR FROM ts) AS hour, ISODOW(ts) AS weekday, YEAR(ts) AS year
FROM events WHERE ts BETWEEN '2024-01-10' AND '2024-02-05' ORDER BY ts`, { events }).toRows());
await engine.close();
CAST
CAST(x AS type) or x::type, with these type names:
| To | Type names |
|---|---|
| text | VARCHAR, TEXT, STRING, CHAR, CHARACTER VARYING, … |
| whole numbers (truncated toward 0) | INT, INTEGER, BIGINT, SMALLINT, TINYINT, HUGEINT, INT2, INT4, INT8 |
| decimals | DOUBLE, DOUBLE PRECISION, FLOAT, REAL, DECIMAL, NUMERIC |
| booleans (1 / 0) | BOOL, BOOLEAN; text 'true', 'yes', 'on', '1' and their opposites |
| dates | DATE (midnight), TIMESTAMP, TIMESTAMPTZ, DATETIME |
Casting text to a number or a date gives a missing value where the text isn’t one. Numbers cast to text are written shortest (whole numbers without .0), dates as YYYY-MM-DD or YYYY-MM-DD HH:MM:SS.
import { Engine, sql } from 'vectrojs';
const engine = new Engine();
const raw = engine.table({ code: ['42', '7', 'n/a'], day: ['2024-05-01', '2024-05-02', 'soon'], price: [9.99, 12.5, -3.7] });
console.log(sql(`
SELECT CAST(code AS INTEGER) AS code, day::DATE AS day, CAST(price AS INT) AS whole,
CAST(price AS VARCHAR) AS text
FROM raw`, { raw }).toRows());
await engine.close();
Missing values
NULL is Vectro’s missing value: NaN for numbers and dates, '' for text. Aggregates skip it, IS NULL finds it, and ORDER BY puts it last unless you say NULLS FIRST. Comparisons with it are unknown, as in standard SQL, so NOT, <>, NOT IN, NOT BETWEEN and NOT LIKE leave those rows out. Missing values has the details.
Speed
The same SQL text against DuckDB on 10M orders and 100K customers (compare/bench-sql.mjs, answers checked): Vectro is faster on all 20 queries. A few of them:
| Query | Vectro | DuckDB |
|---|---|---|
| filtered totals | 3.4 ms | 10.1 ms |
| group by region | 7.8 ms | 30.7 ms |
| join + filter + group | 17.3 ms | 29.0 ms |
| top 10 customers | 12.9 ms | 42.5 ms |
| CASE inside SUM | 7.6 ms | 35.2 ms |
| correlated subquery | 16.0 ms | 50.6 ms |
| top 3 per region with RANK | 36 ms | 119 ms |
| running total by day | 8.6 ms | 14.6 ms |
compare/check-sql.mjs runs 40 queries (CTEs, set operations, correlated subqueries, windows, percentiles, text functions on awkward strings, outer joins, missing values) in both Vectro and DuckDB on the same 200K rows, and every answer is the same.
Not supported yet
Anything outside the list above gives an error that says so. In particular:
INTERSECT ALLandEXCEPT ALL(INTERSECTandEXCEPTwork).- Correlated subqueries linked by anything other than equalities.
MIN,MAX,SUMandAVGof text (COUNTandCOUNT(DISTINCT)of text work).STRING_AGGand other aggregates that build lists; arrays.- Commas between tables in
FROM; useJOIN ... ONorCROSS JOIN. INTERVALin months or years.- Statements other than
SELECT(INSERT,UPDATE,CREATE…), and more than one statement at a time. - Time zones other than UTC, and 64-bit integers beyond 2^53.