Skip to content

Commit 8fbdfb5

Browse files
committed
Feat: Support regexp
1 parent 4a8f0f7 commit 8fbdfb5

12 files changed

Lines changed: 80 additions & 2 deletions

File tree

native/parser/grammar/VoightLexer.g4

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ AND: A N D;
2222
OR: O R;
2323
NOT: N O T;
2424
LIKE: L I K E;
25+
REGEXP: R E G E X P;
26+
RLIKE: R L I K E;
2527
IS: I S;
2628
NULL_SQL: N U L L;
2729
TRUE_SQL: T R U E;

native/parser/grammar/VoightParser.g4

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ comparisonOperator
131131
| GT
132132
| GTE
133133
| LIKE
134+
| REGEXP
135+
| RLIKE
134136
;
135137

136138
additiveExpression

native/parser/parser_json.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,12 @@ class NativeAstBuilder {
738738
if (ctx->LIKE() != nullptr) {
739739
return "LIKE";
740740
}
741+
if (ctx->REGEXP() != nullptr) {
742+
return "REGEXP";
743+
}
744+
if (ctx->RLIKE() != nullptr) {
745+
return "RLIKE";
746+
}
741747
throw ParsingError("Unsupported comparison operator.", span_start(ctx), span_end(ctx));
742748
}
743749

packages/voight/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@voight8/voight",
3-
"version": "0.1.8",
3+
"version": "0.1.9",
44
"description": "TypeScript SQL compiler and policy engine for a restricted SELECT subset of MySQL.",
55
"keywords": [
66
"compiler",

packages/voight/src/ast/query-ast-schema.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ export interface BinaryExpressionNode extends AstNode<"BinaryExpression"> {
171171
| ">"
172172
| ">="
173173
| "LIKE"
174+
| "REGEXP"
175+
| "RLIKE"
174176
| "AND"
175177
| "OR";
176178
readonly left: ExpressionNode;
@@ -418,7 +420,7 @@ const queryAstScope = scope({
418420
kind: "'BinaryExpression'",
419421
span: "SourceSpan",
420422
operator:
421-
"'+' | '-' | '*' | '/' | '%' | '=' | '!=' | '<' | '<=' | '>' | '>=' | 'LIKE' | 'AND' | 'OR'",
423+
"'+' | '-' | '*' | '/' | '%' | '=' | '!=' | '<' | '<=' | '>' | '>=' | 'LIKE' | 'REGEXP' | 'RLIKE' | 'AND' | 'OR'",
422424
left: "ExpressionNode",
423425
right: "ExpressionNode",
424426
},

packages/voight/src/compiler/rewriter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const BINARY_OPERATORS = new Set([
2626
">",
2727
">=",
2828
"LIKE",
29+
"REGEXP",
30+
"RLIKE",
2931
"AND",
3032
"OR",
3133
]);

packages/voight/src/emitter/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,8 @@ function binaryPrecedence(operator: BinaryExpressionNode["operator"]): number {
361361
case ">":
362362
case ">=":
363363
case "LIKE":
364+
case "REGEXP":
365+
case "RLIKE":
364366
return 3;
365367
case "+":
366368
case "-":
@@ -369,6 +371,9 @@ function binaryPrecedence(operator: BinaryExpressionNode["operator"]): number {
369371
case "/":
370372
case "%":
371373
return 5;
374+
default:
375+
operator satisfies never;
376+
throw new Error(`Unsupported binary operator: ${operator}`);
372377
}
373378
}
374379

packages/voight/src/policies/supported-operators.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const SUPPORTED_BINARY_OPERATORS = new Set([
2121
">",
2222
">=",
2323
"LIKE",
24+
"REGEXP",
25+
"RLIKE",
2426
"AND",
2527
"OR",
2628
]);

packages/voight/tests/integration/security/tenant-scoping-regression.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ describe("FIXED: expression subqueries are now tenant-scoped", () => {
5353
expect(result.emitted!.sql).toContain("`timeseries`.`tenant_id` = 'tenant-123'");
5454
});
5555

56+
test("REGEXP predicate — scoped table still receives tenant predicate", () => {
57+
const result = compileTenantScoped(
58+
"SELECT metric FROM timeseries WHERE metric REGEXP '^login'",
59+
);
60+
expect(result.ok).toBe(true);
61+
expect(result.emitted!.sql).toContain("`timeseries`.`tenant_id` = 'tenant-123'");
62+
expect(result.emitted!.sql).toContain("`timeseries`.`metric` REGEXP '^login'");
63+
});
64+
5665
test("IN subquery — tenant predicate injected", () => {
5766
const result = compileTenantScoped(
5867
"SELECT id FROM users WHERE id IN (SELECT timeseries.id FROM timeseries)",

packages/voight/tests/integration/security/wildcard-projection-regression.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ describe("FIXED: wildcard projection respects catalog selectability", () => {
8080
expect(result.diagnostics[0]?.code).toBe(DiagnosticCode.NonSelectableColumn);
8181
});
8282

83+
test("hidden columns are rejected in REGEXP predicates", () => {
84+
const operand = compileScoped("SELECT id FROM users WHERE tenant_id REGEXP '^123$'");
85+
expect(operand.ok).toBe(false);
86+
expect(operand.diagnostics[0]?.code).toBe(DiagnosticCode.NonSelectableColumn);
87+
88+
const pattern = compileScoped("SELECT id FROM users WHERE name REGEXP tenant_id");
89+
expect(pattern.ok).toBe(false);
90+
expect(pattern.diagnostics[0]?.code).toBe(DiagnosticCode.NonSelectableColumn);
91+
});
92+
8393
test("hidden columns are rejected in BETWEEN predicates", () => {
8494
const operand = compileScoped("SELECT id FROM users WHERE tenant_id BETWEEN 'a' AND 'z'");
8595
expect(operand.ok).toBe(false);

0 commit comments

Comments
 (0)