Skip to content

Commit a0c2fbc

Browse files
committed
docs: implement SQL query transformations in NeoFilter component
1 parent be2bf20 commit a0c2fbc

File tree

4 files changed

+141
-54
lines changed

4 files changed

+141
-54
lines changed

packages/docs/src/components/examples/neo-uglysearch/index.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,36 @@ import {
66
import { useState } from "react";
77
import { defaultRule, filterFnList, filterSchema } from "./schema";
88
import { theme } from "./theme";
9-
import { filterRuleToMeilisearch } from "./transform";
9+
import { filterRuleToMeilisearch } from "./transform-meilisearch";
10+
import { filterRuleToSQL } from "./transform-sql";
1011

1112
// See https://github.com/saveweb/neo-uglysearch/tree/main/app/components/filter-sphere
1213

1314
export function NeoFilter() {
1415
const [query, setQuery] = useState<string>(
1516
filterRuleToMeilisearch(defaultRule),
1617
);
18+
const [sqlQuery, setSqlQuery] = useState<string>(
19+
filterRuleToSQL(defaultRule),
20+
);
1721
const { context } = useFilterSphere({
1822
schema: filterSchema,
1923
defaultRule,
2024
filterFnList,
2125
onRuleChange: ({ filterRule }) => {
22-
const query = filterRuleToMeilisearch(filterRule);
23-
setQuery(query);
26+
setQuery(filterRuleToMeilisearch(filterRule));
27+
setSqlQuery(filterRuleToSQL(filterRule));
2428
},
2529
});
2630
return (
2731
<FilterSphereProvider context={context} theme={theme}>
2832
<FilterBuilder />
2933

34+
<div className="mt-2 text-black relative flex flex-col items-start rounded-md border-2 border-black px-3 py-2 gap-2 bg-opacity bg-[#fff4e0]">
35+
<div>SQL query:</div>
36+
<div>{sqlQuery}</div>
37+
</div>
38+
3039
<div className="mt-2 text-black relative flex flex-col items-start rounded-md border-2 border-black px-3 py-2 gap-2 bg-opacity bg-[#fff4e0]">
3140
<div>Meilisearch query:</div>
3241
<div>{query}</div>

packages/docs/src/components/examples/neo-uglysearch/transform.ts renamed to packages/docs/src/components/examples/neo-uglysearch/transform-meilisearch.ts

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -121,53 +121,3 @@ function transformFilterGroup(filterGroup: FilterGroup): string | null {
121121
export const filterRuleToMeilisearch = (filterGroup: FilterGroup) => {
122122
return transformFilterGroup(filterGroup) ?? "";
123123
};
124-
125-
/**
126-
* Serializes a FilterGroup object to JSON string with special date handling
127-
*/
128-
export function serializeFilterGroup(filterGroup: FilterGroup): string {
129-
const replacer = function (this: any, key: string) {
130-
// Special handling for Date objects in args
131-
return this[key] instanceof Date
132-
? {
133-
__type: "Date",
134-
value: this[key].toISOString(),
135-
}
136-
: this[key];
137-
};
138-
const serialized = JSON.stringify(filterGroup, replacer);
139-
return serialized;
140-
}
141-
142-
/**
143-
* deserializes a JSON string back to FilterGroup object
144-
*/
145-
export function deserializeFilterGroup(serialized: string): FilterGroup {
146-
const deserialized = JSON.parse(serialized, (_, value) => {
147-
// Revive Date objects from special format
148-
if (value && typeof value === "object" && value.__type === "Date") {
149-
return new Date(value.value);
150-
}
151-
return value;
152-
});
153-
154-
// Type guard to ensure we have a valid FilterGroup
155-
if (!isValidFilterGroup(deserialized)) {
156-
throw new Error("Invalid FilterGroup structure");
157-
}
158-
return deserialized;
159-
}
160-
161-
/**
162-
* Type guard to validate FilterGroup structure
163-
*/
164-
function isValidFilterGroup(obj: any): obj is FilterGroup {
165-
return (
166-
obj &&
167-
typeof obj === "object" &&
168-
typeof obj.id === "string" &&
169-
obj.type === "FilterGroup" &&
170-
(obj.op === "and" || obj.op === "or") &&
171-
Array.isArray(obj.conditions)
172-
);
173-
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import type { FilterGroup, SingleFilter } from "@fn-sphere/filter";
2+
import { z } from "zod";
3+
import { filterFnList } from "./schema";
4+
5+
const SQL_OPERATORS: Record<string, string> = {
6+
equals: "=",
7+
notEquals: "!=",
8+
greaterThan: ">",
9+
greaterThanOrEqual: ">=",
10+
lessThan: "<",
11+
lessThanOrEqual: "<=",
12+
contains: "LIKE",
13+
notContains: "NOT LIKE",
14+
startsWith: "LIKE",
15+
notStartsWith: "NOT LIKE",
16+
in: "IN",
17+
notIn: "NOT IN",
18+
isNull: "IS NULL",
19+
isNotNull: "IS NOT NULL",
20+
isEmpty: "= ''",
21+
isNotEmpty: "!= ''",
22+
before: "<",
23+
after: ">",
24+
};
25+
26+
const checkUnaryFilter = (filterName: string) => {
27+
const filterSchema = filterFnList.find((fn) => fn.name === filterName);
28+
if (!filterSchema) throw new Error("Unknown filter! " + filterName);
29+
const filterDefine =
30+
typeof filterSchema.define === "function"
31+
? filterSchema.define(z.any())
32+
: filterSchema.define;
33+
const parameters = filterDefine._zod.def.input as z.ZodTuple;
34+
return parameters._zod.def.items.length <= 1;
35+
};
36+
37+
function escapeSQL(value: string): string {
38+
return value.replace(/'/g, "''");
39+
}
40+
41+
function transformSingleFilter(filter: SingleFilter): string | null {
42+
const path = filter.path?.[0];
43+
const operator = filter.name ? SQL_OPERATORS[filter.name] : undefined;
44+
const value = filter.args[0];
45+
46+
if (!filter.name || path === undefined || operator === undefined) {
47+
return null;
48+
}
49+
const isUnaryFilter = checkUnaryFilter(filter.name);
50+
if (value === undefined && !isUnaryFilter) {
51+
return null;
52+
}
53+
54+
// Handle array values for IN/NOT IN
55+
if (Array.isArray(value)) {
56+
const items = value
57+
.map((v) => (typeof v === "string" ? `'${escapeSQL(v)}'` : v))
58+
.join(", ");
59+
return `${path} ${operator} (${items})`;
60+
}
61+
62+
// Unary operators (IS NULL, IS NOT NULL, = '', != '')
63+
if (value === undefined) {
64+
return `${path} ${operator}`;
65+
}
66+
67+
// LIKE patterns for contains/startsWith
68+
if (typeof value === "string") {
69+
const escaped = escapeSQL(value);
70+
if (filter.name === "contains" || filter.name === "notContains") {
71+
return `${path} ${operator} '%${escaped}%'`;
72+
}
73+
if (filter.name === "startsWith" || filter.name === "notStartsWith") {
74+
return `${path} ${operator} '${escaped}%'`;
75+
}
76+
return `${path} ${operator} '${escaped}'`;
77+
}
78+
79+
if (value instanceof Date) {
80+
return `${path} ${operator} '${value.toISOString().split("T")[0]}'`;
81+
}
82+
83+
return `${path} ${operator} ${value}`;
84+
}
85+
86+
function transformFilterGroup(filterGroup: FilterGroup): string | null {
87+
if (!filterGroup.conditions.length) return "";
88+
89+
const conditions = filterGroup.conditions.map((condition) => {
90+
if (condition.type === "Filter") {
91+
return transformSingleFilter(condition);
92+
} else {
93+
return transformFilterGroup(condition);
94+
}
95+
});
96+
97+
const operator = filterGroup.op.toUpperCase() as Uppercase<FilterGroup["op"]>;
98+
const result = conditions.filter((i) => i !== null).join(` ${operator} `);
99+
if (!result) {
100+
return null;
101+
}
102+
103+
return `(${result})`;
104+
}
105+
106+
/**
107+
* Transforms a FilterGroup object into a SQL WHERE clause.
108+
*
109+
* @example
110+
* ```ts
111+
* filterRuleToSQL({
112+
* type: "FilterGroup",
113+
* op: "and",
114+
* conditions: [{
115+
* type: "Filter",
116+
* path: ["title"],
117+
* name: "equals",
118+
* args: ["hello world"]
119+
* }]
120+
* })
121+
* // "WHERE (title = 'hello world')"
122+
* ```
123+
*/
124+
export const filterRuleToSQL = (filterGroup: FilterGroup) => {
125+
const where = transformFilterGroup(filterGroup);
126+
if (!where) return "";
127+
return `WHERE ${where}`;
128+
};

packages/docs/src/content/docs/reference/example.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { PopoverFilter } from "~/components/examples/popover-filter/index.tsx";
3838

3939
## Backend Integration
4040

41-
Filter Sphere is easy to integrate with backend systems. This example demonstrates integration with [Meilisearch](https://www.meilisearch.com/). You can try it out by typing in the search box below.
41+
Filter Sphere is easy to integrate with backend systems. This example demonstrates converting filter rules into SQL WHERE clauses and [Meilisearch](https://www.meilisearch.com/) queries in real time.
4242

4343
import { NeoFilter } from "~/components/examples/neo-uglysearch/index.tsx";
4444

0 commit comments

Comments
 (0)