feat: introduce pine variables (|= syntax)#38
Open
ahmadnazir wants to merge 29 commits into
Open
Conversation
Contributor
- Add |= syntax to assign expressions to named variables - Variables compile to CTEs; later expressions reference them as tables - Multi-expression API: expressions[] array, last is active, preceding are context - Fix build-query empty-expression guard (was hardcoded "x_0" alias, now checks actual table name) - Skip auto-id columns for variable/CTE tables (only real tables get them) - Add docs/variables.md explaining the feature Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Guard api-build and api-eval against blank/nil last expression to avoid
Instaparse NPE ("text is null") when the input is empty
- Add technical implementation section to docs/variables.md
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
When a variable has explicit user columns (select, group, etc.), override the column hint list with the actual CTE output columns rather than all columns of the underlying source tables. For group operations, also add a synthetic "count" column. Variables using * (no explicit columns) fall back to the source table's columns as before. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…econd Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
All docs now follow: why → syntax/overview → examples → how it works → constraints → implementation. Add auto-id.md (hidden id columns for row tracking) and result-updates.md (editing cells in join query results). Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
auto-id is an implementation detail of result-updates, not a standalone concept. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Variables don't belong to a DB schema. seed-variable-references copies the
source table's :in map, causing variables to display as e.g. "public.active_co"
in hints. Now create-hint-from-table checks against state :variables and emits
{:schema nil :table name} for any variable, giving a clean unqualified hint.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The ASSIGN span includes the leading "|" from the grammar rule. When prettify joins operations with "\n | ", this produced "| | =name". Strip the leading pipe from ASSIGN expressions before joining. Also add parser tests covering assignment prettification: - bare assignment (|= name) - spaced assignment (| = name) - assignment after filter (where: ... |= name) - trailing pipe after assignment is a parse error (assignment must be last) Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
api-eval was passing the raw last expression (e.g. "tenant |") directly to generate-state/run-query, producing an empty query and a PostgreSQL "No results returned" error. Now mirrors api-build: splits context/last, trims pipes on the last expression, and guards on blank after trimming. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Previously seed-variable-references only gave the variable its own entry in the refs map (inheriting the source table's relationships). This meant 'real-table | variable' and 'variable-x | variable-y' joins could never be resolved because the other side had no knowledge of the variable. New patch-variable-relations pass runs after all variables are seeded: for every entity T (real table or variable) where T[:referred-by][S] exists (S is the variable's source table), T[:referred-by][variable] is also registered with the same via-details. This enables: - T | V and V | T (real table <-> variable) - V | W and W | V (variable <-> variable, when W already inherited S from its own source table via the seeding pass) Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
… hints - Refactor patch-variable-relations to cover both :referred-by and :refers-to directions via a patch-direction helper, so joins like `employee | mytest` (where mytest wraps the parent table) resolve correctly. - Fix relation-hints to nil the schema for variable entries, preventing hints like "y.mytest .col" when "mytest .col" is correct. - Add tests for both join directions and both hint directions. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Two variables wrapping the same source table (e.g. company |= c1 and company |= c2) had no join path between them because company has no self-referential FK. Added patch-same-source-variable-joins which registers a synthetic :variable-join entry at refs[:table v1 :referred-by v2] for each pair of variables sharing a source table with an id column, gated on the absence of an existing join path so self-referential FK propagation (employee/reports_to) is not disturbed. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
synthetic :variable-join entries always join on id=id so there is no disambiguation need. Suppress the column from the generated hint so the pine expression reads "c2" instead of "c2 .id". Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The previous test used "c1 | c2" where "c2" exactly matches the target variable — trivially correct. Changed to "var_x | var" with var_x and var_y as names so the test exercises partial typing (token "var" filters to var_y without needing the full name), closer to actual UI behaviour. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
pipeline.md: add parameter table for ast/generate explaining why both parse-tree and expression are needed, and what cursor/variables/assign do. variables.md: clarify state threading loop (parse → generate → store); add Pass 3 (patch-same-source-variable-joins) to join resolution section. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- |= is now a regular pipe operation (not a terminal), enabling 'company |= x | w: active = true' — the snapshot is taken at the assignment point while the pipeline continues - Variable names assigned via |= can be used as column qualifiers in subsequent ops within the same expression (e.g. 's: x.id', 'w: x.id = 1') by resolving through :pending-assignments rather than a separate map - Adds docs/expressions.md covering multi-expression evaluation, operation table, variable threading, and error short-circuiting - Updates docs/pipeline.md and docs/variables.md accordingly - Adds what/why ns docstrings to api.clj, ast/main.clj, ast/assign.clj, parser.clj Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…ent join, and assign snapshot Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…t-partial hints When a partial condition/column had an explicit alias (e.g. w: e.company_id), hints were incorrectly looking up columns from the current context table instead of the aliased table. Also, after a comma in select-partial, hints now default to the current context table instead of the last completed column's table. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Add `alias <".">` (e.g., `e.`) as a valid partial form in s:, w:, o:, and u! operations so the LSP can generate column hints for a specific alias even when no column name has been typed yet. - Grammar: new `partial-alias` rule; SELECT-PARTIAL, ORDER-PARTIAL, WHERE-PARTIAL (via partial-condition), and UPDATE-PARTIAL now accept it alongside their existing forms, including the "columns, alias." composite case (e.g. `s: id, e.`) - Parser: `parse-partial-alias` helper; SELECT/ORDER-PARTIAL handlers rewritten to detect the partial-alias child node and attach it as `:partial-alias` on the op map; UPDATE-PARTIAL gets two new match arms; WHERE-PARTIAL encodes it inside `partial-condition` - Hints: `generate-column-hints` reads `:partial-alias` from the operation for select/order-partial before falling back to current Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Cover three gaps where the partial-alias path crosses existing logic: - s: e.id, e. → exclude already-selected alias column - w: id = 1, e. → complete condition then alias-dot shows right table - u! id = '1', e. → prior assignment excluded from alias-dot results Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
GROUP and LIMIT produce bounded result sets that cannot be directly joined — further table ops after them produce malformed SQL. This change detects when a table operation follows a GROUP or LIMIT op within the same expression and automatically wraps the preceding query in an anonymous CTE (__pine_0__, etc.), then continues the pipeline on top of it. An optional |= name between the checkpoint op and the following table lets the user name the CTE explicitly instead of using the auto-name. Introduces: checkpoint-op-types, reset-for-cte, seal-as-cte, flush-checkpoint in ast/main.clj; pending-assignments lookup in table/handle; :ast-based CTE guard in add-auto-id-columns. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
When LIMIT followed GROUP, flush-checkpoint held instead of firing — then handle-ops overwrote the GROUP checkpoint with a LIMIT checkpoint, silently dropping the CTE wrapping. Same bug applied to |= x | l: 1. Fix: flush-checkpoint now fires when the incoming op is in checkpoint-op-types (GROUP or LIMIT), not only when it's a :table op. count: is unaffected since it is not a checkpoint op type. Adds tests for GROUP | LIMIT (auto-named and user-named) and a cross-expression baseline that verifies all three forms produce equivalent SQL. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Projects the current expression's checkpoint CTEs (sealed GROUP/LIMIT results) into the API ast payload so the frontend can render them as container nodes. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
79c29e5 to
a8042f2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Introduces variables to the pine language via a
|=assignment syntax. A variable is a named pine expression that can be referenced by name in subsequent expressions.Syntax
Both
|=and| =(with optional space) are valid.How it works
Instead of sending a single expression, the API now accepts a list of pine expressions evaluated sequentially. Variables defined in earlier expressions are available in later ones. The backend is stateless — no global state is touched.
{ "expressions": [ "company | where: active = true |= active_companies", "active_companies | employee | s: name" ] }Only the last expression's result is returned.
Design: Variables as Nested ASTs
A variable is a first-class entry in
:tables. Real table entries look like{:table "company" :schema "public" :alias "c_0"}. Variable entries carry an additional:astfield — its presence is the discriminator:{:table "active_companies" :schema nil :ast <nested-AST> :alias "ac_0"}There is no separate
:variableflag.:astpresent → variable.:astabsent → real table.A top-level
:variables {}map is maintained in the AST state and populated incrementally as assignments are processed. This is included in the API response for debugging and frontend use.Join resolution
The existing
join-helper/join-tablescode requires zero changes. Before the main pipeline runs,pre-handleaugments the local:referencesmap with entries for each variable::columns:currenttable only; otherwise trace each column's:aliasthrough:aliasesto find all source real tables(get-in refs [:table source-table])into(get-in refs [:table varname])The join system then sees
active_companiesexactly as it seescompany— no special casing.SQL generation
Variable tables emit CTEs.
build-select-queryis refactored into:build-bare-select— generatesSELECT ... FROM ... WHERE ...(no CTE prefix), using the variable name (no schema) for variable tables in FROM/JOIN clausescollect-ctes— recurses into nested variable ASTs in topological order, deduplicates by name, returns a flat list of[name bare-sql]pairsbuild-cte-body— likebuild-bare-selectbut strips thecolumn-aliasfrom the current table's auto-id column so it is exposed as plainidin the CTE (required for join conditions likeactive_companies.id = employee.company_idto resolve)build-select-query— wraps the above: collects CTEs, prependsWITH ... AS (...)clauseComposed variables (variable referencing another variable) are handled naturally:
collect-ctesrecurses, all CTEs flatten to a single top-levelWITHclause (PostgreSQL does not allow nestedWITH).Files changed
src/pine/pine.bnfASSIGNrule:|= symbolas optional tail onOPERATIONSsrc/pine/parser.cljASSIGNnode; extract:assignfrom ops list, return as separate key so it never entershandle-opssrc/pine/ast/main.clj:variables {}and:assign nilto default state; extendpre-handleto accept variables and seed references; record:assignafterhandle-ops; keep:variablesthroughpost-handlesrc/pine/ast/table.clj:variablesfirst inhandle; variable entries carry:ast; join resolution unchangedsrc/pine/eval.cljcollect-ctes,build-cte-body,build-bare-select, updatedbuild-select-query; FROM/JOIN use CTE name for variable tablessrc/pine/api.cljapi-evalandapi-buildacceptexpressions: [...]; sequential evaluation with local variable map; return last expression's resultRelated
🤖 Generated with Claude Code