diff --git a/.changeset/query-map-has-ops.md b/.changeset/query-map-has-ops.md new file mode 100644 index 000000000..62a7d17bc --- /dev/null +++ b/.changeset/query-map-has-ops.md @@ -0,0 +1,8 @@ +--- +"effect-app": minor +"@effect-app/infra": minor +--- + +Query maps as JSON arrays of `[key, value]` tuples. + +`where("meta", "hasKey" | "hasValue" | "hasKeyValue", ...)` (and `not*` / `*-any` / `*-all` variants) filter `ReadonlyMap` fields. Memory, Disk, SQLite, Postgres, and Cosmos compile those ops against the encoded tuple array. diff --git a/packages/effect-app/src/Model/filter/filterApi.ts b/packages/effect-app/src/Model/filter/filterApi.ts index 83e014bc2..81c5eb250 100644 --- a/packages/effect-app/src/Model/filter/filterApi.ts +++ b/packages/effect-app/src/Model/filter/filterApi.ts @@ -17,6 +17,24 @@ export type OtherOps = | "notIncludes-any" | "includes-all" | "notIncludes-all" + | "hasKey" + | "notHasKey" + | "hasValue" + | "notHasValue" + | "hasKeyValue" + | "notHasKeyValue" + | "hasKey-any" + | "notHasKey-any" + | "hasKey-all" + | "notHasKey-all" + | "hasValue-any" + | "notHasValue-any" + | "hasValue-all" + | "notHasValue-all" + | "hasKeyValue-any" + | "notHasKeyValue-any" + | "hasKeyValue-all" + | "notHasKeyValue-all" | "eq" | "neq" | "gt" diff --git a/packages/effect-app/src/Model/query/dsl.ts b/packages/effect-app/src/Model/query/dsl.ts index 3d114eb73..b91351471 100644 --- a/packages/effect-app/src/Model/query/dsl.ts +++ b/packages/effect-app/src/Model/query/dsl.ts @@ -1161,6 +1161,10 @@ type GetArV = T extends ReadonlySet ? R : T extends readonly (infer R)[] ? R : never +type GetMapK = T extends ReadonlyMap ? K : never +type GetMapV = T extends ReadonlyMap ? V : never +type GetMapEntry = T extends ReadonlyMap ? readonly [K, V] : never + type InValues = readonly T[] | ReadonlySet export type FilterContinuations = { @@ -1264,6 +1268,96 @@ export type FilterContinuations = { V extends FieldPathValue, TFieldValuesRefined extends TFieldValues = TFieldValues, E extends boolean = false + >( + path: TFieldName, + op: "hasKey" | "notHasKey", + value: GetMapK + ): ( + current: IsCurrentInitial extends true ? Query + : QueryWhere + ) => IsCurrentInitial extends true ? QueryWhere + : QueryWhere + < + TFieldValues extends FieldValues, + TFieldName extends FieldPath, + V extends FieldPathValue, + TFieldValuesRefined extends TFieldValues = TFieldValues, + E extends boolean = false + >( + path: TFieldName, + op: "hasValue" | "notHasValue", + value: GetMapV + ): ( + current: IsCurrentInitial extends true ? Query + : QueryWhere + ) => IsCurrentInitial extends true ? QueryWhere + : QueryWhere + < + TFieldValues extends FieldValues, + TFieldName extends FieldPath, + V extends FieldPathValue, + TFieldValuesRefined extends TFieldValues = TFieldValues, + E extends boolean = false + >( + path: TFieldName, + op: "hasKeyValue" | "notHasKeyValue", + value: GetMapEntry + ): ( + current: IsCurrentInitial extends true ? Query + : QueryWhere + ) => IsCurrentInitial extends true ? QueryWhere + : QueryWhere + < + TFieldValues extends FieldValues, + TFieldName extends FieldPath, + V extends FieldPathValue, + TFieldValuesRefined extends TFieldValues = TFieldValues, + E extends boolean = false + >( + path: TFieldName, + op: "hasKey-any" | "notHasKey-any" | "hasKey-all" | "notHasKey-all", + value: InValues> + ): ( + current: IsCurrentInitial extends true ? Query + : QueryWhere + ) => IsCurrentInitial extends true ? QueryWhere + : QueryWhere + < + TFieldValues extends FieldValues, + TFieldName extends FieldPath, + V extends FieldPathValue, + TFieldValuesRefined extends TFieldValues = TFieldValues, + E extends boolean = false + >( + path: TFieldName, + op: "hasValue-any" | "notHasValue-any" | "hasValue-all" | "notHasValue-all", + value: InValues> + ): ( + current: IsCurrentInitial extends true ? Query + : QueryWhere + ) => IsCurrentInitial extends true ? QueryWhere + : QueryWhere + < + TFieldValues extends FieldValues, + TFieldName extends FieldPath, + V extends FieldPathValue, + TFieldValuesRefined extends TFieldValues = TFieldValues, + E extends boolean = false + >( + path: TFieldName, + op: "hasKeyValue-any" | "notHasKeyValue-any" | "hasKeyValue-all" | "notHasKeyValue-all", + value: InValues> + ): ( + current: IsCurrentInitial extends true ? Query + : QueryWhere + ) => IsCurrentInitial extends true ? QueryWhere + : QueryWhere + < + TFieldValues extends FieldValues, + TFieldName extends FieldPath, + V extends FieldPathValue, + TFieldValuesRefined extends TFieldValues = TFieldValues, + E extends boolean = false >(f: { path: TFieldName op: "eq" @@ -1364,6 +1458,90 @@ export type FilterContinuationsWithSubpath = { ): ( current: Query ) => QueryWhere + < + TFieldValues extends FieldValues, + TFieldName extends FieldPath, + TFieldValuesSub extends TFieldValues[TFieldName][number], + TFieldNameSub extends FieldPath, + V extends FieldPathValue + >( + subPath: TFieldName, + restPath: TFieldNameSub, + op: "hasKey" | "notHasKey", + value: GetMapK + ): ( + current: Query + ) => QueryWhere + < + TFieldValues extends FieldValues, + TFieldName extends FieldPath, + TFieldValuesSub extends TFieldValues[TFieldName][number], + TFieldNameSub extends FieldPath, + V extends FieldPathValue + >( + subPath: TFieldName, + restPath: TFieldNameSub, + op: "hasValue" | "notHasValue", + value: GetMapV + ): ( + current: Query + ) => QueryWhere + < + TFieldValues extends FieldValues, + TFieldName extends FieldPath, + TFieldValuesSub extends TFieldValues[TFieldName][number], + TFieldNameSub extends FieldPath, + V extends FieldPathValue + >( + subPath: TFieldName, + restPath: TFieldNameSub, + op: "hasKeyValue" | "notHasKeyValue", + value: GetMapEntry + ): ( + current: Query + ) => QueryWhere + < + TFieldValues extends FieldValues, + TFieldName extends FieldPath, + TFieldValuesSub extends TFieldValues[TFieldName][number], + TFieldNameSub extends FieldPath, + V extends FieldPathValue + >( + subPath: TFieldName, + restPath: TFieldNameSub, + op: "hasKey-any" | "notHasKey-any" | "hasKey-all" | "notHasKey-all", + value: InValues> + ): ( + current: Query + ) => QueryWhere + < + TFieldValues extends FieldValues, + TFieldName extends FieldPath, + TFieldValuesSub extends TFieldValues[TFieldName][number], + TFieldNameSub extends FieldPath, + V extends FieldPathValue + >( + subPath: TFieldName, + restPath: TFieldNameSub, + op: "hasValue-any" | "notHasValue-any" | "hasValue-all" | "notHasValue-all", + value: InValues> + ): ( + current: Query + ) => QueryWhere + < + TFieldValues extends FieldValues, + TFieldName extends FieldPath, + TFieldValuesSub extends TFieldValues[TFieldName][number], + TFieldNameSub extends FieldPath, + V extends FieldPathValue + >( + subPath: TFieldName, + restPath: TFieldNameSub, + op: "hasKeyValue-any" | "notHasKeyValue-any" | "hasKeyValue-all" | "notHasKeyValue-all", + value: InValues> + ): ( + current: Query + ) => QueryWhere < TFieldValues extends FieldValues, TFieldName extends FieldPath, diff --git a/packages/infra/src/Store/Cosmos/query.ts b/packages/infra/src/Store/Cosmos/query.ts index e7589f059..f4689eee8 100644 --- a/packages/infra/src/Store/Cosmos/query.ts +++ b/packages/infra/src/Store/Cosmos/query.ts @@ -105,6 +105,43 @@ export function buildWhereCosmosQuery3( (x.value as readonly unknown[]).map((_, i) => `${v}__${i}`).join(", ") }))` + case "hasKey": + return `EXISTS(SELECT VALUE p FROM p IN ${k} WHERE p[0] = ${v})` + case "notHasKey": + return `(NOT EXISTS(SELECT VALUE p FROM p IN ${k} WHERE p[0] = ${v}))` + case "hasValue": + return `EXISTS(SELECT VALUE p FROM p IN ${k} WHERE p[1] = ${v})` + case "notHasValue": + return `(NOT EXISTS(SELECT VALUE p FROM p IN ${k} WHERE p[1] = ${v}))` + case "hasKeyValue": + return `ARRAY_CONTAINS(${k}, ${v})` + case "notHasKeyValue": + return `(NOT ARRAY_CONTAINS(${k}, ${v}))` + case "hasKey-any": + return `EXISTS(SELECT VALUE p FROM p IN ${k} WHERE ARRAY_CONTAINS(${v}, p[0]))` + case "notHasKey-any": + return `(NOT EXISTS(SELECT VALUE p FROM p IN ${k} WHERE ARRAY_CONTAINS(${v}, p[0])))` + case "hasKey-all": + return `(NOT EXISTS(SELECT VALUE key FROM key IN ${v} WHERE NOT EXISTS(SELECT VALUE p FROM p IN ${k} WHERE p[0] = key)))` + case "notHasKey-all": + return `EXISTS(SELECT VALUE key FROM key IN ${v} WHERE NOT EXISTS(SELECT VALUE p FROM p IN ${k} WHERE p[0] = key))` + case "hasValue-any": + return `EXISTS(SELECT VALUE p FROM p IN ${k} WHERE ARRAY_CONTAINS(${v}, p[1]))` + case "notHasValue-any": + return `(NOT EXISTS(SELECT VALUE p FROM p IN ${k} WHERE ARRAY_CONTAINS(${v}, p[1])))` + case "hasValue-all": + return `(NOT EXISTS(SELECT VALUE val FROM val IN ${v} WHERE NOT EXISTS(SELECT VALUE p FROM p IN ${k} WHERE p[1] = val)))` + case "notHasValue-all": + return `EXISTS(SELECT VALUE val FROM val IN ${v} WHERE NOT EXISTS(SELECT VALUE p FROM p IN ${k} WHERE p[1] = val))` + case "hasKeyValue-any": + return `EXISTS(SELECT VALUE pair FROM pair IN ${v} WHERE ARRAY_CONTAINS(${k}, pair))` + case "notHasKeyValue-any": + return `(NOT EXISTS(SELECT VALUE pair FROM pair IN ${v} WHERE ARRAY_CONTAINS(${k}, pair)))` + case "hasKeyValue-all": + return `(NOT EXISTS(SELECT VALUE pair FROM pair IN ${v} WHERE NOT ARRAY_CONTAINS(${k}, pair)))` + case "notHasKeyValue-all": + return `EXISTS(SELECT VALUE pair FROM pair IN ${v} WHERE NOT ARRAY_CONTAINS(${k}, pair))` + case "contains": return `CONTAINS(${k}, ${v}, true)` @@ -165,6 +202,24 @@ export function buildWhereCosmosQuery3( "notIncludes-any": "includes-any", "includes-all": "notIncludes-all", "notIncludes-all": "includes-all", + hasKey: "notHasKey", + notHasKey: "hasKey", + hasValue: "notHasValue", + notHasValue: "hasValue", + hasKeyValue: "notHasKeyValue", + notHasKeyValue: "hasKeyValue", + "hasKey-any": "notHasKey-any", + "notHasKey-any": "hasKey-any", + "hasKey-all": "notHasKey-all", + "notHasKey-all": "hasKey-all", + "hasValue-any": "notHasValue-any", + "notHasValue-any": "hasValue-any", + "hasValue-all": "notHasValue-all", + "notHasValue-all": "hasValue-all", + "hasKeyValue-any": "notHasKeyValue-any", + "notHasKeyValue-any": "hasKeyValue-any", + "hasKeyValue-all": "notHasKeyValue-all", + "notHasKeyValue-all": "hasKeyValue-all", in: "notIn", notIn: "in" } satisfies Record diff --git a/packages/infra/src/Store/SQL/query.ts b/packages/infra/src/Store/SQL/query.ts index 5bb044bf6..a8d6ad980 100644 --- a/packages/infra/src/Store/SQL/query.ts +++ b/packages/infra/src/Store/SQL/query.ts @@ -18,6 +18,9 @@ export interface SQLDialect { readonly jsonArrayNotContainsAny: (arrPath: string, valPlaceholders: readonly string[]) => string readonly jsonArrayContainsAll: (arrPath: string, valPlaceholders: readonly string[]) => string readonly jsonArrayNotContainsAll: (arrPath: string, valPlaceholders: readonly string[]) => string + readonly jsonMapHasKey: (arrPath: string, valPlaceholder: string) => string + readonly jsonMapHasValue: (arrPath: string, valPlaceholder: string) => string + readonly jsonMapHasPair: (arrPath: string, valPlaceholder: string) => string readonly caseInsensitiveLike: (expr: string, valPlaceholder: string) => string readonly caseInsensitiveNotLike: (expr: string, valPlaceholder: string) => string readonly jsonColumnType: "JSON" | "JSONB" @@ -46,6 +49,11 @@ export const sqliteDialect: SQLDialect = { `NOT (${ vals.map((v) => `EXISTS(SELECT 1 FROM json_each(data, '$.${arrPath}') WHERE value = ${v})`).join(" AND ") })`, + jsonMapHasKey: (arrPath, val) => + `EXISTS(SELECT 1 FROM json_each(data, '$.${arrPath}') WHERE json_extract(value, '$[0]') = ${val})`, + jsonMapHasValue: (arrPath, val) => + `EXISTS(SELECT 1 FROM json_each(data, '$.${arrPath}') WHERE json_extract(value, '$[1]') = ${val})`, + jsonMapHasPair: (arrPath, val) => `EXISTS(SELECT 1 FROM json_each(data, '$.${arrPath}') WHERE value = ${val})`, caseInsensitiveLike: (expr, val) => `LOWER(${expr}) LIKE LOWER(${val})`, caseInsensitiveNotLike: (expr, val) => `LOWER(${expr}) NOT LIKE LOWER(${val})`, jsonColumnType: "JSON", @@ -113,6 +121,27 @@ export const pgDialect: SQLDialect = { : `data${parts.map((p) => `->'${p}'`).join("")}` return `NOT (${vals.map((v) => `${jsonPath} @> ${v}::jsonb`).join(" AND ")})` }, + jsonMapHasKey: (arrPath, val) => { + const parts = arrPath.split(".") + const jsonPath = parts.length === 1 + ? `data->'${parts[0]}'` + : `data${parts.map((p) => `->'${p}'`).join("")}` + return `EXISTS(SELECT 1 FROM jsonb_array_elements(${jsonPath}) e WHERE e->0 = ${val}::jsonb)` + }, + jsonMapHasValue: (arrPath, val) => { + const parts = arrPath.split(".") + const jsonPath = parts.length === 1 + ? `data->'${parts[0]}'` + : `data${parts.map((p) => `->'${p}'`).join("")}` + return `EXISTS(SELECT 1 FROM jsonb_array_elements(${jsonPath}) e WHERE e->1 = ${val}::jsonb)` + }, + jsonMapHasPair: (arrPath, val) => { + const parts = arrPath.split(".") + const jsonPath = parts.length === 1 + ? `data->'${parts[0]}'` + : `data${parts.map((p) => `->'${p}'`).join("")}` + return `${jsonPath} @> jsonb_build_array(${val}::jsonb)` + }, caseInsensitiveLike: (expr, val) => `${expr} ILIKE ${val}`, caseInsensitiveNotLike: (expr, val) => `${expr} NOT ILIKE ${val}`, jsonColumnType: "JSONB", @@ -150,6 +179,14 @@ const dottedToJsonPath = (path: string) => .filter((p) => p !== "-1") .join(".") +const mapItemParam = (dialect: SQLDialect, value: unknown) => + dialect.jsonColumnType === "JSONB" ? dialect.serializeJsonValue(value) : value + +const mapPairParam = (dialect: SQLDialect, value: unknown) => { + const encoded = dialect.serializeJsonValue(value) + return typeof encoded === "string" ? encoded : JSON.stringify(value) +} + const sqlStringLiteral = (value: string) => `'${value.replaceAll("'", "''")}'` export function buildWhereSQLQuery( @@ -278,6 +315,109 @@ export function buildWhereSQLQuery( return dialect.jsonArrayNotContainsAll(arrPath, placeholders) } + case "hasKey": { + const arrPath = dottedToJsonPath(resolvedPath) + const v = addParam(mapItemParam(dialect, x.value)) + return dialect.jsonMapHasKey(arrPath, v) + } + case "notHasKey": { + const arrPath = dottedToJsonPath(resolvedPath) + const v = addParam(mapItemParam(dialect, x.value)) + return `NOT (${dialect.jsonMapHasKey(arrPath, v)})` + } + case "hasValue": { + const arrPath = dottedToJsonPath(resolvedPath) + const v = addParam(mapItemParam(dialect, x.value)) + return dialect.jsonMapHasValue(arrPath, v) + } + case "notHasValue": { + const arrPath = dottedToJsonPath(resolvedPath) + const v = addParam(mapItemParam(dialect, x.value)) + return `NOT (${dialect.jsonMapHasValue(arrPath, v)})` + } + case "hasKeyValue": { + const arrPath = dottedToJsonPath(resolvedPath) + const v = addParam(mapPairParam(dialect, x.value)) + return dialect.jsonMapHasPair(arrPath, v) + } + case "notHasKeyValue": { + const arrPath = dottedToJsonPath(resolvedPath) + const v = addParam(mapPairParam(dialect, x.value)) + return `NOT (${dialect.jsonMapHasPair(arrPath, v)})` + } + case "hasKey-any": { + const arrPath = dottedToJsonPath(resolvedPath) + const vals = x.value as readonly unknown[] + const parts = vals.map((val) => dialect.jsonMapHasKey(arrPath, addParam(mapItemParam(dialect, val)))) + return `(${parts.join(" OR ")})` + } + case "notHasKey-any": { + const arrPath = dottedToJsonPath(resolvedPath) + const vals = x.value as readonly unknown[] + const parts = vals.map((val) => dialect.jsonMapHasKey(arrPath, addParam(mapItemParam(dialect, val)))) + return `NOT (${parts.join(" OR ")})` + } + case "hasKey-all": { + const arrPath = dottedToJsonPath(resolvedPath) + const vals = x.value as readonly unknown[] + return vals.map((val) => dialect.jsonMapHasKey(arrPath, addParam(mapItemParam(dialect, val)))).join(" AND ") + } + case "notHasKey-all": { + const arrPath = dottedToJsonPath(resolvedPath) + const vals = x.value as readonly unknown[] + return `NOT (${ + vals.map((val) => dialect.jsonMapHasKey(arrPath, addParam(mapItemParam(dialect, val)))).join(" AND ") + })` + } + case "hasValue-any": { + const arrPath = dottedToJsonPath(resolvedPath) + const vals = x.value as readonly unknown[] + const parts = vals.map((val) => dialect.jsonMapHasValue(arrPath, addParam(mapItemParam(dialect, val)))) + return `(${parts.join(" OR ")})` + } + case "notHasValue-any": { + const arrPath = dottedToJsonPath(resolvedPath) + const vals = x.value as readonly unknown[] + const parts = vals.map((val) => dialect.jsonMapHasValue(arrPath, addParam(mapItemParam(dialect, val)))) + return `NOT (${parts.join(" OR ")})` + } + case "hasValue-all": { + const arrPath = dottedToJsonPath(resolvedPath) + const vals = x.value as readonly unknown[] + return vals.map((val) => dialect.jsonMapHasValue(arrPath, addParam(mapItemParam(dialect, val)))).join(" AND ") + } + case "notHasValue-all": { + const arrPath = dottedToJsonPath(resolvedPath) + const vals = x.value as readonly unknown[] + return `NOT (${ + vals.map((val) => dialect.jsonMapHasValue(arrPath, addParam(mapItemParam(dialect, val)))).join(" AND ") + })` + } + case "hasKeyValue-any": { + const arrPath = dottedToJsonPath(resolvedPath) + const vals = x.value as readonly unknown[] + const parts = vals.map((val) => dialect.jsonMapHasPair(arrPath, addParam(mapPairParam(dialect, val)))) + return `(${parts.join(" OR ")})` + } + case "notHasKeyValue-any": { + const arrPath = dottedToJsonPath(resolvedPath) + const vals = x.value as readonly unknown[] + const parts = vals.map((val) => dialect.jsonMapHasPair(arrPath, addParam(mapPairParam(dialect, val)))) + return `NOT (${parts.join(" OR ")})` + } + case "hasKeyValue-all": { + const arrPath = dottedToJsonPath(resolvedPath) + const vals = x.value as readonly unknown[] + return vals.map((val) => dialect.jsonMapHasPair(arrPath, addParam(mapPairParam(dialect, val)))).join(" AND ") + } + case "notHasKeyValue-all": { + const arrPath = dottedToJsonPath(resolvedPath) + const vals = x.value as readonly unknown[] + return `NOT (${ + vals.map((val) => dialect.jsonMapHasPair(arrPath, addParam(mapPairParam(dialect, val)))).join(" AND ") + })` + } + case "contains": { const v = addParam(`%${x.value}%`) return dialect.caseInsensitiveLike(k, v) diff --git a/packages/infra/src/Store/codeFilter.ts b/packages/infra/src/Store/codeFilter.ts index 8a04fb5fe..5de521c4b 100644 --- a/packages/infra/src/Store/codeFilter.ts +++ b/packages/infra/src/Store/codeFilter.ts @@ -10,6 +10,17 @@ import { compare, get, greaterThan, greaterThanExclusive, lowerThan, lowerThanEx const vAsArr = (v: unknown) => toJsonQueryValue(v) as any[] +const mapEntries = (value: unknown): readonly [unknown, unknown][] => { + const json = toJsonQueryValue(value) + if (!Array.isArray(json)) return [] + return json.filter((entry): entry is [unknown, unknown] => Array.isArray(entry) && entry.length >= 2) +} + +const pairEq = (entry: readonly [unknown, unknown], pair: unknown) => { + const json = toJsonQueryValue(pair) + return Array.isArray(json) && json.length >= 2 && compare(entry[0], json[0]) && compare(entry[1], json[1]) +} + const filterStatement = (x: any, p: FilterR) => { const k = toJsonQueryValue(get(x, p.path)) const v = toJsonQueryValue(p.value) @@ -38,6 +49,42 @@ const filterStatement = (x: any, p: FilterR) => { return (vAsArr(p.value)).every((_) => (k as Array)?.includes(_)) case "notIncludes-all": return !(vAsArr(p.value)).every((_) => (k as Array)?.includes(_)) + case "hasKey": + return mapEntries(k).some(([key]) => compare(key, v)) + case "notHasKey": + return !mapEntries(k).some(([key]) => compare(key, v)) + case "hasValue": + return mapEntries(k).some(([, val]) => compare(val, v)) + case "notHasValue": + return !mapEntries(k).some(([, val]) => compare(val, v)) + case "hasKeyValue": + return mapEntries(k).some((entry) => pairEq(entry, v)) + case "notHasKeyValue": + return !mapEntries(k).some((entry) => pairEq(entry, v)) + case "hasKey-any": + return vAsArr(p.value).some((key) => mapEntries(k).some(([k0]) => compare(k0, key))) + case "notHasKey-any": + return !vAsArr(p.value).some((key) => mapEntries(k).some(([k0]) => compare(k0, key))) + case "hasKey-all": + return vAsArr(p.value).every((key) => mapEntries(k).some(([k0]) => compare(k0, key))) + case "notHasKey-all": + return !vAsArr(p.value).every((key) => mapEntries(k).some(([k0]) => compare(k0, key))) + case "hasValue-any": + return vAsArr(p.value).some((val) => mapEntries(k).some(([, v0]) => compare(v0, val))) + case "notHasValue-any": + return !vAsArr(p.value).some((val) => mapEntries(k).some(([, v0]) => compare(v0, val))) + case "hasValue-all": + return vAsArr(p.value).every((val) => mapEntries(k).some(([, v0]) => compare(v0, val))) + case "notHasValue-all": + return !vAsArr(p.value).every((val) => mapEntries(k).some(([, v0]) => compare(v0, val))) + case "hasKeyValue-any": + return vAsArr(p.value).some((pair) => mapEntries(k).some((entry) => pairEq(entry, pair))) + case "notHasKeyValue-any": + return !vAsArr(p.value).some((pair) => mapEntries(k).some((entry) => pairEq(entry, pair))) + case "hasKeyValue-all": + return vAsArr(p.value).every((pair) => mapEntries(k).some((entry) => pairEq(entry, pair))) + case "notHasKeyValue-all": + return !vAsArr(p.value).every((pair) => mapEntries(k).some((entry) => pairEq(entry, pair))) case "contains": return (k as string).toLowerCase().includes((v as string).toLowerCase()) case "endsWith": diff --git a/packages/infra/test/cosmos-query.test.ts b/packages/infra/test/cosmos-query.test.ts index 7c4535322..c3462e351 100644 --- a/packages/infra/test/cosmos-query.test.ts +++ b/packages/infra/test/cosmos-query.test.ts @@ -38,6 +38,42 @@ describe("cosmos query filter: native Encoded values", () => { ) }) + it("emits EXISTS over Map JSON tuples for hasKey / hasValue / hasKeyValue", () => { + const byKey = buildWhereCosmosQuery3( + "id", + [{ t: "where", path: "meta", op: "hasKey", value: "n" }], + "Orders", + {} + ) + expect(byKey.query).toContain("EXISTS(SELECT VALUE p FROM p IN f[\"meta\"] WHERE p[0] = @v0)") + expect(byKey.parameters).toEqual(expect.arrayContaining([{ name: "@v0", value: "n" }])) + + const byValue = buildWhereCosmosQuery3( + "id", + [{ t: "where", path: "meta", op: "hasValue", value: 1 }], + "Orders", + {} + ) + expect(byValue.query).toContain("p[1] = @v0") + + const byPair = buildWhereCosmosQuery3( + "id", + [{ t: "where", path: "meta", op: "hasKeyValue", value: ["n", 1] }], + "Orders", + {} + ) + expect(byPair.query).toContain("ARRAY_CONTAINS") + expect(byPair.parameters).toEqual(expect.arrayContaining([{ name: "@v0", value: ["n", 1] }])) + + const anyKey = buildWhereCosmosQuery3( + "id", + [{ t: "where", path: "meta", op: "hasKey-any", value: ["n", "x"] }], + "Orders", + {} + ) + expect(anyKey.query).toContain("ARRAY_CONTAINS(@v0, p[0])") + }) + it("binds includes Date as ISO string", () => { const result = buildWhereCosmosQuery3( "id", diff --git a/packages/infra/test/query.test.ts b/packages/infra/test/query.test.ts index f5cdc07f9..7028e1461 100644 --- a/packages/infra/test/query.test.ts +++ b/packages/infra/test/query.test.ts @@ -214,6 +214,10 @@ it("memory store round-trips Date/Set/Map via JSON codecs", () => expect(byDate.map((_) => _.id)).toEqual(["d1"]) const byTag = yield* repo.query(where("tags", "includes", "b")) expect(byTag.map((_) => _.id)).toEqual(["d1"]) + const byKey = yield* repo.query(where("meta", "hasKey", "n")) + expect(byKey.map((_) => _.id)).toEqual(["d1"]) + const byPair = yield* repo.query(where("meta", "hasKeyValue", ["n", 1])) + expect(byPair.map((_) => _.id)).toEqual(["d1"]) }) .pipe(Effect.provide(TestStoreLive), setupRequestContextFromCurrent(), Effect.scoped, Effect.runPromise)) @@ -1456,6 +1460,28 @@ it("does not allow string queries on arrays", () => expectTypeOf(n4).toEqualTypeOf>() expectTypeOf(n5).toEqualTypeOf>() expectTypeOf(n6).toEqualTypeOf>() + + type WithMap = { + readonly id: string + readonly meta: ReadonlyMap + } + const mapped = make() + const m1 = mapped.pipe(where("meta", "hasKey", "n")) + const m2 = mapped.pipe(where("meta", "hasValue", 1)) + const m3 = mapped.pipe(where("meta", "hasKeyValue", ["n", 1] as const)) + const m4 = mapped.pipe(where("meta", "hasKey-any", ["n", "x"])) + const m5 = mapped.pipe(where("meta", "hasValue-all", new Set([1, 2]))) + const m6 = mapped.pipe(where("meta", "hasKeyValue-any", [["n", 1] as const, ["x", 2] as const])) + expectTypeOf(m1).toEqualTypeOf>() + expectTypeOf(m2).toEqualTypeOf>() + expectTypeOf(m3).toEqualTypeOf>() + expectTypeOf(m4).toEqualTypeOf>() + expectTypeOf(m5).toEqualTypeOf>() + expectTypeOf(m6).toEqualTypeOf>() + // @ts-expect-error cannot hasKey on a string field + mapped.pipe(where("id", "hasKey", "n")) + // @ts-expect-error hasKey value must be the map key type + mapped.pipe(where("meta", "hasKey", 1)) }) .pipe(Effect.provide(TestStoreLive), setupRequestContextFromCurrent(), Effect.scoped, Effect.runPromise)) @@ -2200,6 +2226,32 @@ it("codeFilter: Date array / Set includes and in", () => { expect(run(make().pipe(where("tag", "in", new Set(["a"]))))).toEqual(["1"]) }) +it("codeFilter: Map hasKey / hasValue / hasKeyValue", () => { + type MapRow = { + readonly id: string + readonly meta: ReadonlyMap + } + const rows: MapRow[] = [ + { id: "1", meta: new Map([["n", 1], ["x", 2]]) }, + { id: "2", meta: new Map([["n", 9]]) }, + { id: "3", meta: new Map([["z", 2]]) } + ] + const run = (q: any) => (memFilter(toFilter(q))(rows) as MapRow[]).map((_) => _.id) + expect(run(make().pipe(where("meta", "hasKey", "n"))).sort()).toEqual(["1", "2"]) + expect(run(make().pipe(where("meta", "notHasKey", "n")))).toEqual(["3"]) + expect(run(make().pipe(where("meta", "hasValue", 2))).sort()).toEqual(["1", "3"]) + expect(run(make().pipe(where("meta", "notHasValue", 2)))).toEqual(["2"]) + expect(run(make().pipe(where("meta", "hasKeyValue", ["n", 1])))).toEqual(["1"]) + expect(run(make().pipe(where("meta", "notHasKeyValue", ["n", 1]))).sort()).toEqual(["2", "3"]) + expect(run(make().pipe(where("meta", "hasKey-any", ["z", "missing"])))).toEqual(["3"]) + expect(run(make().pipe(where("meta", "hasKey-all", ["n", "x"])))).toEqual(["1"]) + expect(run(make().pipe(where("meta", "notHasKey-all", ["n", "x"]))).sort()).toEqual(["2", "3"]) + expect(run(make().pipe(where("meta", "hasValue-any", [9, 99])))).toEqual(["2"]) + expect(run(make().pipe(where("meta", "hasValue-all", [1, 2])))).toEqual(["1"]) + expect(run(make().pipe(where("meta", "hasKeyValue-any", [["n", 9], ["missing", 0]])))).toEqual(["2"]) + expect(run(make().pipe(where("meta", "hasKeyValue-all", [["n", 1], ["x", 2]])))).toEqual(["1"]) +}) + it("codeFilter: in / notIn", () => { expect(runCF(make().pipe(where("tag", "in", ["x", "z"]))).sort()).toEqual(["1", "3"]) expect(runCF(make().pipe(where("tag", "notIn", ["x", "z"]))).sort()).toEqual(["2", "4"]) diff --git a/packages/infra/test/sql-store.test.ts b/packages/infra/test/sql-store.test.ts index 59adc1c25..a9c0e87e0 100644 --- a/packages/infra/test/sql-store.test.ts +++ b/packages/infra/test/sql-store.test.ts @@ -57,6 +57,72 @@ describe("SQL query builder (SQLite dialect)", () => { expect(result.params).toContain("2024-01-01T00:00:00.000Z") }) + it("where hasKey / hasValue / hasKeyValue on Map JSON tuples", () => { + const key = buildWhereSQLQuery( + sqliteDialect, + "id", + [{ t: "where", path: "meta", op: "hasKey", value: "n" }], + "users", + {} + ) + expect(key.sql).toContain("json_extract(value, '$[0]')") + expect(key.params).toContain("n") + + const value = buildWhereSQLQuery( + sqliteDialect, + "id", + [{ t: "where", path: "meta", op: "hasValue", value: 1 }], + "users", + {} + ) + expect(value.sql).toContain("json_extract(value, '$[1]')") + expect(value.params).toContain(1) + + const pair = buildWhereSQLQuery( + sqliteDialect, + "id", + [{ t: "where", path: "meta", op: "hasKeyValue", value: ["n", 1] }], + "users", + {} + ) + expect(pair.sql).toContain("json_each") + expect(pair.params).toContain(JSON.stringify(["n", 1])) + + const anyKeys = buildWhereSQLQuery( + sqliteDialect, + "id", + [{ t: "where", path: "meta", op: "hasKey-any", value: ["n", "x"] }], + "users", + {} + ) + expect(anyKeys.sql).toContain(" OR ") + expect(anyKeys.params).toEqual(expect.arrayContaining(["n", "x"])) + }) + + it("pg where hasKey / hasKeyValue uses jsonb tuple elements", () => { + const key = buildWhereSQLQuery( + pgDialect, + "id", + [{ t: "where", path: "meta", op: "hasKey", value: "n" }], + "users", + {} + ) + expect(key.sql).toContain("jsonb_array_elements") + expect(key.sql).toContain("e->0") + expect(key.params).toContain(JSON.stringify("n")) + + const pair = buildWhereSQLQuery( + pgDialect, + "id", + [{ t: "where", path: "meta", op: "hasKeyValue", value: ["n", 1] }], + "users", + {} + ) + expect(pair.sql).toContain("@>") + expect(pair.sql).toContain("jsonb_build_array") + expect(pair.params).toContain(JSON.stringify(["n", 1])) + }) + it("where includes-any Date[] binds ISO strings", () => { const result = buildWhereSQLQuery( sqliteDialect, @@ -1508,6 +1574,44 @@ describe("boolean WHERE clauses — SQLite integration (end-to-end)", () => { expect((JSON.parse(rows[0].data) as any).name).toBe("Alice") })) + it("where hasKey / hasValue / hasKeyValue match Map tuple JSON", () => + withDb((db) => { + db.exec(`CREATE TABLE "t" (id TEXT PRIMARY KEY, _etag TEXT, data JSON NOT NULL)`) + db + .prepare(`INSERT INTO "t" (id, _etag, data) VALUES (?, ?, ?)`) + .run("1", "e", JSON.stringify({ meta: [["n", 1], ["x", 2]] })) + db + .prepare(`INSERT INTO "t" (id, _etag, data) VALUES (?, ?, ?)`) + .run("2", "e", JSON.stringify({ meta: [["n", 9]] })) + + const byKey = buildWhereSQLQuery( + sqliteDialect, + "id", + [{ t: "where", path: "meta", op: "hasKey", value: "x" }], + "t", + {} + ) + expect(query(db, byKey.sql, byKey.params).map((r) => r.id)).toEqual(["1"]) + + const byValue = buildWhereSQLQuery( + sqliteDialect, + "id", + [{ t: "where", path: "meta", op: "hasValue", value: 9 }], + "t", + {} + ) + expect(query(db, byValue.sql, byValue.params).map((r) => r.id)).toEqual(["2"]) + + const byPair = buildWhereSQLQuery( + sqliteDialect, + "id", + [{ t: "where", path: "meta", op: "hasKeyValue", value: ["n", 1] }], + "t", + {} + ) + expect(query(db, byPair.sql, byPair.params).map((r) => r.id)).toEqual(["1"]) + })) + it("where neq boolean works", () => withDb((db) => { db.exec(`CREATE TABLE "t" (id TEXT PRIMARY KEY, _etag TEXT, data JSON NOT NULL)`)