374 lines
9.8 KiB
Lua
374 lines
9.8 KiB
Lua
|
-- Query execution engine module
|
||
|
local M = {}
|
||
|
|
||
|
local database = require('notex.database.init')
|
||
|
local query_builder = require('notex.query.builder')
|
||
|
local query_parser = require('notex.query.parser')
|
||
|
local utils = require('notex.utils')
|
||
|
|
||
|
-- Execute parsed query
|
||
|
function M.execute(parsed_query, options)
|
||
|
options = options or {}
|
||
|
local start_time = vim.loop.hrtime()
|
||
|
|
||
|
local result = {
|
||
|
documents = {},
|
||
|
total_count = 0,
|
||
|
execution_time_ms = 0,
|
||
|
query_hash = "",
|
||
|
success = false,
|
||
|
errors = {},
|
||
|
metadata = {}
|
||
|
}
|
||
|
|
||
|
-- Validate parsed query
|
||
|
if #parsed_query.parse_errors > 0 then
|
||
|
result.errors = parsed_query.parse_errors
|
||
|
result.error_type = "parse_error"
|
||
|
return result
|
||
|
end
|
||
|
|
||
|
-- Generate query hash
|
||
|
result.query_hash = query_parser.generate_query_hash(parsed_query)
|
||
|
|
||
|
-- Build SQL query
|
||
|
local sql, params = query_builder.build_sql(parsed_query, options)
|
||
|
if not sql then
|
||
|
table.insert(result.errors, "Failed to build SQL query")
|
||
|
result.error_type = "build_error"
|
||
|
return result
|
||
|
end
|
||
|
|
||
|
-- Validate SQL
|
||
|
local valid, validation_error = query_builder.validate_sql(sql)
|
||
|
if not valid then
|
||
|
table.insert(result.errors, validation_error)
|
||
|
result.error_type = "validation_error"
|
||
|
return result
|
||
|
end
|
||
|
|
||
|
-- Execute query
|
||
|
local ok, query_result = database.execute(sql, params)
|
||
|
if not ok then
|
||
|
table.insert(result.errors, "Query execution failed: " .. query_result)
|
||
|
result.error_type = "execution_error"
|
||
|
return result
|
||
|
end
|
||
|
|
||
|
-- Process results
|
||
|
local processed_results = M.process_query_results(query_result, parsed_query, options)
|
||
|
result.documents = processed_results.documents
|
||
|
result.metadata = processed_results.metadata
|
||
|
|
||
|
-- Get total count
|
||
|
result.total_count = M.get_total_count(parsed_query, options)
|
||
|
|
||
|
-- Calculate execution time
|
||
|
local end_time = vim.loop.hrtime()
|
||
|
result.execution_time_ms = (end_time - start_time) / 1e6
|
||
|
|
||
|
result.success = true
|
||
|
|
||
|
-- Log slow queries
|
||
|
if result.execution_time_ms > 100 then
|
||
|
utils.log("WARN", string.format("Slow query detected: %.2fms", result.execution_time_ms), {
|
||
|
query_hash = result.query_hash,
|
||
|
document_count = #result.documents
|
||
|
})
|
||
|
end
|
||
|
|
||
|
return result
|
||
|
end
|
||
|
|
||
|
-- Process query results
|
||
|
function M.process_query_results(raw_results, parsed_query, options)
|
||
|
local documents = {}
|
||
|
local metadata = {
|
||
|
properties_found = {},
|
||
|
aggregation_results = {}
|
||
|
}
|
||
|
|
||
|
-- Group results by document if we have properties
|
||
|
local documents_by_id = {}
|
||
|
for _, row in ipairs(raw_results) do
|
||
|
local doc_id = row.id
|
||
|
|
||
|
if not documents_by_id[doc_id] then
|
||
|
documents_by_id[doc_id] = {
|
||
|
id = doc_id,
|
||
|
file_path = row.file_path,
|
||
|
content_hash = row.content_hash,
|
||
|
last_modified = row.last_modified,
|
||
|
created_at = row.created_at,
|
||
|
updated_at = row.updated_at,
|
||
|
properties = {}
|
||
|
}
|
||
|
end
|
||
|
|
||
|
-- Add properties from result row
|
||
|
for key, value in pairs(row) do
|
||
|
if key ~= "id" and key ~= "file_path" and key ~= "content_hash" and
|
||
|
key ~= "last_modified" and key ~= "created_at" and key ~= "updated_at" then
|
||
|
if value and value ~= "" then
|
||
|
documents_by_id[doc_id].properties[key] = value
|
||
|
metadata.properties_found[key] = true
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
-- Convert to array
|
||
|
for _, doc in pairs(documents_by_id) do
|
||
|
table.insert(documents, doc)
|
||
|
end
|
||
|
|
||
|
-- Apply post-processing filters
|
||
|
documents = M.apply_post_filters(documents, parsed_query, options)
|
||
|
|
||
|
-- Apply sorting if not handled by SQL
|
||
|
if parsed_query.order_by and parsed_query.order_by.field == "relevance" then
|
||
|
documents = M.sort_by_relevance(documents, parsed_query)
|
||
|
end
|
||
|
|
||
|
return {
|
||
|
documents = documents,
|
||
|
metadata = metadata
|
||
|
}
|
||
|
end
|
||
|
|
||
|
-- Apply post-processing filters
|
||
|
function M.apply_post_filters(documents, parsed_query, options)
|
||
|
local filtered = documents
|
||
|
|
||
|
-- Apply text search highlighting if requested
|
||
|
if options.highlight and parsed_query.conditions then
|
||
|
filtered = M.apply_text_highlighting(filtered, parsed_query)
|
||
|
end
|
||
|
|
||
|
-- Apply additional filters that couldn't be handled by SQL
|
||
|
filtered = M.apply_complex_filters(filtered, parsed_query)
|
||
|
|
||
|
return filtered
|
||
|
end
|
||
|
|
||
|
-- Apply text highlighting
|
||
|
function M.apply_text_highlighting(documents, parsed_query)
|
||
|
-- Implementation for text highlighting
|
||
|
-- This would mark matching text in document properties
|
||
|
return documents
|
||
|
end
|
||
|
|
||
|
-- Apply complex filters
|
||
|
function M.apply_complex_filters(documents, parsed_query)
|
||
|
local filtered = {}
|
||
|
|
||
|
for _, doc in ipairs(documents) do
|
||
|
local include = true
|
||
|
|
||
|
-- Apply any complex filter logic here
|
||
|
if include then
|
||
|
table.insert(filtered, doc)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
return filtered
|
||
|
end
|
||
|
|
||
|
-- Sort by relevance
|
||
|
function M.sort_by_relevance(documents, parsed_query)
|
||
|
-- Simple relevance scoring based on filter matches
|
||
|
local scored = {}
|
||
|
|
||
|
for _, doc in ipairs(documents) do
|
||
|
local score = 0
|
||
|
|
||
|
-- Score based on filter matches
|
||
|
for field, _ in pairs(parsed_query.filters) do
|
||
|
if doc.properties[field] then
|
||
|
score = score + 1
|
||
|
end
|
||
|
end
|
||
|
|
||
|
-- Add document with score
|
||
|
table.insert(scored, {
|
||
|
document = doc,
|
||
|
score = score
|
||
|
})
|
||
|
end
|
||
|
|
||
|
-- Sort by score (descending)
|
||
|
table.sort(scored, function(a, b) return a.score > b.score end)
|
||
|
|
||
|
-- Extract documents
|
||
|
local sorted_documents = {}
|
||
|
for _, item in ipairs(scored) do
|
||
|
table.insert(sorted_documents, item.document)
|
||
|
end
|
||
|
|
||
|
return sorted_documents
|
||
|
end
|
||
|
|
||
|
-- Get total count for query
|
||
|
function M.get_total_count(parsed_query, options)
|
||
|
local count_sql, count_params = query_builder.build_count_query(parsed_query, options)
|
||
|
|
||
|
local ok, count_result = database.execute(count_sql, count_params)
|
||
|
if not ok then
|
||
|
utils.log("ERROR", "Failed to get total count", { error = count_result })
|
||
|
return 0
|
||
|
end
|
||
|
|
||
|
return count_result[1] and count_result[1].total_count or 0
|
||
|
end
|
||
|
|
||
|
-- Execute query with caching
|
||
|
function M.execute_cached(parsed_query, options)
|
||
|
options = options or {}
|
||
|
local cache_enabled = options.cache ~= false
|
||
|
|
||
|
if not cache_enabled then
|
||
|
return M.execute(parsed_query, options)
|
||
|
end
|
||
|
|
||
|
local query_hash = query_parser.generate_query_hash(parsed_query)
|
||
|
local cache_key = "query:" .. query_hash
|
||
|
|
||
|
-- Check cache (implementation would depend on cache system)
|
||
|
-- For now, just execute directly
|
||
|
return M.execute(parsed_query, options)
|
||
|
end
|
||
|
|
||
|
-- Validate query before execution
|
||
|
function M.validate_query(parsed_query)
|
||
|
local errors = {}
|
||
|
|
||
|
-- Check for required fields
|
||
|
if not parsed_query or not parsed_query.filters then
|
||
|
table.insert(errors, "Query must have filters")
|
||
|
end
|
||
|
|
||
|
-- Validate filter values
|
||
|
for field, value in pairs(parsed_query.filters or {}) do
|
||
|
if not M.is_valid_filter_value(value) then
|
||
|
table.insert(errors, string.format("Invalid filter value for field '%s'", field))
|
||
|
end
|
||
|
end
|
||
|
|
||
|
-- Validate conditions
|
||
|
if parsed_query.conditions then
|
||
|
M.validate_conditions_recursive(parsed_query.conditions, errors)
|
||
|
end
|
||
|
|
||
|
return #errors == 0, errors
|
||
|
end
|
||
|
|
||
|
-- Check if filter value is valid
|
||
|
function M.is_valid_filter_value(value)
|
||
|
if type(value) == "string" and #value > 1000 then
|
||
|
return false
|
||
|
end
|
||
|
|
||
|
if type(value) == "table" and #value > 100 then
|
||
|
return false
|
||
|
end
|
||
|
|
||
|
return true
|
||
|
end
|
||
|
|
||
|
-- Validate conditions recursively
|
||
|
function M.validate_conditions_recursive(conditions, errors)
|
||
|
if conditions.type == "comparison" then
|
||
|
if not conditions.field or not conditions.operator or conditions.value == nil then
|
||
|
table.insert(errors, "Invalid comparison condition")
|
||
|
end
|
||
|
elseif conditions.type == "existence" then
|
||
|
if not conditions.field then
|
||
|
table.insert(errors, "Invalid existence condition")
|
||
|
end
|
||
|
elseif conditions.clauses then
|
||
|
for _, clause in ipairs(conditions.clauses) do
|
||
|
M.validate_conditions_recursive(clause, errors)
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
-- Get query suggestions
|
||
|
function M.get_suggestions(partial_query, options)
|
||
|
local suggestions = {
|
||
|
properties = {},
|
||
|
values = {},
|
||
|
operators = {}
|
||
|
}
|
||
|
|
||
|
-- Get property suggestions from schema
|
||
|
local ok, schema_result = database.execute("SELECT DISTINCT property_key FROM schema_metadata ORDER BY document_count DESC LIMIT 20")
|
||
|
if ok then
|
||
|
for _, row in ipairs(schema_result) do
|
||
|
table.insert(suggestions.properties, row.property_key)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
-- Get value suggestions for common properties
|
||
|
local common_properties = {"status", "priority", "tags", "type"}
|
||
|
for _, prop in ipairs(common_properties) do
|
||
|
local ok, values_result = database.execute(
|
||
|
"SELECT DISTINCT value FROM properties WHERE key = ? AND value_type = 'string' LIMIT 10",
|
||
|
{ prop }
|
||
|
)
|
||
|
if ok then
|
||
|
suggestions.values[prop] = {}
|
||
|
for _, row in ipairs(values_result) do
|
||
|
table.insert(suggestions.values[prop], row.value)
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
-- Common operators
|
||
|
suggestions.operators = {"=", "!=", ">", "<", ">=", "<=", "CONTAINS", "STARTS_WITH", "ENDS_WITH", "INCLUDES"}
|
||
|
|
||
|
return suggestions
|
||
|
end
|
||
|
|
||
|
-- Explain query execution plan
|
||
|
function M.explain_query(parsed_query, options)
|
||
|
local sql, params = query_builder.build_sql(parsed_query, options)
|
||
|
|
||
|
local explain_sql = "EXPLAIN QUERY PLAN " .. sql
|
||
|
local ok, explain_result = database.execute(explain_sql, params)
|
||
|
|
||
|
if not ok then
|
||
|
return {
|
||
|
success = false,
|
||
|
error = explain_result,
|
||
|
sql = sql
|
||
|
}
|
||
|
end
|
||
|
|
||
|
return {
|
||
|
success = true,
|
||
|
sql = sql,
|
||
|
params = params,
|
||
|
plan = explain_result,
|
||
|
estimated_cost = M.calculate_query_cost(explain_result)
|
||
|
}
|
||
|
end
|
||
|
|
||
|
-- Calculate query cost
|
||
|
function M.calculate_query_cost(explain_result)
|
||
|
local total_cost = 0
|
||
|
|
||
|
for _, row in ipairs(explain_result) do
|
||
|
-- Simple cost calculation based on SQLite's EXPLAIN output
|
||
|
if row.detail and row.detail:match("SCAN") then
|
||
|
total_cost = total_cost + 10
|
||
|
elseif row.detail and row.detail:match("SEARCH") then
|
||
|
total_cost = total_cost + 2
|
||
|
else
|
||
|
total_cost = total_cost + 1
|
||
|
end
|
||
|
end
|
||
|
|
||
|
return total_cost
|
||
|
end
|
||
|
|
||
|
return M
|