notex.nvim/lua/notex/query/parser.lua

412 lines
No EOL
11 KiB
Lua

-- 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