@@ -92,8 +92,9 @@ export function createContractRegistry(opts = {}) {
9292 throw new TypeError ( `registerContract: ${ at } where is only valid with columns` )
9393 }
9494 if ( hasSql && contract . rowFilter ) {
95+ const sql = /** @type {string } */ ( rule . sql )
9596 for ( const col of contract . rowFilter . columns ) {
96- if ( ! rule . sql ?. includes ( col ) ) {
97+ if ( ! rawSqlProjectsColumn ( sql , col ) ) {
9798 throw new TypeError ( `registerContract: ${ at } raw sql must select rowFilter column '${ col } '` )
9899 }
99100 }
@@ -170,3 +171,127 @@ export function createContractRegistry(opts = {}) {
170171
171172 return { register, list }
172173}
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 - Z 0 - 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 + A S \s + ( " ? [ A - Z a - z 0 - 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