Skip to content

Sql support#5

Open
llambeau wants to merge 43 commits into
mainfrom
sql-support
Open

Sql support#5
llambeau wants to merge 43 commits into
mainfrom
sql-support

Conversation

@llambeau

Copy link
Copy Markdown

No description provided.

llambeau and others added 9 commits April 11, 2026 16:21
Captures architecture decisions for bringing SQL support to bmg.js:
monorepo with separate packages, standalone predicate library, typed AST,
async-only SqlRelation, cursor streaming, Docker-based integration tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Move core package to packages/bmg/. Set up pnpm-workspace.yaml with
root-level test/build scripts that delegate to all packages.

Replace microbundle with tsup for building — fixes a pre-existing build
failure where microbundle's bundled babel couldn't parse `export type`
syntax with newer @babel/parser versions.

Update CI workflow, CLAUDE.md, and package exports for new structure.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Standalone predicate library with:
- Typed AST (discriminated unions): eq, neq, lt, lte, gt, gte, in,
  and, or, not, tautology, contradiction
- Pred builder helpers for ergonomic construction
- evaluate() — run predicates against JS objects
- toSql() — compile to parameterized SQL (Postgres and SQLite dialects)
- isPredicate() type guard for runtime detection
- fromObject() to convert { key: value } to conjunction of equalities

86 tests covering builder, evaluation, SQL compilation, and type guards.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The Predicate type now accepts structured predicate AST nodes alongside
plain objects and functions. toPredicateFunc() detects structured
predicates via isPredicate() and evaluates them using evaluate().

This prepares the ground for SqlRelation which will compile structured
predicates to SQL rather than evaluating them in memory.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
On CI, the predicate package dist/ doesn't exist when bmg tests run
(no build step before tests). Add a vitest alias so bmg resolves
@enspirit/predicate directly from TypeScript source.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Phase 2 of the SQL support roadmap. Provides:

- Typed SQL AST (discriminated unions): SelectExpr, UnionExpr, ExceptExpr,
  IntersectExpr, with JOINs, GROUP BY, ORDER BY, LIMIT/OFFSET, aggregates
- SqlBuilder for ergonomic AST construction with auto-generated table
  qualifiers (t1, t2, ...) and fromSelf() subquery wrapping
- compile() — AST to parameterized SQL with Postgres and SQLite dialects
- compilePredicate() — compiles @enspirit/predicate AST nodes to SQL
  with qualified column names (supports dotted pre-qualified refs)

38 tests covering all SQL constructs: SELECT, DISTINCT, WHERE, GROUP BY,
ORDER BY, LIMIT/OFFSET, JOINs (INNER/LEFT/CROSS), set operations
(UNION/EXCEPT/INTERSECT), subqueries, aliases, aggregates, literals.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Phase 3: processor functions that rewrite the SQL AST, mirroring the
Ruby bmg Processor classes:

- processWhere — add/merge WHERE clause (wraps in subquery for GROUP BY)
- processProject / processAllbut — clip select list + add DISTINCT
- processRename — change column aliases in select list
- processExtend — add columns referencing existing columns
- processConstants — add literal value columns
- processRequalify — regenerate table qualifiers for binary ops
- processJoin — INNER/LEFT JOIN with select list and WHERE merging
- processMerge — UNION/EXCEPT/INTERSECT

23 new tests covering each processor and chained operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Phase 4 core: SqlRelation<T> implements AsyncRelation<T> backed by SQL.

- DatabaseAdapter interface with query(), stream(), and dialect
- BmgSql<T>(adapter, table, attrs?) factory for creating SQL relations
- SqlRelation pushes to SQL: restrict, project, allbut, rename, extend,
  constants, union, minus, intersect (when both operands share adapter)
- Falls back to BaseAsyncRelation (in-memory) for: function predicates,
  prefix, suffix, transform, group, ungroup, wrap, unwrap, image,
  summarize, matching, not_matching, cross_product
- toSql() for SQL inspection without executing
- Cursor-based async iteration via adapter.stream()

24 new tests with mock adapter verifying SQL push-down, fallback, chaining,
and terminal operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
PostgresAdapter wraps the pg (node-postgres) library and implements
DatabaseAdapter:
- query() for batch execution via Pool.query()
- stream() for cursor-based streaming via DECLARE CURSOR / FETCH
- close() to shut down the connection pool
- Uses PostgresDialect for $1 parameterized queries

Also re-exports BmgSql and SqlRelation for convenience so users
only need: import { PostgresAdapter, BmgSql } from '@enspirit/bmg-pg'

4 unit tests with mock pool.
Docker-based integration tests deferred to a later phase.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

@Imran-imtiaz48 Imran-imtiaz48 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation of PostgresAdapter is generally clean and aligns well with the DatabaseAdapter interface. The constructor logic correctly supports both an existing Pool instance and a PoolConfig, which makes the adapter flexible for different integration scenarios. The query() method is straightforward and properly delegates execution to pool.query, returning typed rows, which keeps the abstraction simple and consistent. The stream() implementation using PostgreSQL cursors is a solid approach for handling large result sets without loading everything into memory, and the use of AsyncIterable integrates nicely with modern JavaScript async iteration patterns. Resource management is handled carefully through the cleanup() function, ensuring that cursors are closed, transactions are committed, and clients are released back to the pool even when errors occur. However, there are a few areas that could be improved. The declareSQL conditional inside init() currently produces the same SQL in both branches, making the condition unnecessary and potentially confusing. Error handling could also be strengthened, particularly around transaction management, where a ROLLBACK might be safer than COMMIT if an error occurs during streaming. Additionally, the cursor naming strategy using Date.now() and Math.random() is acceptable but not guaranteed to avoid collisions in highly concurrent environments. The tests cover basic functionality such as dialect assignment, query delegation, parameter passing, and pool shutdown, but they do not validate the streaming behavior, which is the most complex part of the adapter. Adding unit tests or integration tests for cursor-based streaming would significantly improve confidence in the implementation. Overall, the code structure is clear, readable, and maintainable, but tightening error handling, removing redundant logic, and expanding test coverage would make the adapter more robust.

llambeau and others added 4 commits April 11, 2026 17:18
21 integration tests in packages/bmg-pg/tests/integration/ that exercise
the full stack against a real Postgres:
- Basic read, one()
- restrict: plain object, Pred.eq/gt/and/in, chained
- project, allbut, rename, constants
- union, minus, intersect (same-adapter set ops)
- join, left_join (with suppliers + shipments tables)
- Fallback: function predicate → in-memory, correct results
- toSql() inspection
- Cursor-based async iteration
- Chained: restrict → project → rename → toArray

No gating — tests fail hard if Postgres is unavailable.
Separated from unit tests via vitest config:
- `pnpm test` runs unit tests only (excludes integration/)
- `pnpm test:integration` runs integration tests only

docker-compose.yml at root for local dev.
CI workflow adds a separate integration job with Postgres service container.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…fset

Phase 5: additional processors and SqlRelation wiring.

New processors:
- processSummarize — GROUP BY + aggregates (count, sum, avg, min, max)
- processSemiJoin — matching/not_matching via EXISTS/NOT EXISTS subquery
- processOrderBy — ORDER BY clause
- processLimitOffset — LIMIT/OFFSET clause

SqlRelation now pushes to SQL:
- summarize() when all aggregators are SQL-compilable
- matching()/not_matching() with array keys on same-adapter relations

Compiler extended with EXISTS subquery support for semi-joins.

9 new processor unit tests, 4 new integration tests (summarize,
matching, not_matching against real Postgres).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Phase 6: Runtime type system.

- RelationType tracks attribute names and candidate keys at runtime
- project/allbut propagate type info through operator chains
- processProject/processAllbut skip DISTINCT when a key is preserved
- BmgSql() accepts optional { keys: [['sid']] } for key declaration

Type propagation through: restrict (same type), project, allbut,
rename, extend, join, summarize.

15 new RelationType unit tests, 5 new DISTINCT optimization tests
verifying key-aware behavior end-to-end.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Rewrite README to cover:
- Package overview table (bmg, predicate, bmg-sql, bmg-pg)
- In-memory quick start (unchanged)
- PostgreSQL quick start with full working example
- Structured predicates (Pred.eq, Pred.and, Pred.in, etc.)
- SQL push-down vs transparent fallback explanation
- Joins and set operations across SQL relations
- Aggregation via summarize
- Development commands (test, integration, build)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@blambeau

blambeau commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

@llambeau interesting. A few major comments:

1/ I wonder whether using an external SQL-AST lib, e.g. https://github.com/tobilg/polyglot or https://github.com/taozhi8833998/node-sql-parser, would not be a better strategy here

2/ RelationType must then be moved to core, like bmg-rb ; it would then provide the foundation for optimization even with Memory-relations

3/ We most certainly need to (im)port black-box SQL tests from bmg-rb for some guard rails on Claude.

llambeau and others added 15 commits April 16, 2026 16:03
Prepares packages/bmg-sql/tests/black-box/ with README, INDEX, and 19
per-operator tracking docs (one per source YAML in bmg-rb
spec/integration/sequel/base/). All 89 cases transcribed from upstream
SHA fa8c7e0 with description, original Ruby, expected SQLite, and
warnings flagging unsupported operators and API divergences.

No tests ported yet — this is the tracking base for the porting work.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Self-directed plan: one operator per iteration, one commit per iteration,
simple-to-complex order (14 supported operators, 5 blocked). Includes
loop steps, fixture spec, SQL normalizer spec, type-level assertion rule
for type-reshaping operators, and stop conditions.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
First iteration of the porting loop. Adds shared helpers
(mock-adapter.ts, fixtures.ts) and base.test.ts with the
one portable case (base.01). Case base.02 (subquery source)
is blocked pending a raw-SQL relation factory.

Also revises CLAUDE_PLAN.md: drops the SQL normalizer in
favor of asserting bmg-sql's native output directly with
.toBe(), keeping bmg-rb's SQL in the .md tracking docs as
the semantic reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Single case: column-aliasing extend pushed down to SQL as
`<col> AS <alias>`. Uses bmg-sql's string-reference form
(`{ supplier_id: 'sid' }`).

Type quirk flagged in extend.md: string-ref form infers the
literal type ('sid') for the new attribute instead of the
underlying column's value type. Runtime is correct; only the
static type is imprecise. Test documents current behavior via
expectTypeOf.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Covers three variants:
- Project preserving key (no DISTINCT)
- Project dropping key (DISTINCT emitted)
- allbut then project (DISTINCT propagates)

RelationType-driven DISTINCT optimization verified end-to-end.
No bmg-sql source changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
union.01 (self-union) and union.02 (chained) ported. bmg-sql
wraps operands in parens and left-nests chains — different
syntax, same query.

union.03 (UNION ALL via `all: true`) blocked: processMerge
accepts the flag but Relation.union() in bmg core has no options
argument. Exposing it requires cross-package API changes
(core types + Memory + async Base + SqlRelation). Flagged
for user review.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Self-minus, chained minus, and minus+summarize all ported.

Divergence: bmg-sql wraps the EXCEPT as a derived table
(subquery in FROM) rather than a CTE (WITH t2 AS ...).
Same query semantically — engines plan them identically.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
All four cases ported including the duplicate in the upstream
YAML. Adds the pattern for asserting both `sql` and `params`
when the case involves restrict literals — bmg-sql parameterizes
(uses `?` placeholder) where bmg-rb inlines.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
allbut.01-03 and allbut.05 match bmg-rb semantically. allbut.05
uses Pred.or from @enspirit/predicate for OR-restrict.

allbut.04 divergent: bmg-sql emits DISTINCT where bmg-rb does
not. bmg-rb recognizes that restrict on key-prefix (`sid='S1'`
on supplies keyed by [sid,pid]) functionally restores uniqueness
via pid, so allbut(sid) is safe without DISTINCT. bmg-sql's
RelationType only tracks whole-key preservation — fixing this
needs functional-dependency tracking via restrict equalities.
Same result set, redundant DISTINCT only.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Anti-join via NOT EXISTS. Cases .01-.03 ported. Case .04
(against a raw-SQL relation) blocked on subquery factory.

bmg-sql emits `SELECT 1 AS "_exists"` inside EXISTS where
bmg-rb uses `SELECT *`, and adds vacuous `WHERE 1=1` in the
empty-key case. Semantically equivalent for EXISTS evaluation.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Semi-join via EXISTS. Cases .01-.05 match bmg-rb semantically.

matching.06 surfaced a real bmg-sql bug: when the right-side of
matching is itself a join, the inner JOIN ... ON clause emits
wrong aliases ("t1"."pid" = "t1"."pid" instead of
"t7"."pid" = "t8"."pid"). Query returns incorrect results
against a real DB. Flagged via it.fails() with the CORRECT
expected SQL — when fixed in processSemiJoin, the test will
invert and alert us.

matching.07 (native SQL relation) blocked on subquery factory.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
All 8 left_join cases blocked on a cross-cutting bmg-sql bug
surfaced during this iteration: buildJoinPredicate resolves both
operand aliases via each relation's own SqlBuilder, so both start
at `t1`. processJoin renames the right side's table via
processRequalify (e.g. → `t2`), but the already-built ON predicate
isn't rewritten — it still references `t1` on both sides, producing
e.g. `ON "t1"."sid" = "t1"."sid"`.

Scope: affects `join`, `left_join`, and matching-against-a-join
(i.e. matching.06). Query results are incorrect on a real DB.

Fix requires API reshape in processJoin (pass keys in and build
the predicate post-requalify, or return old→new qualifier map
and rewrite the predicate). Non-trivial — stop condition #3
triggered. Stopping loop per CLAUDE_PLAN.md; awaiting user
decision on direction.

Test file has all 8 cases as it.todo with clear blocker notes.
Tracking: left_join.md "Blocker" section, INDEX.md "New bmg-sql
bug surfaced by porting", CLAUDE_PLAN.md Stopped? flag set.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
WHERE push-down works for the simple cases (.01, .02, .06). Porting
surfaced four distinct gaps in bmg-sql / @enspirit/predicate:

- NULL-in-IN (.03-.05): @enspirit/predicate's toSql emits
  IN (?, NULL, ?) with a NULL param, which silently drops NULL rows
  in standard SQL. bmg-rb splits NULL out via OR and collapses
  single-value / single-NULL IN lists. Fix belongs in the predicate
  package.
- Alias-in-WHERE (.07): restrict after rename leaves the predicate on
  the SELECT alias (firstname) instead of resolving back to the
  underlying column (name). Emits invalid SQL in Postgres/standard
  SQL. Needs rename-aware processWhere.
- UNION push-down (.10, .11): post-union restrict wraps the UNION in
  a derived table rather than pushing into each branch. Same result,
  different shape.
- Predicate.match (.08, .09): blocked — LIKE predicate not
  implemented in @enspirit/predicate.

All divergent cases are snapshotted, so future fixes will flip the
assertions and alert us.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Ported .01-.04, .06, .08. GROUP BY + standard aggregates push down
cleanly, and the post-summarize restrict wrap (.06) uses a derived
table — same precedent as minus.03 (bmg-rb uses WITH; same semantics).

Settled the bmg-rb `:qty => :sum` convention: bmg-js equivalent is the
verbose AggregatorSpec `{ qty: { op: 'sum', attr: 'qty' } }`. The
short form `{ qty: 'sum' }` produces SUM(*) in bmg-sql — not the same
thing. Documented at the top of summarize.test.ts.

summarize.05 surfaces the same join-alias bug as matching.06: the
join predicate compiles to `t1.sid = t1.sid`. Marked it.fails
with the correct expected SQL so the fix alerts us.

summarize.07 is blocked (it.todo): three layered bugs in the same
processJoin / processRequalify family — the join-alias bug fires, the
inner subquery is not requalified (inner `t1` collides with outer `t1`),
and the outer WHERE references the wrong alias. Not expressible as a
single correct-SQL snapshot without fixing the processor family.

summarize.09 and .10 are blocked: `distinct_count` is not in bmg
core's AggregatorName union nor in bmg-sql's compilableOps allowlist,
even though the SQL AST already emits COUNT(DISTINCT x). Unblocking
is a cross-package change.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
All 4 cases blocked at the type-system level. bmg core's
Transformation type is strictly JS-function-based
(TransformFunc = (value: unknown) => unknown), with no type-token
channel comparable to bmg-rb's class literals (String, Integer, Date).
bmg-sql's SqlRelation.transform() just falls back to in-memory —
there is no processTransform and no CAST emission in the AST/compiler.

Unblocking is a cross-package feature, not a test-porting fix:
1. Typed-token extension to core's Transformation (shape TBD).
2. processTransform in bmg-sql emitting CAST(col AS t) and the
   date(col) special case for transform.04.
3. Dialect hook for the varchar spelling.

Test file follows the left_join.test.ts precedent — a describe block
with it.todo per case plus an explanatory comment keeping the blocker
visible on the tracker.

This completes option (b) from CLAUDE_PLAN: all non-join operators
iterated. Remaining gap is the cross-cutting join-alias bug blocking
left_join (0/8), join (0/14), matching.06, summarize.05, summarize.07.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
llambeau and others added 15 commits April 18, 2026 19:08
Option (b) is exhausted: all non-join operators iterated (43/89 cases
ported, 7 divergent, 2 known-bug, 24 blocked). Loop paused pending the
join-alias refactor in processJoin / processRequalify — belongs on its
own branch with its own plan, not bundled here.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
SQL `IN (NULL, ...)` does not match NULL rows (NULL IN NULL is
UNKNOWN), so `restrict(Pred.in('col', [null, ...]))` was silently
dropping NULL rows when compiled to SQL — a correctness bug, not
just a divergence from bmg-rb.

Fix partitions nulls out of InPredicate.values:
- all-null list → `col IS NULL`
- mixed list → `(col IS NULL OR col IN (non-null values))`
- no-null list → unchanged `col IN (values)`

The combined form is parenthesized so it binds correctly inside AND
and NOT (kind is still 'in', so the caller's kind-based paren logic
would otherwise miss it).

Landed in two places — predicate compilation is duplicated across
@enspirit/predicate's toSql.ts and bmg-sql's compile.ts. Both get
the same fix; a future cleanup could consolidate.

Black-box test flips:
- restrict.03: divergent → ported
- restrict.04: divergent → ported (minor cosmetic: bmg-sql keeps
  `IN (?)` for single value; bmg-rb degrades to `=`. Same query.)
- restrict.05: divergent → ported (`IS NULL` collapse)

Adds 7 new predicate toSql tests covering null/mixed/AND/NOT cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
bmg core:
- AggregatorName += 'distinct_count' (types.ts, lib-definitions.ts)
- AggregatorResult routes distinct_count to `number` alongside count
- applyAggregator (sync + async) implements distinct_count as
  Set size over non-null values of the attribute

bmg-sql:
- compilableOps += 'distinct_count' in SqlRelation.summarize()
- AST & compile.ts already knew how to emit COUNT(DISTINCT col)

Black-box flips:
- summarize.09: todo → ported (verbose form, result named qty)
- summarize.10: todo → ported (result named count)

CHANGELOG updated — user-facing addition.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Adds Relation.page(ordering, pageNumber, { pageSize? }) to:
- bmg core Relation<T> interface (types.ts + lib-definitions.ts)
- MemoryRelation (sync) via new sync/operators/page.ts
- AsyncRelation interface + BaseAsyncRelation (async/types.ts +
  async/Relation/Base.ts + async/operators/page.ts)
- SqlRelation, wiring to existing processOrderBy + processLimitOffset
  (no new processor needed)

Default pageSize = 20; pages are 1-indexed. Ordering accepts either
a bare attribute name (ASC) or a [name, 'asc' | 'desc'] tuple:

  r.page(['name', 'sid'], 1, { pageSize: 2 })
  r.page([['status', 'desc'], 'name'], 2)

processOrderBy change: wrap complex SELECTs (GROUP BY, LIMIT,
computed columns) in a subquery before applying ORDER BY. Mirrors
processWhere's isComplex wrap. Without this, summarize().page()
would emit `ORDER BY "t1"."qty"` after `MAX("t1"."qty") AS "qty"`,
resolving to the raw column (invalid in most dialects after GROUP
BY) instead of the aggregate alias. bmg-rb uses a CTE wrap for the
same reason — we use a derived table (same precedent as minus.03
and summarize.06).

Black-box flips:
- page.01 through page.05: blocked → ported
- page.04 verifies that findColumnRef resolves the renamed alias
  back to the underlying column in ORDER BY (ORDER BY "t1"."sid"
  not "t1"."id"), matching bmg-rb's push-down

5 sync in-memory tests + 5 black-box SQL tests added. CHANGELOG
updated (new user-facing operator).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Adds a new AST node `RawSubqueryRef` (opaque SQL fragment + alias +
optional bind params) and a `BmgSql.fromSubquery` factory that
builds a SELECT projecting the given attrs from that subquery:

  BmgSql.fromSubquery<T>(
    adapter,
    'SELECT sid FROM suppliers WHERE city = ?',
    ['sid'],
    { params: ['London'] },
  )

The fragment is embedded verbatim, aliased as t1, and composes with
every existing operator (restrict, matching, not_matching, join,
summarize, etc.). Params are bound in the order they appear in the
compiled SQL.

Wired the new TableSpec variant through:
- compile.ts: compileTableSpec + getTableAlias
- processors.ts: getAlias + requalifyTableSpec
- index.ts: export RawSubqueryRef

Black-box flips (3 cases):
- base.02: blocked → ported
- matching.07: blocked → ported (with 'London' bind param)
- not_matching.04: blocked → ported

fixtures.ts helper now exposes the shared `adapter` so
fromSubquery-built relations stay compatible with the fixture
relations (same adapter instance check in extractCompatibleExpr).

CHANGELOG updated — user-facing addition on bmg-sql.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The plan was stale in several places after A→D landed:
- Current state totals conflicted (43 in one bullet, 53 in another)
- "Blocked" section still listed page, base.02, matching.07,
  not_matching.04 as blocked even though unblockers C and D resolved
  them
- Operator-order checklist had stale case counts (not_matching 3/4,
  matching 5/7, summarize 7/10, base 1/2)
- Completion notes was still a placeholder

Changes:
- Current state: single consistent totals block (53/89, 30 blocked
  via it.todo)
- New "Unblocker pass (A→D)" section capturing the four commits, the
  explicit relaxation of stop conditions #3 / #5, and the precedent
  this sets for future bounded unblocker work
- New "Next up" section ranking remaining work by effort × value,
  with the join-alias refactor called out as the single biggest
  lever (~25 cases)
- Updated operator-order checkboxes, plus a new "Operators added
  outside the original 14" subsection for `page`
- "Blocked" section split into "Still blocked" and "Resolved by the
  unblocker pass"
- Completion notes filled in with final tally and pointers forward

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Pre-existing bug: buildJoinPredicate (in relation.ts) built the ON
predicate using each relation's own SqlBuilder, so both operands
started at t1. processRequalify then renamed the right side's tables
but not the already-built on predicate, producing ON t1.sid = t1.sid —
wrong results on a real DB. Blocked ~25 black-box cases across
join.*, left_join.*, matching.06, summarize.05/.07.

Three scoped fixes, co-located in the processor family:

1. processJoin(left, right, keys, kind, builder) now takes keys
   instead of a pre-built predicate and builds ON *after*
   processRequalify via a new buildJoinOn helper. buildJoinOn
   resolves each key's qualifier from the respective select list, so
   multi-way joins (a.join(b, [x]).join(c, [y])) emit correct refs
   even when y lives on a nested table.
2. requalifyTableSpec for inner_join / left_join now also requalifies
   the on predicate via a new requalifyPredicate walk that splits
   AttrRef names on '.' and remaps the qualifier prefix. Fixes
   nested-join-under-EXISTS (matching.06) and layered-summarize joins
   (summarize.07).
3. buildSelectQualifier (compile.ts) now looks up each attr's
   qualifier in the SELECT list, falling back to the left table
   alias only if absent. Fixes WHERE predicates that reference
   right-side join columns (summarize.07, left_join.07, and the
   general restrict-after-join pattern).

Removed now-unused helpers from SqlRelation: buildJoinPredicate,
getLeftAlias, getRightAlias, getSpecAlias.

Ports:
- join.test.ts: new file with 14 cases (10 ported, 4 blocked on
  prefix push-down / CROSS JOIN push-down)
- left_join.test.ts: rewrote from stubs to 8 cases (6 ported, 2
  blocked on defaults/COALESCE API)
- matching.06: flipped from it.fails to ported
- summarize.05: flipped from it.fails to ported
- summarize.07: flipped from it.todo to ported

Net: +19 ported cases (53 → 72/89). Tracking docs (INDEX.md, per-file
.md, CLAUDE_PLAN.md) refreshed to reflect the unblock.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
SqlRelation.prefix() and .suffix() now compile to SQL instead of
falling back to in-memory. Implementation is a thin layer over rename:
when relType is known, build a renaming map covering every attr
(respecting except) and delegate to this.rename().

While porting join.02 / join.10 (which combine rename + prefix), a
pre-existing bug surfaced: JOIN ON and WHERE clauses were referencing
the SELECT alias name, not the underlying physical column. That's
invalid SQL in most dialects — aliases aren't visible in ON/WHERE at
the same query level. Fix: buildJoinOn and buildSelectQualifier now
look up each attribute's underlying (qualifier, column) pair via the
select list, falling back to (fallback_alias, name) only for attrs
not contributed by any select item.

Ports:
- prefix.test.ts (new): prefix.01 — SELECT with AS 'supplier_<attr>'
- suffix.test.ts (new): suffix.01 — symmetric with prefix
- join.02 / join.10: flipped from it.todo to ported
- left_join.02: expected SQL corrected (t1.sid = t2.sid, not t2.id)
- restrict.07: flipped from divergent-snapshot to ported
  (t1.name, not t1.firstname)

Net: +4 ported cases (72 → 76/89). restrict.07 no longer divergent.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
SqlRelation.cross_join / .cross_product / .join(other, []) now compile
to CROSS JOIN instead of falling back to in-memory.

- New processCrossJoin processor: parallel to processJoin but emits a
  cross_join TableSpec (no ON) and skips buildJoinOn.
- Cross-join compile output switches from the comma form (FROM a, b)
  to the explicit CROSS JOIN form for consistency with bmg-rb.
- join(other, []) (empty keys) now routes to processCrossJoin rather
  than the fallback, matching bmg-rb's "empty join keys means cross
  product" semantic.
- Existing compile.test.ts CROSS JOIN expectation updated.

Ports:
- join.08 — flipped from it.todo to ported
- join.09 — flipped to ported (divergent on FROM reordering: bmg-rb
  reorders cross-first, bmg-sql emits in source order; same keys
  picked by both)

Net: +2 ported cases (76 → 78/89).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
left_join now accepts an optional `defaults` map. For unmatched
right-side rows (or matched rows where a right attribute is null), the
default value is substituted instead of null. Usage:

    suppliers.left_join(supplies, ['sid'], { pid: 'P9', qty: 0 })

Cross-package wiring:
- bmg core (sync + async): `left_join` operator and Relation /
  AsyncRelation methods accept a fourth `defaults` parameter. The
  merge step substitutes defaults when the right value is
  null/undefined. `defaults` attrs that aren't in the right side are
  still added to the result (so defaults can provide attrs an empty
  right relation doesn't carry).
- Types: new `LeftJoinDefaults<R>` exported from types.ts.
- bmg-sql: `SqlRelation.left_join` passes `defaults` through to
  `processJoin`, then wraps each defaulted select item in
  `COALESCE(expr, literal)` via a new `applyLeftJoinDefaults` helper.
  Literals parameterize through the standard param path, so e.g.
  `{pid: 'P9'}` becomes `COALESCE(t.pid, ?)` + param `'P9'`.

Unit tests added:
- tests/operators/left_join.test.ts: two cases covering the unmatched
  path and the matched-with-null path.

Ports:
- left_join.03 — flipped from it.todo to ported
- left_join.08 — flipped from it.todo to ported (divergent: bmg-rb
  uses CTE, bmg-sql uses derived-table wrap — same precedent as
  minus.03 / summarize.06 / summarize.07)

Net: +2 ported cases (78 → 80/89).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Current state: 80/89 ported (4 divergent, 9 blocked via it.todo)
- New "Unblocker pass 2" section capturing items 1-3 (prefix/suffix,
  CROSS JOIN, LEFT JOIN defaults) with case-impact breakdown
- Next up: reranked (LIKE predicate, transform, UNION ALL, constants,
  union push-down into branches)
- Operator checklist: left_join 8/8, join 14/14, restrict 9/11
- Added prefix (1/1) and suffix (1/1) to "Operators added outside
  the original 14"
- Blocked list: dropped prefix/suffix/CROSS JOIN/LEFT JOIN defaults
  (all resolved by pass 2); added UNION ALL and transform

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Cross-package feature spanning @enspirit/predicate, @enspirit/bmg-js,
and @enspirit/bmg-sql. Enables substring matching via LIKE.

@enspirit/predicate:
- New MatchPredicate kind (types.ts) with pattern and optional
  caseSensitive flag (default true).
- Pred.match(attr, pattern, opts?) builder.
- Evaluator returns false when the left value isn't a string; does a
  toUpperCase-style compare when caseSensitive is false.
- toSql emits `attr LIKE $1 ESCAPE '\'` (or UPPER(attr) LIKE UPPER($1)
  ESCAPE '\' for case-insensitive). Pattern is wrapped %...% by the
  compiler; the param value carries the wrapped form.
- Guards updated to recognize the new kind.

@enspirit/bmg-js (core):
- New rxmatch(attrs, pattern, { caseSensitive? }) operator on sync and
  async relations. Compiled as restrict(or(match(...), match(...), ...))
  under the hood.
- New RxmatchOptions type export.
- Core unit tests added (5 cases).

@enspirit/bmg-sql:
- compilePredicate gets a 'match' branch matching the predicate
  package's shape (LIKE ... ESCAPE '\' with optional UPPER wrapping).
- requalifyPredicate handles the new kind.
- SqlRelation.rxmatch pushes down via processWhere (builds the same
  or(match...) predicate directly).

Ports:
- rxmatch.01, rxmatch.02 (new test file)
- restrict.08, restrict.09 flipped from it.todo to ported

Net: +4 ported cases (80 → 84/89).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
UNION ALL option (cross-package):
- Relation.union / AsyncRelation.union now accept an options arg:
  { all?: boolean }. Default false (existing UNION behavior). When
  true, skip deduplication.
- In-memory: straight concatenation. Caller is responsible for the
  relation invariant — use only when branches are known disjoint or
  duplicates are acceptable.
- bmg-sql: plumbs the flag through to processMerge's pre-existing
  `all: boolean` parameter, emitting `UNION ALL`.
- New UnionOptions type export in core.
- Unit test added (bmg/tests/operators/union.test.ts).

Ports:
- union.03 — flipped from it.todo to ported.
- constants.01 — ported (processConstants was already wired up; only
  the snapshot test was missing).

Net: +2 ported cases. Totals recounted: 85/89 (prior totals were off
by 1; corrected in INDEX + CLAUDE_PLAN).

Remaining blocked: 4 transform.* cases (type-token API).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The biggest remaining black-box blocker: bmg core's Transformation
type was strictly JS-function-based, so no transform could push down
to SQL. This adds a declarative token channel.

bmg core:
- New TransformToken ('string' | 'integer' | 'date') and TransformStep
  (= TransformFunc | TransformToken). Transformation now accepts
  tokens anywhere a function was accepted.
- In-memory interpretation: 'string' → String(v), 'integer' →
  Math.trunc(Number(v)), 'date' → new Date(v). Null/undefined pass
  through untouched.
- Shared helper support/applyTransform.ts factored out of the sync
  and async operators so they stay in lockstep.
- 5 core unit tests covering single token, pipeline, bare-token,
  and mixed function + token pipelines.

bmg-sql:
- New CastExpr AST node ('cast', expr, type) compiling to
  CAST(expr AS type). type is opaque (emitted verbatim) so dialect-
  specific spellings flow through unchanged.
- New processTransform processor that wraps select items in
  CAST (for 'string' / 'integer') or func_call date() (for 'date')
  according to the pipeline. Tokens chain by nesting.
- SqlRelation.transform now routes to processTransform via a
  toTokenSpec helper that distinguishes token-only transforms (push
  down) from any-function transforms (fall back to in-memory).
- Compile's func_call branch emits the function name as-given
  (callers pick casing). Updated applyLeftJoinDefaults to use
  uppercase 'COALESCE' so existing left_join snapshots keep their
  shape.
- requalifyScalar handles 'cast'; hasComputedAttributes treats
  'cast' as computed (so subsequent ops wrap via ensureSelect).

Ports:
- transform.01-04 all flipped from it.todo to ported.
  - .01: CAST(col AS varchar(255))
  - .02: bare token shorthand (applies to all attrs)
  - .03: nested CAST pipeline [string, integer]
  - .04: date(CAST(...)) composition

Net: +4 ported cases (85 → 89/89). Every bmg-rb black-box case is
now covered.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
processWhere now recognizes `union` / `intersect` / `except` at the
top of an expression and recursively pushes the predicate into both
branches, rather than falling into the ensureSelect/fromSelf path
that wraps the set op in a derived table. Applies the identity:

    σ_p(A ∪ B) = σ_p(A) ∪ σ_p(B)       (and likewise for ∩, \)

One fewer subquery hop; matches bmg-rb's per-branch push-down shape.

Ports:
- restrict.10 — flipped from divergent to ported (exact bmg-rb match)
- restrict.11 — still divergent, but closer to bmg-rb: per-branch
  push-down is now done, only missing the aggressive contradiction
  folding (`city='Paris' AND city='London'` → empty). Snapshot
  updated and the divergence note sharpened.

Net: divergent snapshots shrunk (~5 → ~4). Total ported unchanged
(89/89 — still all covered).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants