412 lines
11 KiB
Lua
412 lines
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
|