Initial vibecoded proof of concept
This commit is contained in:
parent
74812459af
commit
461318a656
61 changed files with 13306 additions and 0 deletions
370
lua/notex/query/builder.lua
Normal file
370
lua/notex/query/builder.lua
Normal file
|
@ -0,0 +1,370 @@
|
|||
-- SQL query builder module
|
||||
local M = {}
|
||||
|
||||
local utils = require('notex.utils')
|
||||
|
||||
-- Build SQL query from parsed query object
|
||||
function M.build_sql(parsed_query, options)
|
||||
options = options or {}
|
||||
local query = {
|
||||
select = "",
|
||||
from = "",
|
||||
where = "",
|
||||
group_by = "",
|
||||
order_by = "",
|
||||
limit = "",
|
||||
params = {}
|
||||
}
|
||||
|
||||
-- Build SELECT clause
|
||||
query.select = M.build_select_clause(parsed_query, options)
|
||||
|
||||
-- Build FROM clause
|
||||
query.from = M.build_from_clause(parsed_query, options)
|
||||
|
||||
-- Build WHERE clause
|
||||
query.where = M.build_where_clause(parsed_query, options)
|
||||
|
||||
-- Build GROUP BY clause
|
||||
query.group_by = M.build_group_by_clause(parsed_query, options)
|
||||
|
||||
-- Build ORDER BY clause
|
||||
query.order_by = M.build_order_by_clause(parsed_query, options)
|
||||
|
||||
-- Build LIMIT clause
|
||||
query.limit = M.build_limit_clause(parsed_query, options)
|
||||
|
||||
-- Combine all clauses
|
||||
local sql = M.combine_clauses(query)
|
||||
|
||||
return sql, query.params
|
||||
end
|
||||
|
||||
-- Build SELECT clause
|
||||
function M.build_select_clause(parsed_query, options)
|
||||
local select_fields = {"d.*"}
|
||||
|
||||
-- Add property aggregates if needed
|
||||
if parsed_query.group_by then
|
||||
table.insert(select_fields, "COUNT(p.id) as document_count")
|
||||
table.insert(select_fields, "GROUP_CONCAT(p.value) as aggregated_values")
|
||||
else
|
||||
-- Add property values for filtering
|
||||
for field, _ in pairs(parsed_query.filters) do
|
||||
table.insert(select_fields, string.format("(SELECT p.value FROM properties p WHERE p.document_id = d.id AND p.key = '%s') as %s", field, field))
|
||||
end
|
||||
end
|
||||
|
||||
if options.count_only then
|
||||
select_fields = {"COUNT(DISTINCT d.id) as total_count"}
|
||||
end
|
||||
|
||||
return "SELECT " .. table.concat(select_fields, ", ")
|
||||
end
|
||||
|
||||
-- Build FROM clause
|
||||
function M.build_from_clause(parsed_query, options)
|
||||
local from_parts = {"documents d"}
|
||||
|
||||
-- Add properties join if we have filters or conditions
|
||||
if next(parsed_query.filters) ~= nil or parsed_query.conditions then
|
||||
table.insert(from_parts, "LEFT JOIN properties p ON d.id = p.document_id")
|
||||
end
|
||||
|
||||
return "FROM " .. table.concat(from_parts, " ")
|
||||
end
|
||||
|
||||
-- Build WHERE clause
|
||||
function M.build_where_clause(parsed_query, options)
|
||||
local where_conditions = {}
|
||||
local params = {}
|
||||
|
||||
-- Add filter conditions
|
||||
for field, value in pairs(parsed_query.filters) do
|
||||
local condition, param = M.build_filter_condition(field, value)
|
||||
table.insert(where_conditions, condition)
|
||||
if param then
|
||||
for key, val in pairs(param) do
|
||||
params[key] = val
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Add parsed conditions
|
||||
if parsed_query.conditions then
|
||||
local condition, param = M.build_conditions(parsed_query.conditions)
|
||||
if condition then
|
||||
table.insert(where_conditions, condition)
|
||||
if param then
|
||||
for key, val in pairs(param) do
|
||||
params[key] = val
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #where_conditions == 0 then
|
||||
return "", {}
|
||||
end
|
||||
|
||||
return "WHERE " .. table.concat(where_conditions, " AND "), params
|
||||
end
|
||||
|
||||
-- Build filter condition
|
||||
function M.build_filter_condition(field, value)
|
||||
local param_name = field:gsub("[^%w]", "_") .. "_filter"
|
||||
|
||||
if type(value) == "table" then
|
||||
-- Handle array values
|
||||
local placeholders = {}
|
||||
for i = 1, #value do
|
||||
local item_param = param_name .. "_" .. i
|
||||
table.insert(placeholders, ":" .. item_param)
|
||||
end
|
||||
return string.format("(p.key = '%s' AND p.value IN (%s))", field, table.concat(placeholders, ", "))
|
||||
else
|
||||
-- Handle single value
|
||||
return string.format("(p.key = '%s' AND p.value = :%s)", field, param_name)
|
||||
end
|
||||
end
|
||||
|
||||
-- Build conditions from parsed condition tree
|
||||
function M.build_conditions(conditions)
|
||||
if conditions.type == "comparison" then
|
||||
return M.build_comparison_condition(conditions)
|
||||
elseif conditions.type == "existence" then
|
||||
return M.build_existence_condition(conditions)
|
||||
elseif conditions.clauses then
|
||||
return M.build_logical_condition(conditions)
|
||||
end
|
||||
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
-- Build comparison condition
|
||||
function M.build_comparison_condition(condition)
|
||||
local field = condition.field
|
||||
local operator = condition.operator
|
||||
local value = condition.value
|
||||
local negated = condition.negated
|
||||
|
||||
local param_name = field:gsub("[^%w]", "_") .. "_comp"
|
||||
local sql_condition
|
||||
local params = {}
|
||||
|
||||
-- Handle special operators
|
||||
if operator == "CONTAINS" then
|
||||
sql_condition = string.format("p.key = '%s' AND p.value LIKE :%s", field, param_name)
|
||||
params[param_name] = "%" .. value .. "%"
|
||||
elseif operator == "STARTS_WITH" then
|
||||
sql_condition = string.format("p.key = '%s' AND p.value LIKE :%s", field, param_name)
|
||||
params[param_name] = value .. "%"
|
||||
elseif operator == "ENDS_WITH" then
|
||||
sql_condition = string.format("p.key = '%s' AND p.value LIKE :%s", field, param_name)
|
||||
params[param_name] = "%" .. value
|
||||
elseif operator == "INCLUDES" then
|
||||
sql_condition = string.format("p.key = '%s' AND p.value LIKE :%s", field, param_name)
|
||||
params[param_name] "%" .. value .. "%"
|
||||
elseif operator == "BEFORE" then
|
||||
sql_condition = string.format("p.key = '%s' AND p.value < :%s", field, param_name)
|
||||
params[param_name] = value
|
||||
elseif operator == "AFTER" then
|
||||
sql_condition = string.format("p.key = '%s' AND p.value > :%s", field, param_name)
|
||||
params[param_name] = value
|
||||
elseif operator == "WITHIN" then
|
||||
-- Handle relative time
|
||||
if type(value) == "table" and value.type == "relative_time" then
|
||||
local time_value = M.calculate_relative_time(value)
|
||||
sql_condition = string.format("p.key = '%s' AND p.value >= :%s", field, param_name)
|
||||
params[param_name] = time_value
|
||||
else
|
||||
sql_condition = string.format("p.key = '%s' AND p.value >= :%s", field, param_name)
|
||||
params[param_name] = value
|
||||
end
|
||||
else
|
||||
-- Handle standard comparison operators
|
||||
local op_map = {
|
||||
["="] = "=",
|
||||
["!="] = "!=",
|
||||
[">"] = ">",
|
||||
["<"] = "<",
|
||||
[">="] = ">=",
|
||||
["<="] = "<="
|
||||
}
|
||||
|
||||
local sql_op = op_map[operator] or "="
|
||||
sql_condition = string.format("p.key = '%s' AND p.value %s :%s", field, sql_op, param_name)
|
||||
params[param_name] = value
|
||||
end
|
||||
|
||||
if negated then
|
||||
sql_condition = "NOT (" .. sql_condition .. ")"
|
||||
end
|
||||
|
||||
return sql_condition, params
|
||||
end
|
||||
|
||||
-- Build existence condition
|
||||
function M.build_existence_condition(condition)
|
||||
local field = condition.field
|
||||
local negated = condition.negated
|
||||
|
||||
local sql_condition = string.format("EXISTS (SELECT 1 FROM properties p2 WHERE p2.document_id = d.id AND p2.key = '%s')", field)
|
||||
|
||||
if negated then
|
||||
sql_condition = "NOT " .. sql_condition
|
||||
end
|
||||
|
||||
return sql_condition, {}
|
||||
end
|
||||
|
||||
-- Build logical condition (AND/OR)
|
||||
function M.build_logical_condition(conditions)
|
||||
local clause_parts = {}
|
||||
local all_params = {}
|
||||
|
||||
for _, clause in ipairs(conditions.clauses) do
|
||||
local clause_sql, clause_params = M.build_conditions(clause)
|
||||
if clause_sql then
|
||||
table.insert(clause_parts, clause_sql)
|
||||
if clause_params then
|
||||
for key, value in pairs(clause_params) do
|
||||
all_params[key] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #clause_parts == 0 then
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
local logical_op = conditions.type:upper()
|
||||
local sql_condition = "(" .. table.concat(clause_parts, " " .. logical_op .. " ") .. ")"
|
||||
|
||||
return sql_condition, all_params
|
||||
end
|
||||
|
||||
-- Build GROUP BY clause
|
||||
function M.build_group_by_clause(parsed_query, options)
|
||||
if not parsed_query.group_by then
|
||||
return ""
|
||||
end
|
||||
|
||||
local group_fields = {}
|
||||
|
||||
if parsed_query.group_by == "property_key" then
|
||||
table.insert(group_fields, "p.key")
|
||||
else
|
||||
table.insert(group_fields, "p." .. parsed_query.group_by)
|
||||
end
|
||||
|
||||
return "GROUP BY " .. table.concat(group_fields, ", ")
|
||||
end
|
||||
|
||||
-- Build ORDER BY clause
|
||||
function M.build_order_by_clause(parsed_query, options)
|
||||
if not parsed_query.order_by then
|
||||
return "ORDER BY d.updated_at DESC"
|
||||
end
|
||||
|
||||
local field = parsed_query.order_by.field
|
||||
local direction = parsed_query.order_by.direction or "ASC"
|
||||
|
||||
-- Map field names to columns
|
||||
local field_map = {
|
||||
created_at = "d.created_at",
|
||||
updated_at = "d.updated_at",
|
||||
file_path = "d.file_path",
|
||||
title = "CASE WHEN p.key = 'title' THEN p.value END"
|
||||
}
|
||||
|
||||
local column = field_map[field] or "p." .. field
|
||||
|
||||
return string.format("ORDER BY %s %s", column, direction)
|
||||
end
|
||||
|
||||
-- Build LIMIT clause
|
||||
function M.build_limit_clause(parsed_query, options)
|
||||
if not parsed_query.limit then
|
||||
return ""
|
||||
end
|
||||
|
||||
return "LIMIT " .. parsed_query.limit
|
||||
end
|
||||
|
||||
-- Combine all SQL clauses
|
||||
function M.combine_clauses(query)
|
||||
local parts = {}
|
||||
|
||||
table.insert(parts, query.select)
|
||||
table.insert(parts, query.from)
|
||||
|
||||
if query.where ~= "" then
|
||||
table.insert(parts, query.where)
|
||||
end
|
||||
|
||||
if query.group_by ~= "" then
|
||||
table.insert(parts, query.group_by)
|
||||
end
|
||||
|
||||
if query.order_by ~= "" then
|
||||
table.insert(parts, query.order_by)
|
||||
end
|
||||
|
||||
if query.limit ~= "" then
|
||||
table.insert(parts, query.limit)
|
||||
end
|
||||
|
||||
return table.concat(parts, "\n")
|
||||
end
|
||||
|
||||
-- Calculate relative time
|
||||
function M.calculate_relative_time(relative_time)
|
||||
local current_time = os.time()
|
||||
local amount = relative_time.amount
|
||||
local unit = relative_time.unit
|
||||
|
||||
local seconds = 0
|
||||
|
||||
if unit == "s" then
|
||||
seconds = amount
|
||||
elseif unit == "m" then
|
||||
seconds = amount * 60
|
||||
elseif unit == "h" then
|
||||
seconds = amount * 3600
|
||||
elseif unit == "d" then
|
||||
seconds = amount * 86400
|
||||
elseif unit == "w" then
|
||||
seconds = amount * 604800
|
||||
elseif unit == "m" then -- month (approximate)
|
||||
seconds = amount * 2592000
|
||||
elseif unit == "y" then -- year (approximate)
|
||||
seconds = amount * 31536000
|
||||
end
|
||||
|
||||
return os.date("%Y-%m-%d", current_time - seconds)
|
||||
end
|
||||
|
||||
-- Build count query
|
||||
function M.build_count_query(parsed_query)
|
||||
local options = { count_only = true }
|
||||
local sql, params = M.build_sql(parsed_query, options)
|
||||
return sql, params
|
||||
end
|
||||
|
||||
-- Validate built SQL
|
||||
function M.validate_sql(sql)
|
||||
if not sql or sql == "" then
|
||||
return false, "Empty SQL query"
|
||||
end
|
||||
|
||||
-- Basic SQL injection prevention
|
||||
if sql:match(";") or sql:match("DROP") or sql:match("DELETE") or sql:match("UPDATE") or sql:match("INSERT") then
|
||||
return false, "Potentially unsafe SQL detected"
|
||||
end
|
||||
|
||||
return true, "SQL query is valid"
|
||||
end
|
||||
|
||||
return M
|
Loading…
Add table
Add a link
Reference in a new issue