using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Dapper;
namespace GB5Shared.ListQuery
{
// ─────────────────────────────────────────────────────────────────────
// SqlClause – the composable unit of parameterised SQL
//
// Rules for all clause methods:
// • Return SqlClause.Empty when the filter does not apply (null/default input)
// • Never throw on null/default — callers always include all clauses in AsWhere
// • Parameter names must be unique within one query — use descriptive names
// • Never concatenate user values into the Sql string — always use parameters
// ─────────────────────────────────────────────────────────────────────
public sealed class SqlClause
{
public static readonly SqlClause Empty = new(string.Empty, new DynamicParameters());
public string Sql { get; }
public DynamicParameters Params { get; }
public bool IsEmpty => string.IsNullOrWhiteSpace(Sql);
public SqlClause(string sql, DynamicParameters parameters)
{
Sql = sql;
Params = parameters;
}
// ── Combining clauses ─────────────────────────────────────────────
/// Merges non-empty clauses with AND, merging their parameters.
public static SqlClause Combine(IEnumerable clauses)
{
var active = clauses.Where(c => !c.IsEmpty).ToList();
if (active.Count == 0) return Empty;
var merged = new DynamicParameters();
foreach (var c in active) merged.AddDynamicParams(c.Params);
return new SqlClause(
string.Join("\r\n AND ", active.Select(c => c.Sql)),
merged);
}
///
/// Combines clauses and returns a complete WHERE block + merged parameters.
/// Always emits at least "WHERE 1=1".
///
public static (string Sql, DynamicParameters Params) AsWhere(IEnumerable clauses)
{
var combined = Combine(clauses);
return combined.IsEmpty
? ("WHERE 1=1", new DynamicParameters())
: ($"WHERE 1=1\r\n AND {combined.Sql}", combined.Params);
}
/// Merges multiple DynamicParameters instances into one.
public static DynamicParameters Merge(params DynamicParameters[] all)
{
var merged = new DynamicParameters();
foreach (var p in all) merged.AddDynamicParams(p);
return merged;
}
}
// ─────────────────────────────────────────────────────────────────────
// SqlClauses – reusable, context-aware WHERE filter library
//
// All methods:
// • Accept a QueryContext — resolve the correct alias and dialect
// • Return SqlClause.Empty when the filter should not apply
// • Use ctx.Dialect.Param("name") for parameter placeholders
// • Use ctx.Col("table", "COLUMN") for alias-qualified columns
//
// To add a new clause:
// 1. Add a static method here
// 2. Return Empty on null/default input
// 3. Use Clause() helper for simple single-param filters
// 4. Reuse this method across all modules that share the filter
// ─────────────────────────────────────────────────────────────────────
public static class SqlClauses
{
// ── Allocation / document structural filters ──────────────────────
public static SqlClause HeaderType(QueryContext ctx, int id) =>
Clause($"{ctx.Col("allocation", "OBJECTHEADERTYPEID")} = {ctx.Dialect.Param("headTypeId")}",
"headTypeId", id);
public static SqlClause ObjectType(QueryContext ctx, int id) =>
id == 0 ? SqlClause.Empty :
Clause($"{ctx.Col("allocation", "OBJECTTYPEID")} = {ctx.Dialect.Param("objTypeId")}",
"objTypeId", id);
public static SqlClause AllocationType(QueryContext ctx, int type) =>
Clause($"{ctx.Col("allocation", "ALLOCATIONTYPE")} = {ctx.Dialect.Param("allocType")}",
"allocType", type);
public static SqlClause AllocationNature(QueryContext ctx, int nature) =>
Clause($"{ctx.Col("allocation", "ALLOCATIONNATURE")} = {ctx.Dialect.Param("allocNature")}",
"allocNature", nature);
public static SqlClause BizType(QueryContext ctx, int? id) =>
id is null ? SqlClause.Empty :
Clause($"{ctx.Col("allocation", "BIZTRANSACTIONTYPEID")} = {ctx.Dialect.Param("bizTypeId")}",
"bizTypeId", id.Value);
// ── Pending quantity ──────────────────────────────────────────────
public static SqlClause PendingOnly(QueryContext ctx) =>
new($"{ctx.Col("pending", "PENDINGQUANTITY")} > 0", new DynamicParameters());
public static SqlClause LoadTypeQuantity(QueryContext ctx, int? loadBizTypeId,
string pendingAlias = "pending")
{
if (loadBizTypeId is null)
return new SqlClause($"{ctx.Col(pendingAlias, "PENDINGQUANTITY")} > 0",
new DynamicParameters());
var p = ctx.Col(pendingAlias, "PENDING");
return Clause(
$"{ctx.Col("loadtype", "BIZTRANSACTIONTYPEID")} = {ctx.Dialect.Param("loadBizTypeId")}\r\n" +
$" AND ( ({ctx.Col("loadtype", "ISGOODQUANTITY")} = 0 AND {p}QUANTITY > 0)\r\n" +
$" OR ({ctx.Col("loadtype", "ISREJECTEDQUANTITY")} = 0 AND {p}REJECTEDQUANTITY > 0)\r\n" +
$" OR ({ctx.Col("loadtype", "ISREWORKQUANTITY")} = 0 AND {p}REWORKQUANTITY > 0)\r\n" +
$" OR ({ctx.Col("loadtype", "ISOTHERQUANTITY")} = 0 AND {p}OTHERQUANTITY > 0))",
"loadBizTypeId", loadBizTypeId.Value);
}
public static SqlClause RemainingPackQuantity(QueryContext ctx) => new(
$"{ctx.Dialect.IsNull(ctx.Col("detail", "GOODQUANTITY"), "0")}" +
$" - {ctx.Dialect.IsNull("packdtl.GOODQUANTITY", "0")} > 0",
new DynamicParameters());
// ── Organisation filters ──────────────────────────────────────────
///
/// Filters by OU across both header and detail OU columns (OR logic).
///
public static SqlClause OUFilter(QueryContext ctx, int? ouId)
{
if (ouId is null) return SqlClause.Empty;
var docOu = ctx.Col("document", "OUID");
var dtlOu = ctx.Col("detail", "INTEROUID");
return Clause(
$"({docOu} = {ctx.Dialect.Param("ouId")} OR {dtlOu} = {ctx.Dialect.Param("ouId")})",
"ouId", ouId.Value);
}
///
/// Filters by OU on a single table (typically header).
///
public static SqlClause OUFilterSingle(QueryContext ctx, int? ouId,
string logicalTable = "document")
{
if (ouId is null) return SqlClause.Empty;
return Clause(
$"{ctx.Col(logicalTable, "OUID")} = {ctx.Dialect.Param("ouId")}",
"ouId", ouId.Value);
}
public static SqlClause PartyFilter(QueryContext ctx, int[]? partyIds)
{
if (partyIds is not { Length: > 0 }) return SqlClause.Empty;
var col = ctx.Col("document", "PARTYID");
return Clause(
$"({col} IN {ctx.Dialect.Param("partyIds")} OR {col} = -1)",
"partyIds", partyIds);
}
public static SqlClause PartyBranchFilter(QueryContext ctx, int? partyBranchId,
string logicalTable = "header")
{
if (partyBranchId is null) return SqlClause.Empty;
return Clause(
$"{ctx.Col(logicalTable, "PARTYBRANCHID")} = {ctx.Dialect.Param("partyBranchId")}",
"partyBranchId", partyBranchId.Value);
}
// ── Text search ───────────────────────────────────────────────────
///
/// Multi-column progressive search across document number, reference, party reference.
/// Returns Empty when text is null or whitespace.
///
public static SqlClause DocumentSearch(QueryContext ctx, string? text)
{
if (string.IsNullOrWhiteSpace(text)) return SqlClause.Empty;
var p = ctx.Dialect.Param("search");
var like = ctx.Dialect.LikeCaseOp;
return Clause(
$"({ctx.Col("document", "DOCUMENTNUMBER")} {like} {p}\r\n" +
$" OR {ctx.Col("document", "PARTYREFERENCENUMBER")} {like} {p}\r\n" +
$" OR {ctx.Col("document", "REFERENCENUMBER")} {like} {p})",
"search", $"%{text.Trim()}%");
}
/// Multi-column search for indent header (number + reference).
public static SqlClause IndentSearch(QueryContext ctx, string? text)
{
if (string.IsNullOrWhiteSpace(text)) return SqlClause.Empty;
var p = ctx.Dialect.Param("search");
var like = ctx.Dialect.LikeCaseOp;
return Clause(
$"({ctx.Col("header", "INDENTNUMBER")} {like} {p}\r\n" +
$" OR {ctx.Col("header", "REFERENCENUMBER")} {like} {p})",
"search", $"%{text.Trim()}%");
}
/// Item code / name search.
public static SqlClause ItemSearch(QueryContext ctx, string? text)
{
if (string.IsNullOrWhiteSpace(text)) return SqlClause.Empty;
var p = ctx.Dialect.Param("itemSearch");
var like = ctx.Dialect.LikeCaseOp;
return Clause(
$"({ctx.Col("item", "ITEMCODE")} {like} {p}\r\n" +
$" OR {ctx.Col("item", "ITEMNAME")} {like} {p})",
"itemSearch", $"%{text.Trim()}%");
}
// ── Generic exact-match filters ───────────────────────────────────
// Used by module QBs that need simple equality filters on nullable fields.
// Parameter name is derived from the column name (underscores stripped, lowercased).
/// Filters by exact nullable int value. Returns Empty when null.
public static SqlClause ExactInt(QueryContext ctx, int? value, string table, string column)
{
if (value is null) return SqlClause.Empty;
var pname = column.Replace("_", "").ToLowerInvariant();
var p = new DynamicParameters();
p.Add(pname, value.Value);
return new SqlClause($"{ctx.Col(table, column)} = {ctx.Dialect.Param(pname)}", p);
}
/// Filters by exact nullable byte value. Returns Empty when null.
public static SqlClause ExactByte(QueryContext ctx, byte? value, string table, string column)
{
if (value is null) return SqlClause.Empty;
var pname = column.Replace("_", "").ToLowerInvariant();
var p = new DynamicParameters();
p.Add(pname, value.Value);
return new SqlClause($"{ctx.Col(table, column)} = {ctx.Dialect.Param(pname)}", p);
}
/// Filters by exact nullable short value. Returns Empty when null.
public static SqlClause ExactShort(QueryContext ctx, short? value, string table, string column)
{
if (value is null) return SqlClause.Empty;
var pname = column.Replace("_", "").ToLowerInvariant();
var p = new DynamicParameters();
p.Add(pname, value.Value);
return new SqlClause($"{ctx.Col(table, column)} = {ctx.Dialect.Param(pname)}", p);
}
/// Filters by exact nullable Guid (UUID) value. Returns Empty when null.
public static SqlClause ExactGuid(QueryContext ctx, Guid? value, string table, string column)
{
if (value is null) return SqlClause.Empty;
var pname = column.Replace("_", "").ToLowerInvariant();
var p = new DynamicParameters();
p.Add(pname, value.Value);
return new SqlClause($"{ctx.Col(table, column)} = {ctx.Dialect.Param(pname)}", p);
}
/// Filters by exact nullable bool value. Returns Empty when null.
public static SqlClause ExactBool(QueryContext ctx, bool? value, string table, string column)
{
if (value is null) return SqlClause.Empty;
var pname = column.Replace("_", "").ToLowerInvariant();
var p = new DynamicParameters();
p.Add(pname, value.Value);
return new SqlClause($"{ctx.Col(table, column)} = {ctx.Dialect.Param(pname)}", p);
}
/// Filters by exact string value. Returns Empty when null or whitespace.
public static SqlClause ExactString(QueryContext ctx, string? value, string table, string column)
{
if (string.IsNullOrWhiteSpace(value)) return SqlClause.Empty;
var pname = column.Replace("_", "").ToLowerInvariant();
var p = new DynamicParameters();
p.Add(pname, value.Trim());
return new SqlClause($"{ctx.Col(table, column)} = {ctx.Dialect.Param(pname)}", p);
}
///
/// LIKE/ILIKE search across code and name columns.
/// Returns Empty when text is null or whitespace.
///
public static SqlClause CodeNameSearch(
QueryContext ctx, string? text,
string table, string codeColumn, string nameColumn)
{
if (string.IsNullOrWhiteSpace(text)) return SqlClause.Empty;
var p = new DynamicParameters();
p.Add("codeNameSearch", $"%{text.Trim()}%");
var like = ctx.Dialect.LikeCaseOp;
var pr = ctx.Dialect.Param("codeNameSearch");
return new SqlClause(
$"({ctx.Col(table, codeColumn)} {like} {pr}" +
$" OR {ctx.Col(table, nameColumn)} {like} {pr})", p);
}
// ── Status filters ────────────────────────────────────────────────
public static SqlClause DocumentStatus(QueryContext ctx, params int[] statuses) =>
Clause($"{ctx.Col("document", "STATUS")} IN {ctx.Dialect.Param("statuses")}",
"statuses", statuses);
public static SqlClause IndentStatus(QueryContext ctx, int status = 1) =>
Clause($"{ctx.Col("header", "STATUS")} = {ctx.Dialect.Param("indentStatus")}",
"indentStatus", status);
public static SqlClause ActiveReleaseStatus(QueryContext ctx) =>
new($"{ctx.Col("detail", "RELEASESTATUS")} IN (0, 3, 6)", new DynamicParameters());
// ── Work-order filters ────────────────────────────────────────────
public static SqlClause WorkOrderAvailability(QueryContext ctx, bool showOnlyAvailable)
{
var d = ctx.Alias("bizclass");
var pdtl = ctx.Alias("posteddetail");
var min = ctx.Alias("postedmin");
return new SqlClause(
$"( {d}.BIZTRANSACTIONCLASSID <> -1399999908\r\n" +
$" OR (\r\n" +
$" {d}.BIZTRANSACTIONCLASSID = -1399999908\r\n" +
$" AND (\r\n" +
$" {pdtl}.INDENTID = {pdtl}.COMBINEINDENTID\r\n" +
$" OR {ctx.Dialect.IsNull($"{pdtl}.AVAILABLETOWORK", "0")} > 0\r\n" +
$" OR {(showOnlyAvailable ? "1" : "0")} = 1\r\n" +
$" OR {ctx.Dialect.IsNull($"{min}.ROUTINGDETAILSLNO", "1")} = {pdtl}.ROUTINGDETAILSLNO\r\n" +
$" )\r\n" +
$" )\r\n" +
$" OR {pdtl}.AVAILABLETOWORK IS NULL\r\n" +
")",
new DynamicParameters());
}
public static SqlClause WorkOrderNotClosed(QueryContext ctx)
{
var d = ctx.Alias("bizclass");
var pdtl = ctx.Alias("posteddetail");
return new SqlClause(
$"-1 <> CASE\r\n" +
$" WHEN {d}.BIZTRANSACTIONCLASSID <> -1399999908 THEN -2100000000\r\n" +
$" ELSE {ctx.Dialect.IsNull($"{pdtl}.INDENTID", "-1")}\r\n" +
" END",
new DynamicParameters());
}
public static SqlClause BomItemSectionFilter(QueryContext ctx) => new(
$"{ctx.Alias("posteddetail")}.ROUTINGDETAILSLNO = 1\r\n" +
$" OR {ctx.Dialect.IsNull($"{ctx.Alias("bomitemsection")}.Indmatcount", "0")} > 1",
new DynamicParameters());
public static SqlClause ProductionRemainingFilter(QueryContext ctx) => new(
$"{ctx.Dialect.IsNull(ctx.Col("detail", "PLANNEDQUANTITY"), "0")}" +
$" - {ctx.Dialect.IsNull("vv.Goodquantity", "0")} > 0",
new DynamicParameters());
// ── Paging ────────────────────────────────────────────────────────
///
/// Generates the ORDER BY + paging tail. Always call this last when building a query.
/// Injects @pgOffset and @pgSize parameters.
///
public static (string Sql, DynamicParameters Params) Paging(
QueryContext ctx, ICriteria c, string orderBy = "1")
{
var p = new DynamicParameters();
p.Add("pgOffset", c.PageOffset);
p.Add("pgSize", c.PageSize);
return (ctx.Dialect.Paging(orderBy, "pgOffset", "pgSize"), p);
}
// ── Sort helpers ──────────────────────────────────────────────────
///
/// Builds a safe single-column ORDER BY expression.
/// is validated case-insensitively against
/// . Unknown or null values fall back to
/// (which is emitted verbatim — it must be
/// a trusted compile-time constant, never client input).
///
/// NEVER concatenate client-supplied sort columns directly into SQL.
/// Always pass them through this method.
///
public static string OrderBy(
string? sortBy,
bool sortDesc,
IReadOnlyDictionary allowList,
string defaultCol)
{
if (sortBy is null || !allowList.TryGetValue(sortBy, out var col))
return defaultCol;
return sortDesc ? $"{col} DESC" : $"{col} ASC";
}
///
/// Builds a compound ORDER BY from "field:direction" tokens
/// (e.g. ["headerdate:desc", "partyname:asc"]).
/// Unknown fields are silently dropped. Falls back to
/// if the result is empty.
///
public static string MultiOrderBy(
string[]? sortFields,
IReadOnlyDictionary allowList,
string defaultCol)
{
if (sortFields is null or { Length: 0 }) return defaultCol;
var parts = new List(sortFields.Length);
foreach (var f in sortFields)
{
var tokens = f.Split(':', 2);
var key = tokens[0].Trim();
var desc = tokens.Length > 1 &&
tokens[1].Trim().Equals("desc", StringComparison.OrdinalIgnoreCase);
if (allowList.TryGetValue(key, out var col))
parts.Add(desc ? $"{col} DESC" : $"{col} ASC");
// unknown fields are silently dropped — no injection risk
}
return parts.Count == 0 ? defaultCol : string.Join(", ", parts);
}
// ── Internal helper ───────────────────────────────────────────────
private static SqlClause Clause(string sql, string paramName, object value)
{
var p = new DynamicParameters();
p.Add(paramName, value);
return new SqlClause(sql, p);
}
}
}