SQLeet - fully SQLite database backed spreadsheet

Top-level Files of tip
Login

Top-level Files of tip

Files in the top-level directory from the latest check-in


SQLeet Architecture

A complete spreadsheet engine in pure SQLite, designed for Qt QAbstractItemModel (or similar) integration.

File Structure

Total SQLite Backend Size: 3,264 lines (2,539 algorithmically pertinent non-comment lines).

Schema Complexity: 145 top-level SQLite statements:

File Purpose Lines
spreadsheet.sql Core schema: tables, indexes, config 445
views.sql Qt model views, conditional formats, filtering 310
triggers.sql Display formatting, formula cascade, setData, undo 467
operations.sql Row/col ops, copy/paste, clear, fill 708
textsheet.sql Text sheets (tokens/paragraphs/wrap) + unified named ranges & cross-kind live embedding 1,334

Load order (each file layers on the previous):

spreadsheet.sql → views.sql → triggers.sql → operations.sql → textsheet.sql

textsheet.sql is optional: omit it for a grid-only build. The Qt frontend loads all five. Smoke tests: test.sql (grid) and textsheet_test.sql (mixed sheets), each a self-checking PASS/FAIL suite — grep FAIL the output to gate health.

Qt Interface — What the Client Calls

The external code is paper-thin. The entire Qt model boils down to these queries:

data(index, role) → single cell

SELECT value FROM v_data
WHERE sheet_id=:s AND row=:r AND col=:c AND role=:role;

data() → viewport batch (preferred, one query for visible area)

SELECT row, col, display, edit_value, font, bg_color, fg_color, text_align, ...
FROM v_viewport
WHERE sheet_id=:s AND row BETWEEN :r1 AND :r2 AND col BETWEEN :c1 AND :c2;

setData(index, value, role)

INSERT INTO v_set_data(sheet_id, row, col, role, value) VALUES(:s,:r,:c,:role,:val);

setFormula()

INSERT INTO v_set_formula(sheet_id, row, col, formula, formula_sql) VALUES(:s,:r,:c,:f,:fsql);

rowCount() / columnCount()

SELECT row_count, col_count FROM v_model_info WHERE sheet_id=:s;

Structural Operations

INSERT INTO v_op(sheet_id, op, target, count) VALUES(:s, 'insert_row', 5, 1);
INSERT INTO v_op(sheet_id, op, target, count) VALUES(:s, 'delete_col', 3, 2);
INSERT INTO v_copy(sheet_id, start_row, start_col, end_row, end_col) VALUES(...);
INSERT INTO v_paste(sheet_id, target_row, target_col) VALUES(...);
INSERT INTO v_clear(sheet_id, start_row, start_col, end_row, end_col, mode) VALUES(...);
INSERT INTO v_new_workbook(name) VALUES('My Workbook');

Grid Named Ranges (named_ranges)

-- Define (or redefine) a named range; dependent formulas update live.
INSERT INTO v_define_name(sheet_id, name, start_row, start_col, end_row, end_col) VALUES(...);
INSERT INTO v_drop_name(sheet_id, name) VALUES(:s, 'Sales');
SELECT name, a1_notation FROM v_named_range WHERE sheet_ref=:s;   -- list

Names work in formulas both as ranges (=SUM(Sales)) and as scalar values (=B2*Rate). The formula bar shows the name; redefining the range updates dependents, and insert/delete row/col shifts named ranges like everything else. All logic is in SQLite; the Qt client just dispatches these INSERTs and offers a Name Box + Names menu (Define from Selection, Name Manager).

Text Sheet Queries

The Qt side reads v_sheet_meta once per sheet to learn kind/wrap_width/show_grid, then uses the matching views. Grid path is unchanged.

-- Sheet kind dispatch
SELECT kind, wrap_width, show_grid FROM v_sheet_meta WHERE sheet_id=:s;

-- Text viewport (parallel to v_viewport for grids)
SELECT row, col, display, edit_value, item_flags, x_offset, ...
FROM v_text_viewport
WHERE sheet_id=:s AND row BETWEEN :r1 AND :r2 AND col BETWEEN :c1 AND :c2;

-- Text single-cell read
SELECT value FROM v_text_data WHERE sheet_id=:s AND row=:r AND col=:c AND role=:role;

-- Unified read (dispatches on kind internally — works for either sheet type)
SELECT value FROM v_data_all WHERE sheet_id=:s AND row=:r AND col=:c AND role=:role;

-- Text setData (delete/replace/split/append dispatched by trigger on content)
INSERT INTO v_text_set(sheet_id, row, col, role, value) VALUES(:s,:r,:c,:role,:val);

-- Create a text sheet
INSERT INTO v_new_text_sheet(workbook_id, name, position, wrap_width) VALUES(1,'Notes',1,80);

-- Bulk-ingest text (tokenizer + wrap run entirely in SQLite)
INSERT INTO v_text_ingest(sheet_id, content) VALUES(:s, :text);

-- Cross-kind named range (obj_ranges — distinct from grid named_ranges; see below)
INSERT INTO v_define_object(workbook_id, scope_sheet_id, name, target_kind, ref_sheet_id,
       start_row, start_col, end_row, end_col) VALUES(...);   -- grid rect
INSERT INTO v_define_object(workbook_id, scope_sheet_id, name, target_kind, ref_sheet_id,
       start_tok_id, end_tok_id) SELECT 1,2,'Title',1,2, MIN(tok_id), MIN(tok_id)+1
       FROM text_tokens WHERE sheet_id=2;                     -- token span

-- Live embedding (host displays live value of source range, any-kind → any-kind)
INSERT INTO v_embed(host_kind, host_sheet_id, host_row, host_col, src_range_id) VALUES(...);

-- Invalidation/recalc queue (also driven automatically by triggers)
SELECT * FROM v_embed_queue;

flags(index) reads the item_flags column from v_text_viewport (35 = normal, 33 = non-editable ref token). The Qt frontend adds a sheet tab bar (mixed kinds), a Sheet menu (New Grid/Text Sheet), and an Embed menu (Name Selection → v_define_object, Embed Named Range Here → v_embed). A second thin QAbstractTableModel (TextSheetModel) reads v_text_data/v_text_viewport and writes v_text_set; the view swaps models on tab change. Everything else stays in SQLite.

Text Sheets & Cross-Sheet Embedding (textsheet.sql)

A workbook can hold two kinds of sheet in one database: grids (kind=0) and text sheets (kind=1). Every table still keys on sheet_id, so the Qt layer keeps calling the same model views; they dispatch on kind internally.

Two-Level Canonical Model

A text sheet stores canonical paragraphs of tokens:

Wrap Projection

Wrapping is a backend projection, not a rewrite. A materialized text_wrap cache holds one row per visible token, addressed by paragraph-local display row. The global Qt (row, col) is computed as paragraph.disp_row_start + local_row, where disp_row_start is a maintained prefix sum of paragraph line counts. Storing a paragraph-local row is the key trick: a single-paragraph edit rebuilds only its wrap rows — no renumbering of the whole cache, no PK-collision gymnastics.

The wrap algorithm (v_rewrap_para) is a greedy line-break recursive CTE that walks tokens in order carrying (local_row, disp_col, x_offset). A new line starts when x_offset + width + next_width > wrap_width and the line already has a token (so an over-wide token sits alone rather than looping). It writes text_wrap, updates line_count, recomputes the sheet-wide disp_row_start prefix sum and the sheet's reported extents.

Schema Objects (textsheet.sql)

Object Role
sheets.kind 0 = grid / 1 = text; one kind per sheet for life
text_sheet_cfg Per-text-sheet wrap_width, show_grid, monospace, default_align
text_paragraphs Canonical logical rows: stable para_id, visible para_ord, materialized line_count + disp_row_start prefix sum, paragraph style
text_tokens Canonical content: stable tok_id, paragraph-local tok_ord, display text, ref_range_id (embedded ref), optional measured_width; generated width and kind
token_roles Sparse per-token Qt roles (ToolTip/CheckState/UserRole+…)
text_wrap Materialized wrap projection: (para_id, local_row, disp_col) → tok_id, x_offset, width; WITHOUT ROWID
obj_ranges Unified cross-kind named ranges: grid rect / token span / paragraph span, with CHECKed anchor columns
embeds Unified live-embedding graph: host (grid cell or text token) → source range, with cached resolved_value + is_dirty queue slot
text_edit_scratch One-row scratch capturing a target's stable ids up front so multi-statement edit triggers are wrap-independent and collision-safe

Generated columns on text_tokens earn their keep:

width INTEGER GENERATED ALWAYS AS (COALESCE(measured_width, length(text) + 1)) STORED,
kind  TEXT    GENERATED ALWAYS AS (CASE WHEN ref_range_id IS NOT NULL THEN 'ref' … END) STORED

width = displayed text width + 1 unit (the +1 is the implicit trailing separator). Default is char-cell/monospace (length+1), fully SQL-native; if Qt has real font metrics it pushes measured_width and the generated column prefers it. STORED so it indexes and never recomputes on read.

Tokenizer

v_text_ingest runs a recursive-CTE scan that splits on space and newline simultaneously, inlined on the recursive column (the recursive table may not be referenced from a subquery). Newlines start paragraphs; space runs collapse to one separator; punctuation rides with its word, so the token stream round-trips to the original line via group_concat(text,' '). Example:

INSERT INTO v_text_ingest(sheet_id, content)
  VALUES(2, 'The quick brown fox, jumped!' || char(10) || 'Second paragraph here.');
-- tokens:  The | quick | brown | fox, | jumped! | Second | paragraph | here.
-- wrap@18: row0 "The quick brown"  row1 "fox, jumped!"
--          row2 "Second paragraph"  row3 "here."

Inline Live References

A token with ref_range_id set is an embedded ref. Its text is the cached resolved value, so DisplayRole and width/wrap work uniformly. EditRole renders the name of the source range (render-on-read), not the value — the same render-on-read philosophy as v_formula_text for grid formulas. Editing the token detaches it (drops the ref, keeps your literal). Plain tokens never touch the resolver, so ordinary editing stays fast. The delegate marks ref tokens non-editable (item_flags = 33).

Width Strategy

width = COALESCE(measured_width, length(text) + 1) — a STORED generated column. Char-cell by default (SQL-native, deterministic); measured_width lets Qt supply real font-metric advance (the one justified transient transport, since SQLite cannot measure glyphs). Exposed to the delegate via SizeHintRole ("width;1") and x_offset (UserRole 259) for sub-cell painting.

Two Independent Named-Range Systems

SQLeet has two separate named-range systems — do not conflate them:

System Table Used by Shift handling
Legacy grid ranges named_ranges Grid cell formulas (=SUM(Sales), =B2*Rate) operations.sql shift triggers
Unified cross-kind ranges obj_ranges Live embedding (embeds) across grid ↔ text textsheet.sql trg_objrange_* triggers

Grid formulas reference named_ranges only; the embedding system references obj_ranges only. textsheet.sql adds its own INSTEAD OF INSERT triggers on v_op (trg_objrange_{insert,delete}_{row,col}) that shift grid obj_ranges and grid-cell embed hosts on insert/delete row/col, using the exact shift/shrink math operations.sql applies to named_ranges. Text ranges anchor to stable surrogate ids (tok_id/para_id), so other edits never move them; endpoint deletion re-anchors to the nearest surviving neighbour in document order (para_ord, tok_ord).

Live Embedding

embeds is the cross-sheet reference graph: a host location displays the live resolved value of a source obj_ranges span, any-kind → any-kind.

-- name a grid range and a text token span, then embed each into the other (live):
INSERT INTO v_define_object(workbook_id,scope_sheet_id,name,target_kind,ref_sheet_id,
       start_row,start_col,end_row,end_col) VALUES(1,1,'Totals',0,1,0,1,1,1);   -- grid B1:B2
INSERT INTO v_define_object(workbook_id,scope_sheet_id,name,target_kind,ref_sheet_id,
       start_tok_id,end_tok_id) SELECT 1,2,'Title',1,2, MIN(tok_id), MIN(tok_id)+1
       FROM text_tokens WHERE sheet_id=2;

INSERT INTO v_embed(host_kind,host_sheet_id,host_row,host_col,src_range_id)
  SELECT 1,2,0,99,(SELECT id FROM obj_ranges WHERE name='Totals');   -- grid range → text token
INSERT INTO v_embed(host_kind,host_sheet_id,host_row,host_col,src_range_id)
  SELECT 0,1,0,3,(SELECT id FROM obj_ranges WHERE name='Title');     -- text range → grid cell

SELECT * FROM v_embed_queue;   -- the invalidation/recalc queue

Observed live propagation: changing grid B2 200→999 updated the embedded ref token to 100 999; editing text token QuarterlyAnnual updated grid D1 to Annual report.

Textsheet Index Strategy

Index Why Partial?
text_wrap PK (para_id, local_row, disp_col) Clustered viewport range scan — (WITHOUT ROWID)
idx_wrap_tok Token → current visible position ("reveal token") no
idx_para_order (sheet_id, para_ord) Ordered paragraph access unique
idx_para_disp (sheet_id, disp_row_start) Map a global display row → owning paragraph no
idx_tok_order (para_id, tok_ord) Canonical order; wrap walk join unique
idx_tok_sheet (sheet_id, tok_id) Invalidation membership lookups no
idx_tok_ref Only the few embedded-ref tokens partial WHERE ref_range_id IS NOT NULL
idx_objrange_name Name resolution no
idx_embed_src Dependents of a source range no
idx_embed_dirty The recalc queue is the small dirty subset partial WHERE is_dirty = 1
idx_embed_token Text-host embeds only partial WHERE host_kind = 1

Every hot access path is either a clustered PK range scan (viewport) or an equality probe on a covering/partial index (invalidation, resolution, reverse lookup).

Textsheet Trigger Catalog

Trigger What it does
trg_rewrap_para Greedy wrap + line_count + prefix sum + extents — the reflow primitive
trg_tok_{ins,upd,del}_rewrap Any token content change reflows its paragraph (and the old one if a token moved)
trg_tok_invalidate_embeds Marks embeds dirty when a source token changes
trg_cell_invalidate_embeds Marks embeds dirty when a source grid cell changes
trg_embed_resolve / trg_embed_resolve_insert Resolve + push new value to host; value-change guard prevents loops
trg_reanchor_token_ranges On endpoint deletion, re-anchors to nearest surviving neighbour in (para_ord, tok_ord)
trg_objrange_{insert,delete}_{row,col} Makes grid obj_ranges and grid-cell embed hosts follow structural edits
trg_cfg_rewrap Wrap-width change reflows the whole sheet (projection only)
trg_new_text_sheet Create text sheet via v_new_text_sheet
trg_text_ingest Bulk tokenize + wrap via v_text_ingest
trg_text_set_* setData dispatch: delete / single-word replace / multi-word split / append / style
trg_text_{split,join}_para Paragraph split and join operations
trg_text_rebuild Full idempotent rebuild from canonical tables
trg_define_object Create/update a cross-kind named range via v_define_object
trg_embed_into_{text,grid} Create an embedding into a text token or grid cell via v_embed

Materialized vs. Computed Live

Materialized (random-read-hot): width, kind (generated columns), text_wrap, line_count, disp_row_start, embeds.resolved_value.

Computed live (views): v_text_grid, v_text_data, v_range_value, v_named_object, v_embed_queue, v_embed_cycles. Render-on-read keeps derived text correct under structural edits, exactly like v_formula_text for grid formulas.

Known cost: trg_rewrap_para recomputes the whole-sheet prefix sum (O(paragraphs)) on every paragraph edit — a deliberate correctness-first choice. Optimization path: bulk-mode flag to skip the prefix pass during v_text_ingest, then run v_text_rebuild once; and/or revert to incremental delta-bumps now that creation paths set disp_row_start consistently.

Formula Engine — Two Tiers

Cell Value Changes Trigger fires? Mark dependents dirty Formula type? Tier 1a: Linear eval SUM(coeff x ref_value) Tier 1b: Range eval SUM/AVG/MIN/MAX/COUNT Tier 2: Client loop SELECT * FROM v_recalc_queue formula_terms formula_ranges formula_tokens UPDATE computed_value, is_dirty=0 Client executes pre-built SQL recursive_triggers=ON
# Cell recalc flowchart — top-down, explicit placement

# --- Center spine (top → bottom) ---
A: box "Cell Value Changes" fit at (0cm, 12cm)
B: diamond "Trigger fires?" fit at (0cm, 9.8cm)
C: box "Mark dependents dirty" fit at (0cm, 7.6cm)
D: diamond "Formula type?" fit at (0cm, 5.4cm)

arrow from A.s to B.n
arrow from B.s to C.n
arrow from C.s to D.n

# --- Tier branch (3-way split from D) ---
E: box "Tier 1a: Linear eval" "SUM(coeff x ref_value)" fit at (-5.5cm, 2.2cm)
F: box "Tier 1b: Range eval" "SUM/AVG/MIN/MAX/COUNT" fit at (0cm, 2.2cm)
G: box "Tier 2: Client loop" "SELECT * FROM v_recalc_queue" fit at (5.5cm, 2.2cm)

arrow from D.sw down 0.6cm then left until even with E then to E.n \
    "formula_terms" small above
arrow from D.s down 0.6cm then to F.n \
    "formula_ranges" small above
arrow from D.se down 0.6cm then right until even with G then to G.n \
    "formula_tokens" small above

# --- Merge Tier 1a/1b; separate Tier 2 path ---
H: box "UPDATE computed_value," "is_dirty=0" fit at (0cm, -0.6cm)
I: box "Client executes" "pre-built SQL" fit at (5.5cm, -0.6cm)

arrow from E.s to H.n
arrow from F.s to H.n
arrow from G.s to I.n

# --- Recursive trigger feedback loop ---
arrow from H.w left 1.2cm then up 6.8cm then to C.w \
    "recursive_triggers=ON" small above

The client parser is the one piece of non-trivial application logic. It uppercases the formula (so entry is case-insensitive) and classifies it:

  1. Tier 1b — a single range aggregate, e.g. =SUM(A1:A10)formula_ranges: [('SUM', A1:A10)]
  2. Tier 1a — a strict linear combination of coeff*ref terms, e.g. =A1+2*B1-C3formula_terms: [(A1, +1.0), (B1, +2.0), (C3, -1.0)]
  3. Tier 2 — anything else (cell×cell products, division, parentheses, standalone constants): =B2*C2, =A1/B1, =(A1+B1)*2

Tier 1 handles the common cases entirely inside triggers — zero client involvement. A change marks dependents dirty (cell_deps) and recursive_triggers chains the re-evaluation automatically.

Tier 2 is decomposed into an ordered token stream in formula_tokens (cell references + literal chunks). The tokens are the single source of truth:

Render-on-read formula text

The human-readable formula (what EditRole / the formula bar shows) is never stored as frozen text — it is derived on read by v_formula_text from the decomposition (formula_ranges / formula_terms / formula_tokens). Because structural ops shift the decomposition, the displayed references stay correct with no extra bookkeeping: inserting a row turns =SUM(D2:D4) into =SUM(D3:D5) and =B2*C2 into =B3*C3 automatically.

The same render-on-read philosophy extends to text sheets: EditRole on a ref token derives the source range name from obj_ranges, not the cached value.

Key SQLite Features Used

Feature Where Why
WITHOUT ROWID cells, cell_styles, cell_deps, formula_terms, formula_ranges, formula_tokens, text_wrap Clustered B-tree on PK — viewport range scans are sequential
Partial indexes idx_cells_dirty, idx_cells_formulas, idx_tok_ref, idx_embed_dirty, idx_embed_token Only index the small fraction of dirty/formula/ref/embed rows
Covering index idx_cells_viewport Viewport queries never touch main table pages
STORED generated columns text_tokens.width, text_tokens.kind Computed at write time, indexed, never re-evaluated on read
INSTEAD OF triggers on views v_set_data, v_op, v_copy, v_paste, v_clear, v_text_set, v_embed, v_define_object Command dispatch — all operations are INSERT INTO v_xxx(...)
Recursive triggers Formula cascade, embed resolution PRAGMA recursive_triggers=ON — Tier 1 formulas and embed pushes chain automatically
Recursive CTE v_recalc_queue, v_circular_refs, v_rewrap_para (wrap walk), v_text_ingest (tokenizer) Topological sort, cycle detection, greedy line-breaking, tokenization — all without procedural code
Ordered group_concat v_formula_text, formula_sql regeneration Render-on-read formula text rebuilt from the decomposition in token order
UPSERT v_set_data trigger INSERT...ON CONFLICT DO UPDATE for cell creation/update
JSON functions cell_styles.font, borders, conditional formats Complex structured data without extra tables
printf() Display formatting trigger Number formatting ($, %, precision)
strftime() Date display Date formatting with configurable patterns
generate_series() Fill operations Range generation without external code
WAL mode Global pragma Concurrent readers during recalc

Two-Pass Negate Trick

Row/column insertion on WITHOUT ROWID tables can't use ORDER BY in UPDATE (unsupported). SQLeet solves PK collision during shifts with a negate trick:

-- Pass 1: make all affected rows negative (guaranteed unique)
UPDATE cells SET row = -(row + 1) WHERE row >= :target;
-- Pass 2: restore with offset
UPDATE cells SET row = (-row - 1) + :count WHERE row < 0;

This technique is reused throughout textsheet.sql wherever token ordinals need gap-safe shifting (e.g. multi-word setData that re-tokenizes in place).

Query Logging (--log)

Launch with --log (or --log=/path/to/log.db, default sqleet-log.db) to trace every statement on the main connection into a second, ATTACHed database (logdb.query_log). Each row is timestamped and serialized:

ts action sql result_rows results micros
2026-…T00:39:02.085 setFormula(r=4, c=3) = '=SUM(D2:D4)' SELECT row, col, … FROM v_data … 4 [1\|3\|100\|100] ; [2\|3\|200\|200] ; … 31

action is the app command that prompted the query — nested queries (recalc drain, trigger-driven SQL) inherit the top-level action, so you can see exactly what each statement was doing. Capture uses sqlite3_trace_v2; writes go through a separate connection (a trace callback must not touch the connection it traces). There's a headless --selftest that drives the real model over the real connection (combine with --log to capture a full session).

Smoke Test Results

Run both suites and grep FAIL:

# Grid suite (test.sql)
sqlite3 :memory: ".read spreadsheet.sql" ".read views.sql" ".read triggers.sql" \
  ".read operations.sql" ".read test.sql"

# Mixed-sheet suite (textsheet_test.sql — 25 assertions)
sqlite3 :memory: ".read spreadsheet.sql" ".read views.sql" ".read triggers.sql" \
  ".read operations.sql" ".read textsheet.sql" ".read textsheet_test.sql"

The grid suite also passes unchanged with textsheet.sql loaded — the new v_op triggers only touch the new tables.

Grid suite (test.sql) — all ✅

Mixed-sheet suite (textsheet_test.sql) — all ✅