Skip to content

Commit fe01144

Browse files
philcunliffeclaude
andauthored
Shared-scan graph projection: one scan per contract, declarative rule predicates (LLP 0095/0096) (#291)
* Shared-scan graph projection: one scan per contract, declarative predicates (LLP 0095/0096) projectGraph ran every contract rule as an independent full SQL scan with refresh:'always', and the LLP 0026 aux filter prepended `attributes` to each rule and JSON-parsed it per rule per row. At 25 rules that meant 25 full table sweeps per projection: ~35GB read and 45+ minutes for a ~1.3GB source on a production hypaware-server deployment (LLP 0095). - project.js: one shared scan per contract over the union of declarative rules' columns; per-rule where predicates (eq/in/likePrefix, SQL null semantics) evaluated in JS; raw-SQL rules stay supported, run standalone, grouped by identical SQL text. - Contract gains rowFilter, evaluated once per row on both paths; the ai-gateway contract's aux filter moves there. - ai-gateway-graph: 21 of 25 rules migrated to columns/where; the two content_text prefix surfaces (x node+edge) stay raw SQL so the table's largest column keeps its server-side pushdown. - Registry validates the new shapes; equivalence test pins the JS evaluator to the SQL engine's semantics over a fixture hitting every predicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Harden raw-rule column guard + cover likePrefix + @import types (neutral review, PR #291) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 879184e commit fe01144

11 files changed

Lines changed: 746 additions & 125 deletions

hypaware-core/plugins-workspace/ai-gateway-graph/src/graph_contract.js

Lines changed: 68 additions & 57 deletions
Large diffs are not rendered by default.

hypaware-core/plugins-workspace/ai-gateway-graph/src/types.d.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,24 @@
33
/** A materialized graph row (node or edge), keyed by column name. */
44
export type GraphRow = Record<string, unknown>
55

6-
/** One T0 contract rule: a read-only SELECT plus a row mapper. */
6+
/** A declarative rule filter (LLP 0096): AND of eq / in / likePrefix, SQL null semantics. */
7+
export interface RulePredicate {
8+
eq?: Record<string, string>
9+
in?: Record<string, string[]>
10+
likePrefix?: Record<string, string>
11+
}
12+
13+
/**
14+
* One T0 contract rule: a source read plus a row mapper. Declarative
15+
* `columns` (+ optional `where`) joins the contract's shared scan; raw `sql`
16+
* runs standalone (LLP 0096). Exactly one of the two.
17+
*/
718
export interface ContractRule {
819
kind: 'node' | 'edge'
920
type: string
10-
sql: string
21+
sql?: string
22+
columns?: string[]
23+
where?: RulePredicate
1124
toRow(row: Record<string, unknown>): GraphRow | null
1225
}
1326

hypaware-core/plugins-workspace/context-graph/src/contract-registry.js

Lines changed: 205 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,24 @@ export function createContractRegistry(opts = {}) {
4848
throw new TypeError(`registerContract: '${contract.name}' rules must be a non-empty array`)
4949
}
5050
// Validate each rule's shape at registration, not at projection time: the
51-
// engine reads `kind`/`sql`/`toRow` directly (project.js) and routes by
52-
// `kind`, so a connector typo would otherwise surface as a confusing
53-
// mid-projection failure (or silently route rows into the wrong target
54-
// map) far from the contract that caused it.
51+
// engine reads `kind`/`sql`/`columns`/`where`/`toRow` directly
52+
// (project.js) and routes by `kind`, so a connector typo would otherwise
53+
// surface as a confusing mid-projection failure (or silently route rows
54+
// into the wrong target map) far from the contract that caused it.
55+
// @ref LLP 0096#decision [implements]: exactly one read form per rule; `where` only rides `columns`; raw SQL must carry the rowFilter's columns itself
56+
if (contract.rowFilter !== undefined) {
57+
const filter = contract.rowFilter
58+
const at = `'${contract.name}' rowFilter`
59+
if (!filter || typeof filter !== 'object') {
60+
throw new TypeError(`registerContract: ${at} must be an object`)
61+
}
62+
if (!Array.isArray(filter.columns) || filter.columns.length === 0 || filter.columns.some((c) => typeof c !== 'string' || c.length === 0)) {
63+
throw new TypeError(`registerContract: ${at} columns must be non-empty strings`)
64+
}
65+
if (typeof filter.keep !== 'function') {
66+
throw new TypeError(`registerContract: ${at} keep must be a function`)
67+
}
68+
}
5569
contract.rules.forEach((rule, i) => {
5670
const at = `'${contract.name}' rule ${i}`
5771
if (!rule || typeof rule !== 'object') {
@@ -63,8 +77,27 @@ export function createContractRegistry(opts = {}) {
6377
if (typeof rule.type !== 'string' || rule.type.length === 0) {
6478
throw new TypeError(`registerContract: ${at} type must be a non-empty string`)
6579
}
66-
if (typeof rule.sql !== 'string' || rule.sql.length === 0) {
67-
throw new TypeError(`registerContract: ${at} sql must be a non-empty string`)
80+
const hasSql = typeof rule.sql === 'string' && rule.sql.length > 0
81+
const hasColumns = Array.isArray(rule.columns)
82+
if (hasSql === hasColumns) {
83+
throw new TypeError(`registerContract: ${at} must carry exactly one of sql or columns`)
84+
}
85+
if (hasColumns) {
86+
const cols = /** @type {unknown[]} */ (rule.columns)
87+
if (cols.length === 0 || cols.some((c) => typeof c !== 'string' || c.length === 0)) {
88+
throw new TypeError(`registerContract: ${at} columns must be non-empty strings`)
89+
}
90+
if (rule.where !== undefined) validatePredicate(rule.where, at)
91+
} else if (rule.where !== undefined) {
92+
throw new TypeError(`registerContract: ${at} where is only valid with columns`)
93+
}
94+
if (hasSql && contract.rowFilter) {
95+
const sql = /** @type {string} */ (rule.sql)
96+
for (const col of contract.rowFilter.columns) {
97+
if (!rawSqlProjectsColumn(sql, col)) {
98+
throw new TypeError(`registerContract: ${at} raw sql must select rowFilter column '${col}'`)
99+
}
100+
}
68101
}
69102
if (typeof rule.toRow !== 'function') {
70103
throw new TypeError(`registerContract: ${at} toRow must be a function`)
@@ -86,6 +119,48 @@ export function createContractRegistry(opts = {}) {
86119
})
87120
}
88121

122+
/**
123+
* A `where` must be built from the three supported predicate shapes only,
124+
* with the value types the JS evaluator expects: anything else would
125+
* silently match nothing at projection time.
126+
*
127+
* @param {unknown} where
128+
* @param {string} at
129+
*/
130+
function validatePredicate(where, at) {
131+
if (!where || typeof where !== 'object') {
132+
throw new TypeError(`registerContract: ${at} where must be an object`)
133+
}
134+
const w = /** @type {Record<string, unknown>} */ (where)
135+
for (const key of Object.keys(w)) {
136+
if (key !== 'eq' && key !== 'in' && key !== 'likePrefix') {
137+
throw new TypeError(`registerContract: ${at} where.${key} is not a supported predicate (eq, in, likePrefix)`)
138+
}
139+
}
140+
for (const shape of ['eq', 'likePrefix']) {
141+
const block = w[shape]
142+
if (block === undefined) continue
143+
if (!block || typeof block !== 'object') {
144+
throw new TypeError(`registerContract: ${at} where.${shape} must be an object`)
145+
}
146+
for (const [col, value] of Object.entries(block)) {
147+
if (typeof value !== 'string' || value.length === 0) {
148+
throw new TypeError(`registerContract: ${at} where.${shape}.${col} must be a non-empty string`)
149+
}
150+
}
151+
}
152+
if (w.in !== undefined) {
153+
if (!w.in || typeof w.in !== 'object') {
154+
throw new TypeError(`registerContract: ${at} where.in must be an object`)
155+
}
156+
for (const [col, list] of Object.entries(w.in)) {
157+
if (!Array.isArray(list) || list.length === 0 || list.some((v) => typeof v !== 'string' || v.length === 0)) {
158+
throw new TypeError(`registerContract: ${at} where.in.${col} must be a non-empty array of strings`)
159+
}
160+
}
161+
}
162+
}
163+
89164
/**
90165
* All registered contracts, name-sorted so projection order is stable.
91166
* @returns {Contract[]}
@@ -96,3 +171,127 @@ export function createContractRegistry(opts = {}) {
96171

97172
return { register, list }
98173
}
174+
175+
/**
176+
* True when a raw rule's SQL provably projects `col` in its top-level
177+
* SELECT list, so the contract's rowFilter (which reads `row[col]`) has the
178+
* column to test. A loose `sql.includes(col)` accepts false positives (a
179+
* `WHERE attributes IS NOT NULL`, or a different column whose name merely
180+
* contains `col`), so match the projection list only: take the identifiers
181+
* between the first top-level SELECT and its FROM, accept `*` / `table.*`,
182+
* and reduce each item to its output name (the alias after AS, or the column
183+
* past a `table.` qualifier). Anything ambiguous (a computed expression with
184+
* no alias) is treated as not-a-match, so the guard stays conservative and
185+
* rejects registration when the column is not provably projected. This is a
186+
* focused projection check, not a general SQL parser.
187+
*
188+
* @param {string} sql
189+
* @param {string} col
190+
* @returns {boolean}
191+
*/
192+
function rawSqlProjectsColumn(sql, col) {
193+
const projection = selectProjection(sql)
194+
if (projection === undefined) return false
195+
for (const item of splitTopLevel(projection)) {
196+
const name = projectionOutputName(item)
197+
if (name === '*' || name === col) return true
198+
}
199+
return false
200+
}
201+
202+
/**
203+
* The text between the first top-level `SELECT` and its matching `FROM`
204+
* (both matched as standalone, case-insensitive keywords at parenthesis
205+
* depth 0, so a subquery's SELECT/FROM never leaks in). Undefined when the
206+
* SQL has no top-level `SELECT ... FROM`.
207+
*
208+
* @param {string} sql
209+
* @returns {string | undefined}
210+
*/
211+
function selectProjection(sql) {
212+
const upper = sql.toUpperCase()
213+
let depth = 0
214+
let selectEnd = -1
215+
for (let i = 0; i < sql.length; i++) {
216+
const ch = sql[i]
217+
if (ch === '(') depth++
218+
else if (ch === ')') depth--
219+
else if (depth === 0 && selectEnd === -1 && matchKeyword(upper, i, 'SELECT')) {
220+
selectEnd = i + 'SELECT'.length
221+
i = selectEnd - 1
222+
} else if (depth === 0 && selectEnd !== -1 && matchKeyword(upper, i, 'FROM')) {
223+
return sql.slice(selectEnd, i)
224+
}
225+
}
226+
return undefined
227+
}
228+
229+
/**
230+
* True when `kw` sits at index `i` of `upper` as a whole word (its
231+
* neighbours are not identifier characters), so `FROM` matches but
232+
* `FROMAGE` or a `from_x` column does not.
233+
*
234+
* @param {string} upper
235+
* @param {number} i
236+
* @param {string} kw
237+
* @returns {boolean}
238+
*/
239+
function matchKeyword(upper, i, kw) {
240+
if (!upper.startsWith(kw, i)) return false
241+
const boundary = (/** @type {string | undefined} */ c) => c === undefined || !/[A-Z0-9_]/.test(c)
242+
return boundary(upper[i - 1]) && boundary(upper[i + kw.length])
243+
}
244+
245+
/**
246+
* Split on commas at parenthesis depth 0, so a `f(a, b)` projection item
247+
* stays whole.
248+
*
249+
* @param {string} s
250+
* @returns {string[]}
251+
*/
252+
function splitTopLevel(s) {
253+
/** @type {string[]} */
254+
const parts = []
255+
let depth = 0
256+
let start = 0
257+
for (let i = 0; i < s.length; i++) {
258+
const ch = s[i]
259+
if (ch === '(') depth++
260+
else if (ch === ')') depth--
261+
else if (ch === ',' && depth === 0) {
262+
parts.push(s.slice(start, i))
263+
start = i + 1
264+
}
265+
}
266+
parts.push(s.slice(start))
267+
return parts
268+
}
269+
270+
/**
271+
* The output name a single projection item exposes on the result row: the
272+
* alias after `AS`, `*` for a wildcard (`*` or `table.*`), or the column
273+
* past a `table.` qualifier. A bare computed expression has no derivable
274+
* column name and returns its trimmed text, which will not match a plain
275+
* column name, keeping the guard conservative.
276+
*
277+
* @param {string} item
278+
* @returns {string}
279+
*/
280+
function projectionOutputName(item) {
281+
let s = item.trim()
282+
if (s.length === 0) return ''
283+
const asMatch = /\s+AS\s+("?[A-Za-z0-9_]+"?)\s*$/i.exec(s)
284+
if (asMatch) return stripQuotes(asMatch[1])
285+
if (s === '*' || s.endsWith('.*')) return '*'
286+
const dot = s.lastIndexOf('.')
287+
if (dot !== -1) s = s.slice(dot + 1)
288+
return stripQuotes(s)
289+
}
290+
291+
/**
292+
* @param {string} s
293+
* @returns {string}
294+
*/
295+
function stripQuotes(s) {
296+
return s.startsWith('"') && s.endsWith('"') && s.length >= 2 ? s.slice(1, -1) : s
297+
}

0 commit comments

Comments
 (0)