Files
jormun-db/dynamodb/filter.odin
2026-02-16 10:52:35 -05:00

822 lines
18 KiB
Odin

// FilterExpression and ProjectionExpression support
// FilterExpression: post-retrieval filter applied to Scan/Query results
// ProjectionExpression: return only specified attributes from items
package dynamodb
import "core:encoding/json"
import "core:strings"
// ============================================================================
// ProjectionExpression
//
// A comma-separated list of attribute names (with optional #name substitution)
// that specifies which attributes to return.
// ============================================================================
parse_projection_expression :: proc(
request_body: []byte,
attribute_names: Maybe(map[string]string),
) -> (paths: []string, ok: bool) {
data, parse_err := json.parse(request_body, allocator = context.temp_allocator)
if parse_err != nil {
return nil, false
}
defer json.destroy_value(data)
root, root_ok := data.(json.Object)
if !root_ok {
return nil, false
}
pe_val, found := root["ProjectionExpression"]
if !found {
return nil, false // absent is not an error, caller should check
}
pe_str, str_ok := pe_val.(json.String)
if !str_ok {
return nil, false
}
// Split by comma and resolve names
parts := strings.split(string(pe_str), ",")
result := make([dynamic]string)
for part in parts {
trimmed := strings.trim_space(part)
if len(trimmed) == 0 {
continue
}
resolved, res_ok := resolve_attribute_name(trimmed, attribute_names)
if !res_ok {
// Cleanup previously cloned strings
for path in result {
delete(path)
}
delete(result)
return nil, false
}
append(&result, strings.clone(resolved)) // Clone for safe storage
}
return result[:], true
}
// Apply projection to a single item — returns a new item with only the specified attributes
apply_projection :: proc(item: Item, projection: []string) -> Item {
if len(projection) == 0 {
// No projection — return a deep copy of the full item
return item_deep_copy(item)
}
projected := make(Item)
for path in projection {
if val, found := item[path]; found {
projected[strings.clone(path)] = attr_value_deep_copy(val)
}
}
return projected
}
// Deep copy an entire item
item_deep_copy :: proc(item: Item) -> Item {
result := make(Item)
for key, val in item {
result[strings.clone(key)] = attr_value_deep_copy(val)
}
return result
}
// ============================================================================
// FilterExpression
//
// A condition expression applied post-retrieval. Supports:
// - Comparisons: path = :val, path <> :val, path < :val, etc.
// - BETWEEN: path BETWEEN :lo AND :hi
// - IN: path IN (:v1, :v2, :v3)
// - begins_with: begins_with(path, :prefix)
// - contains: contains(path, :substr)
// - attribute_exists(path)
// - attribute_not_exists(path)
// - AND / OR / NOT combinators
//
// This is a recursive-descent parser for condition expressions.
// ============================================================================
// Parsed filter node (expression tree)
Filter_Node_Type :: enum {
Comparison, // path op value
Between, // path BETWEEN lo AND hi
In, // path IN (v1, v2, ...)
Begins_With, // begins_with(path, value)
Contains, // contains(path, value)
Attribute_Exists, // attribute_exists(path)
Attribute_Not_Exists, // attribute_not_exists(path)
And, // left AND right
Or, // left OR right
Not, // NOT child
}
Comparison_Op :: enum {
EQ, // =
NE, // <>
LT, // <
LE, // <=
GT, // >
GE, // >=
}
Filter_Node :: struct {
type: Filter_Node_Type,
// For Comparison
path: string,
comp_op: Comparison_Op,
value: Attribute_Value,
// For Between
value2: Maybe(Attribute_Value),
// For In
in_values: []Attribute_Value,
// For And/Or
left: ^Filter_Node,
right: ^Filter_Node,
// For Not
child: ^Filter_Node,
}
filter_node_destroy :: proc(node: ^Filter_Node) {
if node == nil {
return
}
attr_value_destroy(&node.value)
if v2, ok := node.value2.?; ok {
v2_copy := v2
attr_value_destroy(&v2_copy)
}
for &iv in node.in_values {
attr_value_destroy(&iv)
}
if node.in_values != nil {
delete(node.in_values)
}
if node.left != nil {
filter_node_destroy(node.left)
}
if node.right != nil {
filter_node_destroy(node.right)
}
if node.child != nil {
filter_node_destroy(node.child)
}
// Free the node itself (allocated with new(Filter_Node))
free(node)
}
// ============================================================================
// Filter Expression Parser
// ============================================================================
parse_filter_expression :: proc(
expression: string,
attribute_names: Maybe(map[string]string),
attribute_values: map[string]Attribute_Value,
) -> (node: ^Filter_Node, ok: bool) {
t := tokenizer_init(expression)
node, ok = parse_or_expr(&t, attribute_names, attribute_values)
if !ok {
return nil, false
}
// Verify all tokens were consumed (no trailing garbage)
if trailing := tokenizer_next(&t); trailing != nil {
filter_node_destroy(node)
return nil, false
}
return node, true
}
parse_or_expr :: proc(
t: ^Tokenizer,
names: Maybe(map[string]string),
values: map[string]Attribute_Value,
) -> (^Filter_Node, bool) {
left, left_ok := parse_and_expr(t, names, values)
if !left_ok {
return nil, false
}
for {
saved_pos := t.pos
tok_maybe := tokenizer_next(t)
tok, has := tok_maybe.?
if !has {
break
}
if strings.equal_fold(tok, "OR") {
right, right_ok := parse_and_expr(t, names, values)
if !right_ok {
filter_node_destroy(left)
return nil, false
}
parent := new(Filter_Node)
parent.type = .Or
parent.left = left
parent.right = right
left = parent
} else {
t.pos = saved_pos
break
}
}
return left, true
}
parse_and_expr :: proc(
t: ^Tokenizer,
names: Maybe(map[string]string),
values: map[string]Attribute_Value,
) -> (^Filter_Node, bool) {
left, left_ok := parse_not_expr(t, names, values)
if !left_ok {
return nil, false
}
for {
saved_pos := t.pos
tok_maybe := tokenizer_next(t)
tok, has := tok_maybe.?
if !has {
break
}
if strings.equal_fold(tok, "AND") {
right, right_ok := parse_not_expr(t, names, values)
if !right_ok {
filter_node_destroy(left)
return nil, false
}
parent := new(Filter_Node)
parent.type = .And
parent.left = left
parent.right = right
left = parent
} else {
t.pos = saved_pos
break
}
}
return left, true
}
parse_not_expr :: proc(
t: ^Tokenizer,
names: Maybe(map[string]string),
values: map[string]Attribute_Value,
) -> (^Filter_Node, bool) {
saved_pos := t.pos
tok_maybe := tokenizer_next(t)
tok, has := tok_maybe.?
if !has {
return nil, false
}
if strings.equal_fold(tok, "NOT") {
child, child_ok := parse_primary_expr(t, names, values)
if !child_ok {
return nil, false
}
node := new(Filter_Node)
node.type = .Not
node.child = child
return node, true
}
t.pos = saved_pos
return parse_primary_expr(t, names, values)
}
parse_primary_expr :: proc(
t: ^Tokenizer,
names: Maybe(map[string]string),
values: map[string]Attribute_Value,
) -> (^Filter_Node, bool) {
first_tok, first_ok := next_token(t)
if !first_ok {
return nil, false
}
// Parenthesized expression
if first_tok == "(" {
inner, inner_ok := parse_or_expr(t, names, values)
if !inner_ok {
return nil, false
}
rparen, rp_ok := next_token(t)
if !rp_ok || rparen != ")" {
filter_node_destroy(inner)
return nil, false
}
return inner, true
}
// Function-style: begins_with, contains, attribute_exists, attribute_not_exists
if strings.equal_fold(first_tok, "begins_with") {
return parse_filter_begins_with(t, names, values)
}
if strings.equal_fold(first_tok, "contains") {
return parse_filter_contains(t, names, values)
}
if strings.equal_fold(first_tok, "attribute_exists") {
return parse_filter_attr_exists(t, names, true)
}
if strings.equal_fold(first_tok, "attribute_not_exists") {
return parse_filter_attr_exists(t, names, false)
}
// Comparison, BETWEEN, or IN: path op value
path, path_ok := resolve_attribute_name(first_tok, names)
if !path_ok {
return nil, false
}
op_tok, op_ok := next_token(t)
if !op_ok {
return nil, false
}
// BETWEEN
if strings.equal_fold(op_tok, "BETWEEN") {
return parse_filter_between(t, path, names, values)
}
// IN
if strings.equal_fold(op_tok, "IN") {
return parse_filter_in(t, path, names, values)
}
// Comparison operators
comp_op: Comparison_Op
if op_tok == "=" {
comp_op = .EQ
} else if op_tok == "<>" {
comp_op = .NE
} else if op_tok == "<" {
comp_op = .LT
} else if op_tok == "<=" {
comp_op = .LE
} else if op_tok == ">" {
comp_op = .GT
} else if op_tok == ">=" {
comp_op = .GE
} else {
return nil, false
}
val_tok, vt_ok := next_token(t)
if !vt_ok {
return nil, false
}
val, val_ok := resolve_attribute_value(val_tok, values)
if !val_ok {
return nil, false
}
node := new(Filter_Node)
node.type = .Comparison
node.path = path
node.comp_op = comp_op
node.value = val
return node, true
}
parse_filter_begins_with :: proc(
t: ^Tokenizer,
names: Maybe(map[string]string),
values: map[string]Attribute_Value,
) -> (^Filter_Node, bool) {
lparen, lp_ok := next_token(t)
if !lp_ok || lparen != "(" {
return nil, false
}
path_tok, path_ok := next_token(t)
if !path_ok {
return nil, false
}
path, path_resolved := resolve_attribute_name(path_tok, names)
if !path_resolved {
return nil, false
}
comma, comma_ok := next_token(t)
if !comma_ok || comma != "," {
return nil, false
}
val_tok, vt_ok := next_token(t)
if !vt_ok {
return nil, false
}
val, val_ok := resolve_attribute_value(val_tok, values)
if !val_ok {
return nil, false
}
rparen, rp_ok := next_token(t)
if !rp_ok || rparen != ")" {
attr_value_destroy(&val)
return nil, false
}
node := new(Filter_Node)
node.type = .Begins_With
node.path = path
node.value = val
return node, true
}
parse_filter_contains :: proc(
t: ^Tokenizer,
names: Maybe(map[string]string),
values: map[string]Attribute_Value,
) -> (^Filter_Node, bool) {
lparen, lp_ok := next_token(t)
if !lp_ok || lparen != "(" {
return nil, false
}
path_tok, path_ok := next_token(t)
if !path_ok {
return nil, false
}
path, path_resolved := resolve_attribute_name(path_tok, names)
if !path_resolved {
return nil, false
}
comma, comma_ok := next_token(t)
if !comma_ok || comma != "," {
return nil, false
}
val_tok, vt_ok := next_token(t)
if !vt_ok {
return nil, false
}
val, val_ok := resolve_attribute_value(val_tok, values)
if !val_ok {
return nil, false
}
rparen, rp_ok := next_token(t)
if !rp_ok || rparen != ")" {
attr_value_destroy(&val)
return nil, false
}
node := new(Filter_Node)
node.type = .Contains
node.path = path
node.value = val
return node, true
}
parse_filter_attr_exists :: proc(
t: ^Tokenizer,
names: Maybe(map[string]string),
exists: bool,
) -> (^Filter_Node, bool) {
lparen, lp_ok := next_token(t)
if !lp_ok || lparen != "(" {
return nil, false
}
path_tok, path_ok := next_token(t)
if !path_ok {
return nil, false
}
path, path_resolved := resolve_attribute_name(path_tok, names)
if !path_resolved {
return nil, false
}
rparen, rp_ok := next_token(t)
if !rp_ok || rparen != ")" {
return nil, false
}
node := new(Filter_Node)
node.type = .Attribute_Exists if exists else .Attribute_Not_Exists
node.path = path
return node, true
}
parse_filter_between :: proc(
t: ^Tokenizer,
path: string,
names: Maybe(map[string]string),
values: map[string]Attribute_Value,
) -> (^Filter_Node, bool) {
lo_tok, lo_ok := next_token(t)
if !lo_ok {
return nil, false
}
lo_val, lo_val_ok := resolve_attribute_value(lo_tok, values)
if !lo_val_ok {
return nil, false
}
and_tok, and_ok := next_token(t)
if !and_ok || !strings.equal_fold(and_tok, "AND") {
attr_value_destroy(&lo_val)
return nil, false
}
hi_tok, hi_ok := next_token(t)
if !hi_ok {
attr_value_destroy(&lo_val)
return nil, false
}
hi_val, hi_val_ok := resolve_attribute_value(hi_tok, values)
if !hi_val_ok {
attr_value_destroy(&lo_val)
return nil, false
}
node := new(Filter_Node)
node.type = .Between
node.path = path
node.value = lo_val
node.value2 = hi_val
return node, true
}
parse_filter_in :: proc(
t: ^Tokenizer,
path: string,
names: Maybe(map[string]string),
values: map[string]Attribute_Value,
) -> (^Filter_Node, bool) {
lparen, lp_ok := next_token(t)
if !lp_ok || lparen != "(" {
return nil, false
}
in_vals := make([dynamic]Attribute_Value)
for {
val_tok, vt_ok := next_token(t)
if !vt_ok {
for &v in in_vals {
attr_value_destroy(&v)
}
delete(in_vals)
return nil, false
}
val, val_ok := resolve_attribute_value(val_tok, values)
if !val_ok {
for &v in in_vals {
attr_value_destroy(&v)
}
delete(in_vals)
return nil, false
}
append(&in_vals, val)
sep_tok, sep_ok := next_token(t)
if !sep_ok {
for &v in in_vals {
attr_value_destroy(&v)
}
delete(in_vals)
return nil, false
}
if sep_tok == ")" {
break
}
if sep_tok != "," {
for &v in in_vals {
attr_value_destroy(&v)
}
delete(in_vals)
return nil, false
}
}
node := new(Filter_Node)
node.type = .In
node.path = path
node.in_values = in_vals[:]
return node, true
}
// ============================================================================
// Filter Expression Evaluation
// ============================================================================
evaluate_filter :: proc(item: Item, node: ^Filter_Node) -> bool {
if node == nil {
return true
}
switch node.type {
case .Comparison:
attr, found := item[node.path]
if !found {
return false
}
return evaluate_comparison(attr, node.comp_op, node.value)
case .Between:
attr, found := item[node.path]
if !found {
return false
}
lo_cmp := compare_attribute_values(attr, node.value)
if v2, ok := node.value2.?; ok {
hi_cmp := compare_attribute_values(attr, v2)
return lo_cmp >= 0 && hi_cmp <= 0
}
return false
case .In:
attr, found := item[node.path]
if !found {
return false
}
for in_val in node.in_values {
if compare_attribute_values(attr, in_val) == 0 {
return true
}
}
return false
case .Begins_With:
attr, found := item[node.path]
if !found {
return false
}
attr_str, attr_ok := attr_value_to_string_for_compare(attr)
val_str, val_ok := attr_value_to_string_for_compare(node.value)
if !attr_ok || !val_ok {
return false
}
return strings.has_prefix(attr_str, val_str)
case .Contains:
attr, found := item[node.path]
if !found {
return false
}
return evaluate_contains(attr, node.value)
case .Attribute_Exists:
_, found := item[node.path]
return found
case .Attribute_Not_Exists:
_, found := item[node.path]
return !found
case .And:
return evaluate_filter(item, node.left) && evaluate_filter(item, node.right)
case .Or:
return evaluate_filter(item, node.left) || evaluate_filter(item, node.right)
case .Not:
return !evaluate_filter(item, node.child)
}
return false
}
evaluate_comparison :: proc(attr: Attribute_Value, op: Comparison_Op, val: Attribute_Value) -> bool {
cmp := compare_attribute_values(attr, val)
// -2 means types are incomparable - all comparisons return false
// (matches DynamoDB behavior: mixed-type comparisons always fail)
if cmp == -2 {
return false
}
switch op {
case .EQ: return cmp == 0
case .NE: return cmp != 0
case .LT: return cmp < 0
case .LE: return cmp <= 0
case .GT: return cmp > 0
case .GE: return cmp >= 0
}
return false
}
evaluate_contains :: proc(attr: Attribute_Value, val: Attribute_Value) -> bool {
// For strings: substring check
#partial switch a in attr {
case String:
if v, ok := val.(String); ok {
return strings.contains(string(a), string(v))
}
case String_Set:
if v, ok := val.(String); ok {
for s in a {
if s == string(v) {
return true
}
}
}
case DDB_Number_Set:
if v, ok := val.(DDB_Number); ok {
for num in a {
if compare_ddb_numbers(num, v) == 0 {
return true
}
}
}
case List:
for item in a {
if compare_attribute_values(item, val) == 0 {
return true
}
}
}
return false
}
// Compare two AttributeValues. Returns <0, 0, or >0.
// For mixed types, returns -2 (not comparable).
compare_attribute_values :: proc(a: Attribute_Value, b: Attribute_Value) -> int {
a_str, a_ok := attr_value_to_string_for_compare(a)
b_str, b_ok := attr_value_to_string_for_compare(b)
if !a_ok || !b_ok {
// Try bool comparison
a_bool, a_is_bool := a.(Bool)
b_bool, b_is_bool := b.(Bool)
if a_is_bool && b_is_bool {
if bool(a_bool) == bool(b_bool) {
return 0
}
return -2
}
return -2
}
// For Numbers, do with DDB_Number comparison
_, a_is_num := a.(DDB_Number)
_, b_is_num := b.(DDB_Number)
if a_is_num && b_is_num {
a_num := a.(DDB_Number)
b_num := b.(DDB_Number)
return compare_ddb_numbers(a_num, b_num)
}
return strings.compare(a_str, b_str)
}
// ============================================================================
// Request parsing helpers for FilterExpression
// ============================================================================
parse_filter_expression_string :: proc(request_body: []byte) -> (expr: string, ok: bool) {
data, parse_err := json.parse(request_body, allocator = context.temp_allocator)
if parse_err != nil {
return
}
defer json.destroy_value(data)
root, root_ok := data.(json.Object)
if !root_ok {
return
}
fe_val, found := root["FilterExpression"]
if !found {
return
}
fe_str, str_ok := fe_val.(json.String)
if !str_ok {
return
}
expr = string(fe_str)
ok = true
return
}