AXLR / Catalog / data

csv_query

Run one read-only SQL SELECT over one or more CSVs loaded as named tables into an in-memory SQLite database, with column types (INTEGER, REAL, TEXT) inferred and empty cells as NULL: GROUP BY, JOIN, window functions, CTEs. Returns JSON rows and the result as CSV. Read-only by construction: PRAGMA query_only, an authorizer that refuses ATTACH, PRAGMA, writes and load_extension, a single statement, a time budget and a row limit.

Install

/tools install csv_query
/tools install csv_query@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · csv_query 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool csv_query; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
sqlstringyesOne SELECT, WITH ... SELECT or VALUES statement. length ≤ 100000
tablesobject of stringno{table name: CSV text}
csvstringnoA single CSV, loaded as table data (or the name in table)
tablestringno"data"
pathsobject of stringno{table name: relative CSV path}
delimiterstringnoOverride the sniffed delimiter; \t for tab
infer_typesbooleannotrue
limitintegerno1000≥ 1; ≤ 100000
Input schema (JSON)
{
  "type": "object",
  "required": [
    "sql"
  ],
  "additionalProperties": false,
  "properties": {
    "sql": {
      "type": "string",
      "maxLength": 100000,
      "description": "One SELECT, WITH ... SELECT or VALUES statement"
    },
    "tables": {
      "type": "object",
      "additionalProperties": {
        "type": "string"
      },
      "description": "{table name: CSV text}"
    },
    "csv": {
      "type": "string",
      "description": "A single CSV, loaded as table data (or the name in table)"
    },
    "table": {
      "type": "string",
      "default": "data"
    },
    "paths": {
      "type": "object",
      "additionalProperties": {
        "type": "string"
      },
      "description": "{table name: relative CSV path}"
    },
    "delimiter": {
      "type": "string",
      "description": "Override the sniffed delimiter; \\t for tab"
    },
    "infer_types": {
      "type": "boolean",
      "default": true
    },
    "limit": {
      "type": "integer",
      "minimum": 1,
      "maximum": 100000,
      "default": 1000
    }
  }
}

Example

Input

{
  "tables": {
    "sales": "region,product,amount\nNorth,apple,10\nNorth,pear,5.5\nSouth,apple,7\nSouth,apple,3\n"
  },
  "sql": "SELECT region, SUM(amount) AS total FROM sales GROUP BY region ORDER BY region"
}

Output (stdout)

{
  "columns": [
    "region",
    "total"
  ],
  "rows": [
    {
      "region": "North",
      "total": 15.5
    },
    {
      "region": "South",
      "total": 10.0
    }
  ],
  "csv": "region,total\nNorth,15.5\nSouth,10.0\n"
}

Files

The command the install card registers is python3 csv_query.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

csv_query.py · sha256 d13556e1a7e3064d70cfef8bd800a65722e7e355012f7bdff829877750760ceb
#!/usr/bin/env python3
"""SQL over one or more CSVs: each CSV becomes a table in an in-memory SQLite database
(columns typed INTEGER, REAL or TEXT by inference, empty cells NULL) and one read-only
SELECT runs against them. The result comes back as JSON rows and as CSV.

Read-only by construction: PRAGMA query_only, an authorizer that allows only reading
(no ATTACH, PRAGMA, writes or load_extension), one statement only, a step budget and a row
limit.

Input (JSON on stdin):
  sql          one SELECT (or WITH ... SELECT) statement
  tables       {name: csv text}
  csv          a single CSV, as table "data" (or the name in table)
  table        name for csv, default "data"
  paths        {name: relative path} CSV files in the working directory
  delimiter    override the sniffed delimiter for every table
  infer_types  true (default): INTEGER / REAL / TEXT by inference; false: every column TEXT
  limit        maximum rows returned, default 1000 (at most 100000)
"""
import csv
import io
import json
import pathlib
import re
import sqlite3
import sys
import time

INT = re.compile(r"^[+-]?\d{1,18}$")
NUM = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$")
NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
MAX_BYTES = 20_000_000
ALLOWED = {sqlite3.SQLITE_SELECT, sqlite3.SQLITE_READ, sqlite3.SQLITE_FUNCTION}
if hasattr(sqlite3, "SQLITE_RECURSIVE"):
    ALLOWED.add(sqlite3.SQLITE_RECURSIVE)
DENIED_FUNCTIONS = {"load_extension", "readfile", "writefile", "edit", "fts3_tokenizer"}


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def read_path(path):
    if not isinstance(path, str) or not path or path.startswith(("/", "\\", "~")) or re.match(r"^[A-Za-z]:", path) or ".." in re.split(r"[\\/]", path):
        fail("paths must be relative paths inside the working directory, without '..'")
    target = pathlib.Path(path)
    if not target.is_file():
        fail(f"no such file: {path}")
    if target.stat().st_size > MAX_BYTES:
        fail(f"{path} is larger than 20 MB")
    return target.read_bytes().decode("utf-8-sig", errors="replace")


def parse_csv(name, text, delimiter):
    if text.startswith(""):
        text = text[1:]
    if delimiter is None:
        try:
            delimiter = csv.Sniffer().sniff(text[:65536], delimiters=",;\t|").delimiter
        except csv.Error:
            delimiter = ","
    try:
        rows = [row for row in csv.reader(io.StringIO(text), delimiter=delimiter) if row and any(c.strip() for c in row)]
    except csv.Error as error:
        fail(f"table {name}: the CSV could not be parsed: {error}")
    if not rows:
        fail(f"table {name} has no header row")
    header, data = rows[0], rows[1:]
    width = max(len(r) for r in rows)
    columns, seen = [], {}
    for index in range(width):
        base = header[index].strip() if index < len(header) and header[index].strip() else f"column_{index + 1}"
        label = base
        while label.lower() in seen:
            seen[base.lower()] = seen.get(base.lower(), 1) + 1
            label = f"{base}_{seen[base.lower()]}"
        seen[label.lower()] = 1
        columns.append(label)
    data = [row + [""] * (width - len(row)) for row in data]
    return columns, data, delimiter


def column_type(values):
    present = [v.strip() for v in values if v.strip() != ""]
    if not present:
        return "TEXT"
    if all(INT.match(v) for v in present):
        return "INTEGER"
    if all(NUM.match(v) for v in present):
        return "REAL"
    return "TEXT"


def convert(value, kind):
    if value.strip() == "":
        return None
    if kind == "INTEGER":
        return int(value.strip())
    if kind == "REAL":
        return float(value.strip())
    return value


def quote(identifier):
    return '"' + identifier.replace('"', '""') + '"'


def authorizer(action, arg1, arg2, database, trigger):
    if action == sqlite3.SQLITE_FUNCTION:
        return sqlite3.SQLITE_DENY if (arg2 or "").lower() in DENIED_FUNCTIONS else sqlite3.SQLITE_OK
    return sqlite3.SQLITE_OK if action in ALLOWED else sqlite3.SQLITE_DENY


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("stdin must be a JSON object")
    unknown = sorted(set(request) - {"sql", "tables", "csv", "table", "paths", "delimiter", "infer_types", "limit"})
    if unknown:
        fail(f"unknown field: {unknown[0]}")
    sql = request.get("sql")
    if not isinstance(sql, str) or not sql.strip():
        fail("sql is required: one SELECT statement")
    sources = {}
    tables = request.get("tables", {})
    if not isinstance(tables, dict) or not all(isinstance(v, str) for v in tables.values()):
        fail("tables must be an object {name: csv text}")
    sources.update(tables)
    if "csv" in request:
        if not isinstance(request["csv"], str):
            fail("csv must be a string")
        name = request.get("table", "data")
        if not isinstance(name, str):
            fail("table must be a string")
        sources[name] = request["csv"]
    paths = request.get("paths", {})
    if not isinstance(paths, dict):
        fail("paths must be an object {name: relative path}")
    for name, path in paths.items():
        sources[name] = read_path(path)
    if not sources:
        fail("give at least one table: tables, csv or paths")
    if sum(len(t) for t in sources.values()) > MAX_BYTES:
        fail("the CSVs are larger than 20 MB in total")
    for name in sources:
        if not isinstance(name, str) or not NAME.match(name) or name.lower().startswith("sqlite_"):
            fail(f"table name {name!r} must be a letter or _ followed by letters, digits or _ (and not sqlite_...)")
    if len({n.lower() for n in sources}) != len(sources):
        fail("table names must differ in more than case")
    delimiter = request.get("delimiter")
    if delimiter is not None:
        if not isinstance(delimiter, str) or len(delimiter.replace("\\t", "\t")) != 1:
            fail("delimiter must be a single character")
        delimiter = delimiter.replace("\\t", "\t")
    infer_types = request.get("infer_types", True)
    if not isinstance(infer_types, bool):
        fail("infer_types must be true or false")
    limit = request.get("limit", 1000)
    if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100000:
        fail("limit must be an integer from 1 to 100000")

    statement = sql.strip()
    while statement.endswith(";"):
        statement = statement[:-1].rstrip()
    if not re.match(r"^(\(\s*)*(select|with|values)\b", statement, re.I):
        fail("only one SELECT (or WITH ... SELECT, VALUES) statement is allowed")

    db = sqlite3.connect(":memory:")
    schema = {}
    for name, text in sources.items():
        columns, data, used = parse_csv(name, text, delimiter)
        kinds = [column_type([row[i] for row in data]) if infer_types else "TEXT" for i in range(len(columns))]
        db.execute(f"CREATE TABLE {quote(name)} (" + ", ".join(f"{quote(c)} {k}" for c, k in zip(columns, kinds)) + ")")
        try:
            db.executemany(f"INSERT INTO {quote(name)} VALUES (" + ", ".join("?" * len(columns)) + ")",
                           [[convert(row[i], kinds[i]) if infer_types else (row[i] if row[i] != "" else None) for i in range(len(columns))] for row in data])
        except (OverflowError, sqlite3.Error) as error:
            fail(f"table {name} could not be loaded: {error}")
        schema[name] = {"columns": [{"name": c, "type": k} for c, k in zip(columns, kinds)], "rows": len(data), "delimiter": used}
    db.commit()
    db.execute("PRAGMA query_only = ON")
    db.set_authorizer(authorizer)
    deadline = time.monotonic() + 10
    db.set_progress_handler(lambda: 1 if time.monotonic() > deadline else 0, 10000)
    try:
        cursor = db.execute(statement)
        rows = cursor.fetchmany(limit + 1)
    except sqlite3.Warning as error:
        fail(f"only one statement is allowed: {error}")
    except sqlite3.ProgrammingError as error:
        if "one statement" in str(error):
            fail(f"only one statement is allowed: {error}")
        fail(f"SQL error: {error}")
    except sqlite3.DatabaseError as error:
        message = str(error)
        if "interrupted" in message:
            fail("the query ran longer than 10 seconds")
        if "not authorized" in message or "prohibited" in message:
            fail(f"the query is not allowed (read-only: no ATTACH, PRAGMA, writes or extensions): {message}")
        fail(f"SQL error: {message}")
    except (OverflowError, ValueError, MemoryError) as error:
        fail(f"SQL error: {error}")
    columns = [d[0] for d in cursor.description] if cursor.description else []
    truncated = len(rows) > limit
    rows = rows[:limit]

    def plain(value):
        return value.hex() if isinstance(value, bytes) else value

    records = [{columns[i]: plain(v) for i, v in enumerate(row)} for row in rows]
    if len(set(columns)) != len(columns):
        records = [[plain(v) for v in row] for row in rows]
    out = io.StringIO()
    writer = csv.writer(out, lineterminator="\n")
    writer.writerow(columns)
    for row in rows:
        writer.writerow(["" if v is None else (v.hex() if isinstance(v, bytes) else v) for v in row])
    print(json.dumps({"columns": columns, "rows": records, "row_count": len(rows), "truncated": truncated, "csv": out.getvalue(),
                      "tables": schema, "sqlite_version": sqlite3.sqlite_version}))


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/csv_query/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/csv_query/1.0.0/csv_query.py