-- Query syntax parser module local M = {} local utils = require('notex.utils') -- Parse query string into structured object function M.parse(query_string) local result = { filters = {}, conditions = nil, order_by = nil, group_by = nil, limit = nil, raw_query = query_string, parse_errors = {} } if not query_string or query_string == "" then table.insert(result.parse_errors, "Empty query string") return result end -- Extract query block from markdown local query_content = M.extract_query_block(query_string) if not query_content then table.insert(result.parse_errors, "No valid query block found") return result end -- Parse query lines local lines = M.split_lines(query_content) local current_section = "filters" -- filters, where, order_by, group_by, limit for _, line in ipairs(lines) do line = line:trim() if line == "" or line:match("^%s*%-%-%-") then -- Skip empty lines and comments continue end -- Detect section changes local section = M.detect_section(line) if section then current_section = section continue end -- Parse based on current section if current_section == "filters" then M.parse_filter_line(line, result) elseif current_section == "where" then M.parse_condition_line(line, result) elseif current_section == "order_by" then M.parse_order_by_line(line, result) elseif current_section == "group_by" then M.parse_group_by_line(line, result) elseif current_section == "limit" then M.parse_limit_line(line, result) end end -- Validate parsed query M.validate_parsed_query(result) return result end -- Extract query block from markdown content function M.extract_query_block(content) -- Match ```notex-query blocks local start_pos, end_pos, query_content = content:match("```notex%-query%s*\n(.*)\n```") if query_content then return query_content end -- Match inline query format local inline_query = content:match("```notex%-query%s*\n(.*)") if inline_query then return inline_query end return nil end -- Split content into lines function M.split_lines(content) local lines = {} for line in content:gmatch("[^\r\n]+") do table.insert(lines, line) end return lines end -- Detect query section function M.detect_section(line) local upper_line = line:upper() if upper_line:match("^FROM%s+") then return "filters" elseif upper_line:match("^WHERE%s+") then return "where" elseif upper_line:match("^ORDER%s+BY%s+") then return "order_by" elseif upper_line:match("^GROUP%s+BY%s+") then return "group_by" elseif upper_line:match("^LIMIT%s+") then return "limit" end return nil end -- Parse filter line (FROM clause or direct property filters) function M.parse_filter_line(line, result) -- Handle FROM clause local from_match = line:match("^FROM%s+(.+)") if from_match then M.parse_property_filters(from_match, result) return end -- Handle direct property filters M.parse_property_filters(line, result) end -- Parse property filters function M.parse_property_filters(filter_string, result) -- Parse YAML-style property filters local yaml_filters = M.parse_yaml_filters(filter_string) for key, value in pairs(yaml_filters) do result.filters[key] = value end end -- Parse YAML-style filters function M.parse_yaml_filters(yaml_string) local filters = {} -- Handle key: value pairs for key, value in yaml_string:gmatch("(%w+)%s*:%s*(.+)") do -- Parse quoted values local quoted_value = value:match('^"(.*)"$') if quoted_value then filters[key] = quoted_value else -- Parse array values local array_match = value:match("^%[(.*)%]$") if array_match then local array_values = {} for item in array_match:gsub("%s", ""):gmatch("[^,]+") do table.insert(array_values, item:gsub("^['\"](.*)['\"]$", "%1")) end filters[key] = array_values else filters[key] = value:trim() end end end return filters end -- Parse WHERE condition line function M.parse_condition_line(line, result) local condition_string = line:match("^WHERE%s+(.+)") if not condition_string then table.insert(result.parse_errors, "Invalid WHERE clause: " .. line) return end result.conditions = M.parse_conditions(condition_string) end -- Parse conditions with logical operators function M.parse_conditions(condition_string) local conditions = { type = "AND", clauses = {} } -- Split by AND/OR operators local and_parts = M.split_logical_operators(condition_string, "AND") local or_parts = {} -- Check if this is an OR condition for _, part in ipairs(and_parts) do if part:match("OR") then local or_split = M.split_logical_operators(part, "OR") if #or_split > 1 then conditions.type = "OR" for _, or_part in ipairs(or_split) do table.insert(conditions.clauses, M.parse_single_condition(or_part)) end return conditions end end end -- Parse AND conditions for _, and_part in ipairs(and_parts) do table.insert(conditions.clauses, M.parse_single_condition(and_part)) end return conditions end -- Split by logical operator function M.split_logical_operators(string, operator) local parts = {} local pattern = "%s+" .. operator .. "%s+" for part in string:gmatch("([^" .. pattern .. "]+)") do table.insert(parts, part:trim()) end return parts end -- Parse single condition function M.parse_single_condition(condition_string) condition_string = condition_string:trim() -- Handle NOT operator local negated = false if condition_string:match("^NOT%s+") then negated = true condition_string = condition_string:sub(4):trim() end -- Parse comparison operators local operators = { { pattern = ">=%s*", type = ">=" }, { pattern = "<=%s*", type = "<=" }, { pattern = "!=%s*", type = "!=" }, { pattern = ">%s*", type = ">" }, { pattern = "<%s*", type = "<" }, { pattern = "=%s*", type = "=" }, { pattern = "%s+CONTAINS%s+", type = "CONTAINS" }, { pattern = "%s+STARTS_WITH%s+", type = "STARTS_WITH" }, { pattern = "%s+ENDS_WITH%s+", type = "ENDS_WITH" }, { pattern = "%s+INCLUDES%s+", type = "INCLUDES" }, { pattern = "%s+BEFORE%s+", type = "BEFORE" }, { pattern = "%s+AFTER%s+", type = "AFTER" }, { pattern = "%s+WITHIN%s+", type = "WITHIN" } } for _, op in ipairs(operators) do local field, value = condition_string:match("^(.-)" .. op.pattern .. "(.+)$") if field and value then return { type = "comparison", field = field:trim(), operator = op.type, value = M.parse_value(value:trim()), negated = negated } end end -- If no operator found, treat as existence check return { type = "existence", field = condition_string, negated = negated } end -- Parse value (handle quotes, numbers, booleans) function M.parse_value(value_string) -- Handle quoted strings local quoted = value_string:match('^"(.*)"$') if quoted then return quoted end -- Handle single quoted strings quoted = value_string:match("^'(.*)'$") if quoted then return quoted end -- Handle numbers local number = tonumber(value_string) if number then return number end -- Handle booleans local lower = value_string:lower() if lower == "true" then return true elseif lower == "false" then return false end -- Handle date/time relative values (e.g., "7d" for 7 days) local time_match = value_string:match("^(%d+)([hdwmy])$") if time_match then local amount = tonumber(time_match[1]) local unit = time_match[2] return { type = "relative_time", amount = amount, unit = unit } end -- Default to string return value_string end -- Parse ORDER BY line function M.parse_order_by_line(line, result) local order_string = line:match("^ORDER%s+BY%s+(.+)") if not order_string then table.insert(result.parse_errors, "Invalid ORDER BY clause: " .. line) return end local field, direction = order_string:match("^(%w+)%s*(ASC|DESC)?$") if not field then table.insert(result.parse_errors, "Invalid ORDER BY format: " .. order_string) return end result.order_by = { field = field, direction = direction and direction:upper() or "ASC" } end -- Parse GROUP BY line function M.parse_group_by_line(line, result) local group_string = line:match("^GROUP%s+BY%s+(.+)") if not group_string then table.insert(result.parse_errors, "Invalid GROUP BY clause: " .. line) return end result.group_by = group_string:trim() end -- Parse LIMIT line function M.parse_limit_line(line, result) local limit_string = line:match("^LIMIT%s+(.+)") if not limit_string then table.insert(result.parse_errors, "Invalid LIMIT clause: " .. line) return end local limit = tonumber(limit_string) if not limit or limit <= 0 then table.insert(result.parse_errors, "Invalid LIMIT value: " .. limit_string) return end result.limit = limit end -- Validate parsed query function M.validate_parsed_query(query) -- Check if we have any filters or conditions if next(query.filters) == nil and not query.conditions then table.insert(query.parse_errors, "Query must have at least one filter or condition") end -- Validate field names (basic check) local valid_fields = {} -- Could be populated from schema for field, _ in pairs(query.filters) do if not M.is_valid_field_name(field) then table.insert(query.parse_errors, "Invalid field name: " .. field) end end -- Validate conditions if query.conditions then M.validate_conditions(query.conditions, query.parse_errors) end end -- Check if field name is valid function M.is_valid_field_name(field) return field:match("^[%w_%-%.]+$") ~= nil end -- Validate conditions recursively function M.validate_conditions(conditions, errors) if conditions.type == "comparison" then if not M.is_valid_field_name(conditions.field) then table.insert(errors, "Invalid field name in condition: " .. conditions.field) end elseif conditions.type == "existence" then if not M.is_valid_field_name(conditions.field) then table.insert(errors, "Invalid field name in existence check: " .. conditions.field) end elseif conditions.clauses then for _, clause in ipairs(conditions.clauses) do M.validate_conditions(clause, errors) end end end -- Generate query hash for caching function M.generate_query_hash(query) local hash_input = vim.json.encode({ filters = query.filters, conditions = query.conditions, order_by = query.order_by, group_by = query.group_by, limit = query.limit }) return utils.sha256(hash_input) end return M