Skip to content

Add type coercion to where clause comparisons (keyword/string) #232

@andreasronge

Description

@andreasronge

Summary

Add automatic type coercion to where clause comparisons so that keywords (atoms) and strings are compared by value, enabling LLM-generated code like (where :department = :engineering) to match string data values like "engineering".

Context

Architecture reference: PTC-Lisp Specification - Section 7.1 where
Dependencies: None
Related issues: None

Current State

Verified: The comparison helpers in lib/ptc_runner/lisp/eval.ex (lines 545-570) use strict equality without type coercion:

defp safe_eq(a, b), do: a == b  # No type coercion

When filtering data with string values using keyword literals:

(where :department = :engineering)  ; Returns [] when data has "engineering"
(where :department = "engineering") ; Works correctly

The field accessor already supports flexible key access via flex_get_in in lib/ptc_runner/lisp/runtime.ex, but value comparison does not.

This causes LLM-generated programs to fail silently when they use keywords instead of strings for comparison values - the filter returns empty results instead of matching data.

Acceptance Criteria

  • (where :field = :keyword) matches string values (e.g., :engineering matches "engineering")
  • (where :field = "string") matches keyword values (e.g., "engineering" matches :engineering)
  • in operator handles mixed keyword/string membership (e.g., (where :status in [:active :pending]) matches "active")
  • includes operator handles keyword/string comparison for list membership
  • Ordering operators (>, <, >=, <=) do NOT coerce types - comparing atom to string returns false (consistent with nil handling)
  • Exact type matches still work (no performance regression for matching types)
  • Boolean atoms (true, false) are NOT coerced to strings "true", "false"
  • Existing tests pass
  • E2E test demonstrates keyword-to-string matching in filter pipeline

Implementation Hints

Files to modify:

  • lib/ptc_runner/lisp/eval.ex - Add type coercion clauses to safe_eq, safe_in, safe_includes
  • test/ptc_runner/lisp/flex_access_test.exs - Add tests for keyword/string value coercion

Patterns to follow:

  • The existing flex_get pattern in lib/ptc_runner/lisp/runtime.ex (lines 19-39) shows how to handle atom/string conversion

Implementation approach:

# In safe_eq - add before catch-all clause:
defp safe_eq(a, b) when is_atom(a) and not is_boolean(a) and is_binary(b), do: to_string(a) == b
defp safe_eq(a, b) when is_binary(a) and is_atom(b) and not is_boolean(b), do: a == to_string(b)
defp safe_eq(a, b), do: a == b

# In safe_in - normalize values before membership check:
defp safe_in(value, coll) when is_list(coll) do
  normalized = normalize_for_comparison(value)
  Enum.any?(coll, fn item -> normalize_for_comparison(item) == normalized end)
end

defp normalize_for_comparison(v) when is_atom(v) and not is_boolean(v), do: to_string(v)
defp normalize_for_comparison(v), do: v

# In safe_includes - for list membership, use the same normalize_for_comparison approach
defp safe_includes(coll, value) when is_list(coll) do
  normalized = normalize_for_comparison(value)
  Enum.any?(coll, fn item -> normalize_for_comparison(item) == normalized end)
end

Design decision - Ordering operators:
Ordering operators (>, <, >=, <=) should NOT coerce atom/string types. Comparing :active > "active" would be semantically meaningless. These should return false (matching nil behavior) rather than attempting string comparison. No changes needed to safe_cmp.

Edge cases to consider:

  • nil should NOT be coerced (already handled by existing nil guards)
  • Boolean atoms (true, false) should NOT be coerced to strings - use is_boolean/1 guard
  • Numbers should NOT be affected by this change
  • Empty atom :"" coerces to empty string "" (correct behavior)

Test Plan

Unit tests:

  • safe_eq(:keyword, "string") returns true
  • safe_eq("string", :keyword) returns true
  • safe_eq(:keyword, :keyword) returns true (exact match still works)
  • safe_eq(true, "true") returns false (booleans not coerced)
  • safe_eq(false, "false") returns false (booleans not coerced)
  • safe_in(:active, ["active", "pending"]) returns true
  • safe_in("active", [:active, :pending]) returns true
  • safe_includes([:active, :pending], "active") returns true

E2E test:

test "where clause coerces keywords to strings" do
  program = ~S|(->> ctx/items (filter (where :status = :active)))|
  context = %{"items" => [
    %{"status" => "active", "name" => "A"},
    %{"status" => "inactive", "name" => "B"}
  ]}

  assert {:ok, [%{"status" => "active", "name" => "A"}], _, _} =
           PtcRunner.Lisp.run(program, context: context)
end

test "in operator coerces keywords to strings" do
  program = ~S|(->> ctx/items (filter (where :status in [:active :pending])))|
  context = %{"items" => [
    %{"status" => "active"},
    %{"status" => "pending"},
    %{"status" => "closed"}
  ]}

  assert {:ok, [%{"status" => "active"}, %{"status" => "pending"}], _, _} =
           PtcRunner.Lisp.run(program, context: context)
end

Out of Scope

  • Changing how field keys are accessed (already handled by flex_get)
  • Coercing numbers to strings or vice versa
  • Coercing booleans to strings
  • Adding warning/logging when coercion occurs
  • Coercion for ordering operators (>, <, >=, <=)

Documentation Updates

  • docs/ptc-lisp-specification.md - Update Section 7.1 to mention that comparison values support keyword/string coercion (similar to the existing "Flexible Key Access" section)

Metadata

Metadata

Assignees

No one assigned

    Labels

    claude-approvedMaintainer-approved for Claude automationenhancementNew feature or requestready-for-implementationIssue is approved and ready to implement

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions