Skip to content

Commit 6aef427

Browse files
andreasrongegithub-actions[bot]claude
authored
feat: Add keyword/string type coercion to where clause comparisons (#232) (#233)
Implement automatic coercion of keywords to strings in equality operators (=, not=), and membership operators (in, includes). This improves LLM ergonomics by allowing keywords generated by LLMs to match string data values without explicit conversion. - Add normalize_for_comparison helper to coerce non-boolean atoms to strings - Update safe_eq, safe_in, safe_includes to use coercion - Exclude booleans from coercion (true/false do not coerce to strings) - Add comprehensive tests covering keyword/string matching - Document coercion behavior in Section 7.1 of ptc-lisp-specification.md - Note that ordering comparisons (>, <, >=, <=) do NOT coerce 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 1801253 commit 6aef427

File tree

3 files changed

+155
-3
lines changed

3 files changed

+155
-3
lines changed

docs/ptc-lisp-specification.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,25 @@ Check if field is truthy (not `nil` or `false`):
478478
(where [:user :premium]) ; nested truthy check
479479
```
480480

481+
#### Keyword/String Coercion
482+
483+
For the equality operators (`=`, `not=`), `in`, and `includes`, keywords are coerced to strings for comparison. This allows LLM-generated keywords to match string data values:
484+
485+
```clojure
486+
;; Keyword coerces to string
487+
(where :status = :active) ; matches if field is "active"
488+
(where :status in [:active :pending]) ; both keywords coerce to strings
489+
(where :tags includes :urgent) ; keyword "urgent" matches in ["urgent" "bug"]
490+
```
491+
492+
**Coercion rules:**
493+
- Keywords (atoms that are not booleans) coerce to their string representation
494+
- `true` and `false` do **not** coerce (prevent `true` from matching `"true"`)
495+
- Empty keyword `:""` coerces to empty string `""`
496+
- Other types (`strings`, `numbers`, `nil`) are unchanged
497+
498+
**Note:** Ordering comparisons (`>`, `<`, `>=`, `<=`) do **not** use coercion. Type mismatches return `false` (same as `nil` handling).
499+
481500
### 7.2 Combining Predicates
482501

483502
Use `all-of`, `any-of`, `none-of` to combine predicate functions:

lib/ptc_runner/lisp/eval.ex

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,12 @@ defmodule PtcRunner.Lisp.Eval do
545545
defp safe_eq(nil, nil), do: true
546546
defp safe_eq(nil, _), do: false
547547
defp safe_eq(_, nil), do: false
548-
defp safe_eq(a, b), do: a == b
548+
549+
defp safe_eq(a, b) do
550+
a_normalized = normalize_for_comparison(a)
551+
b_normalized = normalize_for_comparison(b)
552+
a_normalized == b_normalized
553+
end
549554

550555
defp safe_cmp(nil, _, _op), do: false
551556
defp safe_cmp(_, nil, _op), do: false
@@ -556,19 +561,42 @@ defmodule PtcRunner.Lisp.Eval do
556561

557562
# `in` operator: field value is member of collection
558563
defp safe_in(nil, _coll), do: false
559-
defp safe_in(value, coll) when is_list(coll), do: value in coll
564+
565+
defp safe_in(value, coll) when is_list(coll) do
566+
normalized_value = normalize_for_comparison(value)
567+
568+
Enum.any?(coll, fn item ->
569+
normalize_for_comparison(item) == normalized_value
570+
end)
571+
end
572+
560573
defp safe_in(_, _), do: false
561574

562575
# `includes` operator: collection includes value
563576
defp safe_includes(nil, _value), do: false
564-
defp safe_includes(coll, value) when is_list(coll), do: value in coll
577+
578+
defp safe_includes(coll, value) when is_list(coll) do
579+
normalized_value = normalize_for_comparison(value)
580+
581+
Enum.any?(coll, fn item ->
582+
normalize_for_comparison(item) == normalized_value
583+
end)
584+
end
565585

566586
defp safe_includes(coll, value) when is_binary(coll) and is_binary(value) do
567587
String.contains?(coll, value)
568588
end
569589

570590
defp safe_includes(_, _), do: false
571591

592+
# Coerce keywords to strings for comparison, but preserve other types
593+
# This allows LLM-generated keywords to match string data values
594+
defp normalize_for_comparison(value) when is_atom(value) and not is_boolean(value) do
595+
to_string(value)
596+
end
597+
598+
defp normalize_for_comparison(value), do: value
599+
572600
# Convert Lisp closures to Erlang functions for use with higher-order functions
573601
# The closure must have 1 parameter (enforced at evaluation time)
574602
defp closure_to_fun({:closure, patterns, body, closure_env}, ctx, memory, tool_exec) do

test/ptc_runner/lisp/flex_access_test.exs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,109 @@ defmodule PtcRunner.Lisp.FlexAccessTest do
6060
PtcRunner.Lisp.run(program, context: context)
6161
end
6262
end
63+
64+
describe "where clause with keyword/string coercion" do
65+
test "where = coerces keyword to string for equality" do
66+
program = ~S"(->> ctx/items (filter (where :status = :active)))"
67+
68+
context = %{
69+
"items" => [
70+
%{"status" => "active", "name" => "A"},
71+
%{"status" => "inactive", "name" => "B"}
72+
]
73+
}
74+
75+
assert {:ok, [%{"status" => "active", "name" => "A"}], _, _} =
76+
PtcRunner.Lisp.run(program, context: context)
77+
end
78+
79+
test "where not= with keyword/string coercion" do
80+
program = ~S"(->> ctx/items (filter (where :status not= :active)))"
81+
82+
context = %{
83+
"items" => [
84+
%{"status" => "active", "name" => "A"},
85+
%{"status" => "inactive", "name" => "B"}
86+
]
87+
}
88+
89+
assert {:ok, [%{"status" => "inactive", "name" => "B"}], _, _} =
90+
PtcRunner.Lisp.run(program, context: context)
91+
end
92+
93+
test "where in coerces keywords in collection to strings" do
94+
program = ~S"(->> ctx/items (filter (where :status in [:active :pending])))"
95+
96+
context = %{
97+
"items" => [
98+
%{"status" => "active", "name" => "A"},
99+
%{"status" => "inactive", "name" => "B"},
100+
%{"status" => "pending", "name" => "C"}
101+
]
102+
}
103+
104+
assert {:ok,
105+
[%{"status" => "active", "name" => "A"}, %{"status" => "pending", "name" => "C"}],
106+
_, _} =
107+
PtcRunner.Lisp.run(program, context: context)
108+
end
109+
110+
test "where includes with list membership using keyword/string coercion" do
111+
program = ~S"(->> ctx/items (filter (where :tags includes :urgent)))"
112+
113+
context = %{
114+
"items" => [
115+
%{"tags" => ["urgent", "bug"], "name" => "A"},
116+
%{"tags" => ["feature"], "name" => "B"}
117+
]
118+
}
119+
120+
assert {:ok, [%{"tags" => ["urgent", "bug"], "name" => "A"}], _, _} =
121+
PtcRunner.Lisp.run(program, context: context)
122+
end
123+
124+
test "where = does not coerce booleans" do
125+
program = ~S"(->> ctx/items (filter (where :active = true)))"
126+
127+
context = %{
128+
"items" => [
129+
%{"active" => true, "name" => "A"},
130+
%{"active" => "true", "name" => "B"}
131+
]
132+
}
133+
134+
# Only the boolean true should match, not the string "true"
135+
assert {:ok, [%{"active" => true, "name" => "A"}], _, _} =
136+
PtcRunner.Lisp.run(program, context: context)
137+
end
138+
139+
test "where = does not coerce false to string" do
140+
program = ~S"(->> ctx/items (filter (where :active = false)))"
141+
142+
context = %{
143+
"items" => [
144+
%{"active" => false, "name" => "A"},
145+
%{"active" => "false", "name" => "B"}
146+
]
147+
}
148+
149+
# Only the boolean false should match, not the string "false"
150+
assert {:ok, [%{"active" => false, "name" => "A"}], _, _} =
151+
PtcRunner.Lisp.run(program, context: context)
152+
end
153+
154+
test "where = coerces empty atom to empty string" do
155+
program = ~S'(->> ctx/items (filter (where :value = "")))'
156+
157+
context = %{
158+
"items" => [
159+
%{"value" => "", "name" => "A"},
160+
%{"value" => "nonempty", "name" => "B"}
161+
]
162+
}
163+
164+
assert {:ok, [%{"value" => "", "name" => "A"}], _, _} =
165+
PtcRunner.Lisp.run(program, context: context)
166+
end
167+
end
63168
end

0 commit comments

Comments
 (0)