diff --git a/lib/ash_typescript.ex b/lib/ash_typescript.ex index e8ec2be5..85c68e78 100644 --- a/lib/ash_typescript.ex +++ b/lib/ash_typescript.ex @@ -26,6 +26,36 @@ defmodule AshTypescript do Application.get_env(:ash_typescript, :type_mapping_overrides, []) end + @doc """ + Gets extra TypedStructs to generate types for, even if not referenced by RPC resources. + + ## Configuration + + config :ash_typescript, + extra_structs: [Platform.Auth.SessionInfo] + + ## Returns + A list of TypedStruct module atoms. + """ + def extra_structs do + Application.get_env(:ash_typescript, :extra_structs, []) + end + + @doc """ + Gets whether to generate generic filter schemas (e.g. UserFilterSchema) for Zod/Valibot. + Defaults to false. + """ + def generate_filter_schemas? do + Application.get_env(:ash_typescript, :generate_filter_schemas, false) + end + + @doc """ + Gets whether to generate clean types for resources. Defaults to true. + """ + def generate_clean_types? do + Application.get_env(:ash_typescript, :generate_clean_types, true) + end + @doc """ Gets the TypeScript type to use for untyped maps from application configuration. diff --git a/lib/ash_typescript/codegen/filter_types.ex b/lib/ash_typescript/codegen/filter_types.ex index b35adaac..f15bf189 100644 --- a/lib/ash_typescript/codegen/filter_types.ex +++ b/lib/ash_typescript/codegen/filter_types.ex @@ -128,10 +128,8 @@ defmodule AshTypescript.Codegen.FilterTypes do defp generate_attribute_filter(attribute, resource) do base_type = TypeMapper.get_ts_type(attribute) - allow_nil? = Map.get(attribute, :allow_nil?, true) - # Generate specific filter operations based on the attribute type - operations = get_applicable_operations(attribute.type, base_type, allow_nil?) + filter_type = map_to_generic_filter(attribute.type, base_type) formatted_name = AshTypescript.FieldFormatter.format_field_for_client( @@ -141,9 +139,7 @@ defmodule AshTypescript.Codegen.FilterTypes do ) """ - #{formatted_name}?: { - #{Enum.join(operations, "\n")} - }; + #{formatted_name}?: #{filter_type}; """ end @@ -176,16 +172,12 @@ defmodule AshTypescript.Codegen.FilterTypes do base_type = TypeMapper.get_ts_type(field) array_type = "Array<#{base_type}>" - operations = - [:eq, :not_eq, :in, :is_nil] - |> Enum.map(&format_operation(&1, array_type)) + filter_type = "GenericFilter<#{array_type}>" formatted_name = format_aggregate_name(aggregate.name, resource) """ - #{formatted_name}?: { - #{Enum.join(operations, "\n")} - }; + #{formatted_name}?: #{filter_type}; """ end end @@ -195,13 +187,11 @@ defmodule AshTypescript.Codegen.FilterTypes do defp generate_fixed_type_aggregate_filter(aggregate, type, resource) do base_type = TypeMapper.get_ts_type(%{type: type}, nil) - operations = get_applicable_operations(type, base_type, _allow_nil? = true) + filter_type = map_to_generic_filter(type, base_type) formatted_name = format_aggregate_name(aggregate.name, resource) """ - #{formatted_name}?: { - #{Enum.join(operations, "\n")} - }; + #{formatted_name}?: #{filter_type}; """ end @@ -229,15 +219,17 @@ defmodule AshTypescript.Codegen.FilterTypes do AshTypescript.FieldFormatter.format_field_for_client(name, resource, formatter()) end - defp get_applicable_operations(type, base_type, allow_nil?) do - ops = - type - |> classify_filter_type() - |> get_operations_for_type() + defp map_to_generic_filter(type, base_type) do + category = classify_filter_type(type) - ops = if allow_nil?, do: ops ++ [:is_nil], else: ops - - Enum.map(ops, &format_operation(&1, base_type)) + case category do + :string -> "StringFilter" + :numeric -> "NumberFilter<#{base_type}>" + :datetime -> "DateFilter<#{base_type}>" + :boolean -> "BooleanFilter" + :atom -> "AtomFilter" + :default -> "GenericFilter<#{base_type}>" + end end defp classify_filter_type(type) do @@ -272,46 +264,6 @@ defmodule AshTypescript.Codegen.FilterTypes do end end - defp get_operations_for_type(:string), do: [:eq, :not_eq, :in] - - defp get_operations_for_type(:numeric), - do: [ - :eq, - :not_eq, - :greater_than, - :greater_than_or_equal, - :less_than, - :less_than_or_equal, - :in - ] - - defp get_operations_for_type(:datetime), - do: [ - :eq, - :not_eq, - :greater_than, - :greater_than_or_equal, - :less_than, - :less_than_or_equal, - :in - ] - - defp get_operations_for_type(:boolean), do: [:eq, :not_eq] - defp get_operations_for_type(:atom), do: [:eq, :not_eq, :in] - defp get_operations_for_type(:default), do: [:eq, :not_eq, :in] - - defp format_operation(:is_nil, _base_type) do - " #{format_field("is_nil")}?: boolean;" - end - - defp format_operation(:in, base_type) do - " #{format_field("in")}?: Array<#{base_type}>;" - end - - defp format_operation(op, base_type) do - " #{format_field(Atom.to_string(op))}?: #{base_type};" - end - defp generate_relationship_filters(resource, allowed_resources) do resource |> Ash.Resource.Info.public_relationships() diff --git a/lib/ash_typescript/codegen/orchestrator.ex b/lib/ash_typescript/codegen/orchestrator.ex index 630214df..04e370a3 100644 --- a/lib/ash_typescript/codegen/orchestrator.ex +++ b/lib/ash_typescript/codegen/orchestrator.ex @@ -79,14 +79,16 @@ defmodule AshTypescript.Codegen.Orchestrator do embedded_resources = TypeDiscovery.find_embedded_resources(otp_app) struct_argument_resources = TypeDiscovery.find_struct_argument_resources(otp_app) controller_resources = collect_typed_controller_resources() + extra_structs = AshTypescript.extra_structs() all_resources = - (rpc_resources ++ embedded_resources ++ struct_argument_resources ++ controller_resources) + (rpc_resources ++ + embedded_resources ++ struct_argument_resources ++ controller_resources ++ extra_structs) |> Enum.uniq() |> Enum.sort_by(&inspect/1) schema_resources = - (embedded_resources ++ struct_argument_resources ++ controller_resources) + (embedded_resources ++ struct_argument_resources ++ controller_resources ++ extra_structs) |> Enum.uniq() |> Enum.sort_by(&inspect/1) @@ -329,12 +331,15 @@ defmodule AshTypescript.Codegen.Orchestrator do resource_schemas_str = SchemaCore.generate_schemas_for_resources(formatter, schema_resources) - all_schema_strings = [resource_schemas_str | additional_schemas] + generic_schemas_str = SchemaCore.generate_generic_schemas(formatter) + + all_schema_strings = [resource_schemas_str, generic_schemas_str | additional_schemas] validate_unique_schema_names!(formatter, all_schema_strings) content = SharedSchemaGenerator.generate(formatter, resource_schemas: resource_schemas_str, + generic_schemas: generic_schemas_str, types_output_file: types_output_file, schema_output_file: output_file, additional_schemas: additional_schemas diff --git a/lib/ash_typescript/codegen/resource_schemas.ex b/lib/ash_typescript/codegen/resource_schemas.ex index 1fa7cb4c..1c1013e6 100644 --- a/lib/ash_typescript/codegen/resource_schemas.ex +++ b/lib/ash_typescript/codegen/resource_schemas.ex @@ -180,12 +180,16 @@ defmodule AshTypescript.Codegen.ResourceSchemas do # Used by first aggregates and load restrictions (allowed_loads with flat allows) attributes_only_schema = generate_attributes_only_schema(resource, allowed_resources) + clean_mapped_type = """ + export type #{resource_name} = Clean<#{resource_name}ResourceSchema>; + """ + base_schemas = """ // #{resource_name} Schema #{unified_schema} """ - [base_schemas, attributes_only_schema, input_schema] + [base_schemas, attributes_only_schema, clean_mapped_type, input_schema] |> Enum.reject(&(&1 == "")) |> Enum.join("\n\n") end @@ -252,8 +256,42 @@ defmodule AshTypescript.Codegen.ResourceSchemas do ) ) - primitive_fields_union = - generate_primitive_fields_union(Enum.map(primitive_fields, & &1.name), resource) + primitive_fields_structs = primitive_fields + primitive_fields = Enum.map(primitive_fields, & &1.name) + + const_name = "#{Helpers.camel_case_prefix(resource_name)}ResourcePrimitiveFields" + + primitive_array_elements = + Enum.map_join(primitive_fields, ", ", fn field -> + "\"#{format_client_field_name(resource, field)}\"" + end) + + {primitive_fields_union, const_declaration} = + if Enum.empty?(primitive_fields) do + {"never", ""} + else + {"(typeof #{const_name})[number]", + "export const #{const_name} = [#{primitive_array_elements}] as const;"} + end + + enum_fields = + primitive_fields_structs + |> Enum.filter(fn field -> + {base, _} = Introspection.unwrap_new_type(field.type, field.constraints || []) + is_atom(base) and Spark.implements_behaviour?(base, Ash.Type.Enum) + end) + + enum_consts = + enum_fields + |> Enum.map_join("\n", fn field -> + {base, _} = Introspection.unwrap_new_type(field.type, field.constraints || []) + + const_name = + "#{Helpers.camel_case_prefix(resource_name)}#{Helpers.camel_case_prefix(to_string(field.name))}Enum" + + values = Enum.map_join(base.values(), ", ", &"\"#{to_string(&1)}\"") + "export const #{const_name} = [#{values}] as const;" + end) metadata_schema_fields = [ " __type: \"Resource\";", @@ -261,10 +299,20 @@ defmodule AshTypescript.Codegen.ResourceSchemas do ] all_field_lines = - primitive_fields + primitive_fields_structs |> Enum.map(fn field -> formatted_name = format_client_field_name(resource, field.name) - type_str = TypeMapper.get_ts_type(field) + {base, _} = Introspection.unwrap_new_type(field.type, field.constraints || []) + + type_str = + if is_atom(base) and Spark.implements_behaviour?(base, Ash.Type.Enum) do + const_name = + "#{Helpers.camel_case_prefix(resource_name)}#{Helpers.camel_case_prefix(to_string(field.name))}Enum" + + "(typeof #{const_name})[number]" + else + TypeMapper.get_ts_type(field) + end if allow_nil?(field) do " #{formatted_name}: #{type_str} | null;" @@ -281,8 +329,15 @@ defmodule AshTypescript.Codegen.ResourceSchemas do |> then(&Enum.concat(metadata_schema_fields, &1)) |> Enum.join("\n") + const_out = + [const_declaration, enum_consts] + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n") + + const_out = if const_out != "", do: "#{const_out}\n", else: "" + """ - export type #{resource_name}ResourceSchema = { + #{const_out}export type #{resource_name}ResourceSchema = { #{all_field_lines} }; """ @@ -309,8 +364,25 @@ defmodule AshTypescript.Codegen.ResourceSchemas do is_complex_attr?(attr) end) - primitive_fields_union = - generate_primitive_fields_union(Enum.map(primitive_attrs, & &1.name), resource) + primitive_attrs_structs = primitive_attrs + primitive_attrs = Enum.map(primitive_attrs, & &1.name) + + const_name = "#{Helpers.camel_case_prefix(resource_name)}AttributesOnlyPrimitiveFields" + + primitive_array_elements = + Enum.map_join(primitive_attrs, ", ", fn field -> + "\"#{format_client_field_name(resource, field)}\"" + end) + + {primitive_fields_union, const_declaration} = + if Enum.empty?(primitive_attrs) do + {"never", ""} + else + {"(typeof #{const_name})[number]", + "export const #{const_name} = [#{primitive_array_elements}] as const;"} + end + + enum_consts = "" metadata_schema_fields = [ " __type: \"Resource\";", @@ -318,10 +390,20 @@ defmodule AshTypescript.Codegen.ResourceSchemas do ] all_field_lines = - primitive_attrs + primitive_attrs_structs |> Enum.map(fn field -> formatted_name = format_client_field_name(resource, field.name) - type_str = TypeMapper.get_ts_type(field) + {base, _} = Introspection.unwrap_new_type(field.type, field.constraints || []) + + type_str = + if is_atom(base) and Spark.implements_behaviour?(base, Ash.Type.Enum) do + const_name = + "#{Helpers.camel_case_prefix(resource_name)}#{Helpers.camel_case_prefix(to_string(field.name))}Enum" + + "(typeof #{const_name})[number]" + else + TypeMapper.get_ts_type(field) + end if allow_nil?(field) do " #{formatted_name}: #{type_str} | null;" @@ -338,8 +420,15 @@ defmodule AshTypescript.Codegen.ResourceSchemas do |> then(&Enum.concat(metadata_schema_fields, &1)) |> Enum.join("\n") + const_out = + [const_declaration, enum_consts] + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n") + + const_out = if const_out != "", do: "#{const_out}\n", else: "" + """ - export type #{resource_name}AttributesOnlySchema = { + #{const_out}export type #{resource_name}AttributesOnlySchema = { #{all_field_lines} }; """ @@ -635,21 +724,6 @@ defmodule AshTypescript.Codegen.ResourceSchemas do end end - defp generate_primitive_fields_union(fields, resource) do - if Enum.empty?(fields) do - "never" - else - fields - |> Enum.map_join( - " | ", - fn field_name -> - formatted = format_client_field_name(resource, field_name) - "\"#{formatted}\"" - end - ) - end - end - defp relationship_field_definition(resource, rel) do formatted_name = format_client_field_name(resource, rel.name) related_resource_name = Helpers.build_resource_type_name(rel.destination) diff --git a/lib/ash_typescript/codegen/schema_core.ex b/lib/ash_typescript/codegen/schema_core.ex index 55c39ff8..3b69db7b 100644 --- a/lib/ash_typescript/codegen/schema_core.ex +++ b/lib/ash_typescript/codegen/schema_core.ex @@ -224,7 +224,32 @@ defmodule AshTypescript.Codegen.SchemaCore do @doc "Generates a schema for a single resource." def generate_schema_for_resource(formatter, resource) do - generate_schema_impl(formatter, resource) + schema = generate_schema_impl(formatter, resource) + + if AshTypescript.generate_filter_schemas?() do + resource_name = CodegenHelpers.build_resource_type_name(resource) + filter_schema_name = "#{resource_name}FilterSchema" + filter_schema = "export const #{filter_schema_name} = #{formatter.wrap_record()};" + schema <> "\n" <> filter_schema + else + schema + end + end + + @doc "Generates generic generic schemas for validation." + def generate_generic_schemas(formatter) do + if formatter.generate_schemas_enabled?() do + """ + // ============================ + // Generic base schemas + // ============================ + + #{formatter.pagination_schemas()} + #{formatter.generic_filter_schemas()} + """ + else + "" + end end # ───────────────────────────────────────────────────────────────── diff --git a/lib/ash_typescript/codegen/schema_formatter.ex b/lib/ash_typescript/codegen/schema_formatter.ex index bd4e886c..fc32d113 100644 --- a/lib/ash_typescript/codegen/schema_formatter.ex +++ b/lib/ash_typescript/codegen/schema_formatter.ex @@ -97,6 +97,12 @@ defmodule AshTypescript.Codegen.SchemaFormatter do @doc ~S'Human-readable library name for comments and error messages (e.g. "Zod" or "Valibot").' @callback library_name() :: String.t() + @doc "Valibot/Zod pagination input schemas" + @callback pagination_schemas() :: String.t() + + @doc "Generic filter struct schemas" + @callback generic_filter_schemas() :: String.t() + @doc "The import path for the validation library from application config." @callback configured_import_path() :: String.t() end diff --git a/lib/ash_typescript/codegen/shared_schema_generator.ex b/lib/ash_typescript/codegen/shared_schema_generator.ex index a591b8fa..89ab109e 100644 --- a/lib/ash_typescript/codegen/shared_schema_generator.ex +++ b/lib/ash_typescript/codegen/shared_schema_generator.ex @@ -32,6 +32,7 @@ defmodule AshTypescript.Codegen.SharedSchemaGenerator do """ def generate(formatter, opts) do resource_schemas = Keyword.fetch!(opts, :resource_schemas) + generic_schemas = Keyword.get(opts, :generic_schemas, "") types_output_file = Keyword.fetch!(opts, :types_output_file) schema_output_file = Keyword.fetch!(opts, :schema_output_file) additional_schemas = Keyword.get(opts, :additional_schemas, []) @@ -52,7 +53,7 @@ defmodule AshTypescript.Codegen.SharedSchemaGenerator do additional_section = additional_schemas |> Enum.reject(&(&1 == "")) - |> Enum.join("\n") + |> Enum.join("\n\n") """ // Generated by AshTypescript - Shared #{library_name} Schemas @@ -60,6 +61,8 @@ defmodule AshTypescript.Codegen.SharedSchemaGenerator do #{formatter.import_statement(import_path)} #{type_import_line} + #{generic_schemas} + #{resource_schemas} #{additional_section} diff --git a/lib/ash_typescript/codegen/utility_types.ex b/lib/ash_typescript/codegen/utility_types.ex index b6dd95f3..9e3968ca 100644 --- a/lib/ash_typescript/codegen/utility_types.ex +++ b/lib/ash_typescript/codegen/utility_types.ex @@ -27,6 +27,13 @@ defmodule AshTypescript.Codegen.UtilityTypes do - AshRpcError type """ def generate_utility_types do + fmt = fn field -> + AshTypescript.FieldFormatter.format_field_name( + field, + AshTypescript.Rpc.output_field_formatter() + ) + end + """ // Utility Types @@ -40,6 +47,57 @@ defmodule AshTypescript.Codegen.UtilityTypes do __primitiveFields: string; }; + // Clean public mapping + export type Clean = T extends null | undefined ? T : + T extends { __type: "Relationship", __array: true, __resource: infer R } ? Array> : + T extends { __type: "Relationship", __resource: infer R } ? Clean : + T extends { __type: "ComplexCalculation", __returnType: infer R } ? Clean : + T extends { __type: "Union" } ? Omit : + T extends { __type: "Resource" | "TypedMap" } ? { [K in keyof Omit]: Clean } : + T extends Array ? Array> : + T; + + // Generic Filter operations + export type GenericFilter = { + #{fmt.("eq")}?: T; + #{fmt.("not_eq")}?: T; + #{fmt.("in")}?: T[]; + #{fmt.("is_nil")}?: boolean; + }; + + export type StringFilter = GenericFilter & { + #{fmt.("contains")}?: string; + #{fmt.("icontains")}?: string; + #{fmt.("like")}?: string; + #{fmt.("ilike")}?: string; + }; + + export type NumberFilter = GenericFilter & { + #{fmt.("gt")}?: T; + #{fmt.("greater_than")}?: T; + #{fmt.("gte")}?: T; + #{fmt.("greater_than_or_equal")}?: T; + #{fmt.("lt")}?: T; + #{fmt.("less_than")}?: T; + #{fmt.("lte")}?: T; + #{fmt.("less_than_or_equal")}?: T; + }; + + export type DateFilter = GenericFilter & { + #{fmt.("gt")}?: T; + #{fmt.("greater_than")}?: T; + #{fmt.("gte")}?: T; + #{fmt.("greater_than_or_equal")}?: T; + #{fmt.("lt")}?: T; + #{fmt.("less_than")}?: T; + #{fmt.("lte")}?: T; + #{fmt.("less_than_or_equal")}?: T; + }; + + export type BooleanFilter = GenericFilter; + + export type AtomFilter = GenericFilter; + // Utility type to convert union to intersection export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( k: infer I, diff --git a/lib/ash_typescript/codegen/valibot_schema_generator.ex b/lib/ash_typescript/codegen/valibot_schema_generator.ex index c1c45b10..0026867f 100644 --- a/lib/ash_typescript/codegen/valibot_schema_generator.ex +++ b/lib/ash_typescript/codegen/valibot_schema_generator.ex @@ -116,6 +116,109 @@ defmodule AshTypescript.Codegen.ValibotSchemaGenerator do @impl true def configured_import_path, do: AshTypescript.Rpc.valibot_import_path() + @impl true + def pagination_schemas do + """ + export const paginationKeysetInputSchema = v.object({ + after: v.optional(v.string()), + before: v.optional(v.string()), + limit: v.optional(v.pipe(v.number(), v.integer())), + filter: v.optional(v.record(v.string(), v.any())), + }); + + export const paginationOffsetInputSchema = v.object({ + limit: v.optional(v.pipe(v.number(), v.integer())), + offset: v.optional(v.pipe(v.number(), v.integer())), + filter: v.optional(v.record(v.string(), v.any())), + count: v.optional(v.boolean()), + }); + + export const paginationInputSchema = v.union([ + paginationKeysetInputSchema, + paginationOffsetInputSchema, + ]); + """ + end + + @impl true + def generic_filter_schemas do + fmt = fn field -> + AshTypescript.FieldFormatter.format_field_name( + field, + AshTypescript.Rpc.output_field_formatter() + ) + end + + """ + export const stringFilterFieldSchema = v.union([ + v.string(), + v.object({ + #{fmt.("eq")}: v.optional(v.string()), + #{fmt.("not_eq")}: v.optional(v.string()), + #{fmt.("contains")}: v.optional(v.string()), + #{fmt.("icontains")}: v.optional(v.string()), + #{fmt.("is_nil")}: v.optional(v.boolean()), + #{fmt.("in")}: v.optional(v.array(v.string())), + }), + ]); + + export const numberFilterFieldSchema = v.union([ + v.number(), + v.object({ + #{fmt.("eq")}: v.optional(v.number()), + #{fmt.("not_eq")}: v.optional(v.number()), + #{fmt.("gt")}: v.optional(v.number()), + #{fmt.("gte")}: v.optional(v.number()), + #{fmt.("lt")}: v.optional(v.number()), + #{fmt.("lte")}: v.optional(v.number()), + #{fmt.("is_nil")}: v.optional(v.boolean()), + #{fmt.("in")}: v.optional(v.array(v.number())), + // aliases + #{fmt.("greater_than")}: v.optional(v.number()), + #{fmt.("greater_than_or_equal")}: v.optional(v.number()), + #{fmt.("less_than")}: v.optional(v.number()), + #{fmt.("less_than_or_equal")}: v.optional(v.number()), + }), + ]); + + export const booleanFilterFieldSchema = v.union([ + v.boolean(), + v.object({ + #{fmt.("eq")}: v.optional(v.boolean()), + #{fmt.("is_nil")}: v.optional(v.boolean()), + }), + ]); + + export const dateFilterFieldSchema = v.union([ + v.string(), + v.object({ + #{fmt.("eq")}: v.optional(v.string()), + #{fmt.("not_eq")}: v.optional(v.string()), + #{fmt.("gt")}: v.optional(v.string()), + #{fmt.("gte")}: v.optional(v.string()), + #{fmt.("lt")}: v.optional(v.string()), + #{fmt.("lte")}: v.optional(v.string()), + #{fmt.("is_nil")}: v.optional(v.boolean()), + // aliases + #{fmt.("greater_than")}: v.optional(v.string()), + #{fmt.("greater_than_or_equal")}: v.optional(v.string()), + #{fmt.("less_than")}: v.optional(v.string()), + #{fmt.("less_than_or_equal")}: v.optional(v.string()), + }), + ]); + + export const atomFilterFieldSchema = v.union([ + v.string(), + v.object({ + #{fmt.("eq")}: v.optional(v.string()), + #{fmt.("not_eq")}: v.optional(v.string()), + #{fmt.("is_nil")}: v.optional(v.boolean()), + #{fmt.("in")}: v.optional(v.array(v.string())), + }), + ]); + """ + end + @impl true def format_string(constraints, require_non_empty) do if constraints == [] do diff --git a/lib/ash_typescript/codegen/zod_schema_generator.ex b/lib/ash_typescript/codegen/zod_schema_generator.ex index 3212b720..fddcff64 100644 --- a/lib/ash_typescript/codegen/zod_schema_generator.ex +++ b/lib/ash_typescript/codegen/zod_schema_generator.ex @@ -116,6 +116,109 @@ defmodule AshTypescript.Codegen.ZodSchemaGenerator do @impl true def configured_import_path, do: AshTypescript.Rpc.zod_import_path() + @impl true + def pagination_schemas do + """ + export const paginationKeysetInputSchema = z.object({ + after: z.string().optional(), + before: z.string().optional(), + limit: z.number().int().optional(), + filter: z.record(z.string(), z.any()).optional(), + }); + + export const paginationOffsetInputSchema = z.object({ + limit: z.number().int().optional(), + offset: z.number().int().optional(), + filter: z.record(z.string(), z.any()).optional(), + count: z.boolean().optional(), + }); + + export const paginationInputSchema = z.union([ + paginationKeysetInputSchema, + paginationOffsetInputSchema, + ]); + """ + end + + @impl true + def generic_filter_schemas do + fmt = fn field -> + AshTypescript.FieldFormatter.format_field_name( + field, + AshTypescript.Rpc.output_field_formatter() + ) + end + + """ + export const stringFilterFieldSchema = z.union([ + z.string(), + z.object({ + #{fmt.("eq")}: z.string().optional(), + #{fmt.("not_eq")}: z.string().optional(), + #{fmt.("contains")}: z.string().optional(), + #{fmt.("icontains")}: z.string().optional(), + #{fmt.("is_nil")}: z.boolean().optional(), + #{fmt.("in")}: z.array(z.string()).optional(), + }), + ]); + + export const numberFilterFieldSchema = z.union([ + z.number(), + z.object({ + #{fmt.("eq")}: z.number().optional(), + #{fmt.("not_eq")}: z.number().optional(), + #{fmt.("gt")}: z.number().optional(), + #{fmt.("gte")}: z.number().optional(), + #{fmt.("lt")}: z.number().optional(), + #{fmt.("lte")}: z.number().optional(), + #{fmt.("is_nil")}: z.boolean().optional(), + #{fmt.("in")}: z.array(z.number()).optional(), + // aliases + #{fmt.("greater_than")}: z.number().optional(), + #{fmt.("greater_than_or_equal")}: z.number().optional(), + #{fmt.("less_than")}: z.number().optional(), + #{fmt.("less_than_or_equal")}: z.number().optional(), + }), + ]); + + export const booleanFilterFieldSchema = z.union([ + z.boolean(), + z.object({ + #{fmt.("eq")}: z.boolean().optional(), + #{fmt.("is_nil")}: z.boolean().optional(), + }), + ]); + + export const dateFilterFieldSchema = z.union([ + z.string(), + z.object({ + #{fmt.("eq")}: z.string().optional(), + #{fmt.("not_eq")}: z.string().optional(), + #{fmt.("gt")}: z.string().optional(), + #{fmt.("gte")}: z.string().optional(), + #{fmt.("lt")}: z.string().optional(), + #{fmt.("lte")}: z.string().optional(), + #{fmt.("is_nil")}: z.boolean().optional(), + // aliases + #{fmt.("greater_than")}: z.string().optional(), + #{fmt.("greater_than_or_equal")}: z.string().optional(), + #{fmt.("less_than")}: z.string().optional(), + #{fmt.("less_than_or_equal")}: z.string().optional(), + }), + ]); + + export const atomFilterFieldSchema = z.union([ + z.string(), + z.object({ + #{fmt.("eq")}: z.string().optional(), + #{fmt.("not_eq")}: z.string().optional(), + #{fmt.("is_nil")}: z.boolean().optional(), + #{fmt.("in")}: z.array(z.string()).optional(), + }), + ]); + """ + end + @impl true def format_string(constraints, require_non_empty) do if constraints == [] do diff --git a/test/ash_typescript/aggregate_field_formatting_test.exs b/test/ash_typescript/aggregate_field_formatting_test.exs index 3564bcc2..6c596d7c 100644 --- a/test/ash_typescript/aggregate_field_formatting_test.exs +++ b/test/ash_typescript/aggregate_field_formatting_test.exs @@ -75,11 +75,10 @@ defmodule AshTypescript.AggregateFieldFormattingTest do assert String.contains?(typescript_output, "FilterConfig") || String.contains?(typescript_output, "Filter") + # Filter fields now use generic filter type references filter_field_found = - typescript_output - |> String.contains?("CommentCount?: {") || - typescript_output - |> String.contains?("HelpfulCommentCount?: {") + String.contains?(typescript_output, "CommentCount?: NumberFilter;") || + String.contains?(typescript_output, "HelpfulCommentCount?: NumberFilter;") assert filter_field_found end diff --git a/test/ash_typescript/codegen/aggregate_calculation_support_test.exs b/test/ash_typescript/codegen/aggregate_calculation_support_test.exs index d4099e7d..19fcefec 100644 --- a/test/ash_typescript/codegen/aggregate_calculation_support_test.exs +++ b/test/ash_typescript/codegen/aggregate_calculation_support_test.exs @@ -27,9 +27,7 @@ defmodule AshTypescript.Codegen.AggregateCalculationSupportTest do test "generates filter type for sum aggregate over calculation" do result = FilterTypes.generate_filter_type(AshTypescript.Test.Todo) - assert result =~ "totalWeightedScore?: {" - assert result =~ "eq?: number" - assert result =~ "greaterThan?: number" + assert result =~ "totalWeightedScore?: NumberFilter;" end end diff --git a/test/ash_typescript/filter_formatting_test.exs b/test/ash_typescript/filter_formatting_test.exs index b605cd01..19be212b 100644 --- a/test/ash_typescript/filter_formatting_test.exs +++ b/test/ash_typescript/filter_formatting_test.exs @@ -30,84 +30,87 @@ defmodule AshTypescript.FilterFormattingTest do Application.put_env(:ash_typescript, :output_field_formatter, :camel_case) result = FilterTypes.generate_filter_type(Post) + utility_result = AshTypescript.Codegen.UtilityTypes.generate_utility_types() # Check that attribute field names are formatted to camelCase # view_count -> viewCount - assert String.contains?(result, "viewCount?: {") + assert String.contains?(result, "viewCount?: NumberFilter;") # published_at -> publishedAt - assert String.contains?(result, "publishedAt?: {") + assert String.contains?(result, "publishedAt?: DateFilter;") - # Check that filter operation field names are formatted to camelCase + # Check that filter operation field names are formatted to camelCase in utility types # not_eq -> notEq - assert String.contains?(result, " notEq?: ") + assert String.contains?(utility_result, "notEq?: ") # greater_than -> greaterThan - assert String.contains?(result, " greaterThan?: ") + assert String.contains?(utility_result, "greaterThan?: ") # greater_than_or_equal -> greaterThanOrEqual - assert String.contains?(result, " greaterThanOrEqual?: ") + assert String.contains?(utility_result, "greaterThanOrEqual?: ") # less_than -> lessThan - assert String.contains?(result, " lessThan?: ") + assert String.contains?(utility_result, "lessThan?: ") # less_than_or_equal -> lessThanOrEqual - assert String.contains?(result, " lessThanOrEqual?: ") + assert String.contains?(utility_result, "lessThanOrEqual?: ") # Should not contain the original snake_case operation names - refute String.contains?(result, " not_eq?: ") - refute String.contains?(result, " greater_than?: ") - refute String.contains?(result, " greater_than_or_equal?: ") - refute String.contains?(result, " less_than?: ") - refute String.contains?(result, " less_than_or_equal?: ") + refute String.contains?(utility_result, "not_eq?: ") + refute String.contains?(utility_result, "greater_than?: ") + refute String.contains?(utility_result, "greater_than_or_equal?: ") + refute String.contains?(utility_result, "less_than?: ") + refute String.contains?(utility_result, "less_than_or_equal?: ") end test "generates FilterInput with snake_case field names when output formatter is :snake_case" do Application.put_env(:ash_typescript, :output_field_formatter, :snake_case) result = FilterTypes.generate_filter_type(Post) + utility_result = AshTypescript.Codegen.UtilityTypes.generate_utility_types() # Check that attribute field names remain in snake_case - assert String.contains?(result, "view_count?: {") - assert String.contains?(result, "published_at?: {") + assert String.contains?(result, "view_count?: NumberFilter;") + assert String.contains?(result, "published_at?: DateFilter;") # Check that filter operation field names remain in snake_case - assert String.contains?(result, " not_eq?: ") - assert String.contains?(result, " greater_than?: ") - assert String.contains?(result, " greater_than_or_equal?: ") - assert String.contains?(result, " less_than?: ") - assert String.contains?(result, " less_than_or_equal?: ") + assert String.contains?(utility_result, "not_eq?: ") + assert String.contains?(utility_result, "greater_than?: ") + assert String.contains?(utility_result, "greater_than_or_equal?: ") + assert String.contains?(utility_result, "less_than?: ") + assert String.contains?(utility_result, "less_than_or_equal?: ") # Should not contain camelCase names - refute String.contains?(result, "viewCount?: {") - refute String.contains?(result, "publishedAt?: {") - refute String.contains?(result, " notEq?: ") - refute String.contains?(result, " greaterThan?: ") + refute String.contains?(result, "viewCount?: NumberFilter;") + refute String.contains?(result, "publishedAt?: DateFilter;") + refute String.contains?(utility_result, "notEq?: ") + refute String.contains?(utility_result, "greaterThan?: ") end test "generates FilterInput with PascalCase field names when output formatter is :pascal_case" do Application.put_env(:ash_typescript, :output_field_formatter, :pascal_case) result = FilterTypes.generate_filter_type(Post) + utility_result = AshTypescript.Codegen.UtilityTypes.generate_utility_types() # Check that attribute field names are converted to PascalCase # view_count -> ViewCount - assert String.contains?(result, "ViewCount?: {") + assert String.contains?(result, "ViewCount?: NumberFilter;") # published_at -> PublishedAt - assert String.contains?(result, "PublishedAt?: {") + assert String.contains?(result, "PublishedAt?: DateFilter;") # Check that filter operation field names are converted to PascalCase # not_eq -> NotEq - assert String.contains?(result, " NotEq?: ") + assert String.contains?(utility_result, "NotEq?: ") # greater_than -> GreaterThan - assert String.contains?(result, " GreaterThan?: ") + assert String.contains?(utility_result, "GreaterThan?: ") # greater_than_or_equal -> GreaterThanOrEqual - assert String.contains?(result, " GreaterThanOrEqual?: ") + assert String.contains?(utility_result, "GreaterThanOrEqual?: ") # less_than -> LessThan - assert String.contains?(result, " LessThan?: ") + assert String.contains?(utility_result, "LessThan?: ") # less_than_or_equal -> LessThanOrEqual - assert String.contains?(result, " LessThanOrEqual?: ") + assert String.contains?(utility_result, "LessThanOrEqual?: ") # Should not contain snake_case names - refute String.contains?(result, "view_count?: {") - refute String.contains?(result, "published_at?: {") - refute String.contains?(result, " not_eq?: ") - refute String.contains?(result, " greater_than?: ") + refute String.contains?(result, "view_count?: NumberFilter;") + refute String.contains?(result, "published_at?: DateFilter;") + refute String.contains?(utility_result, "not_eq?: ") + refute String.contains?(utility_result, "greater_than?: ") end test "relationship filters use formatted field names" do @@ -132,46 +135,34 @@ defmodule AshTypescript.FilterFormattingTest do assert String.contains?(result, "and?: Array") # Check that User fields including is_super_admin are formatted properly - assert String.contains?(result, "name?: {") - assert String.contains?(result, "email?: {") - assert String.contains?(result, "active?: {") - assert String.contains?(result, "isSuperAdmin?: {") + assert String.contains?(result, "name?: StringFilter;") + assert String.contains?(result, "email?: StringFilter;") + assert String.contains?(result, "active?: BooleanFilter;") + assert String.contains?(result, "isSuperAdmin?: BooleanFilter;") end test "mixed formatting scenarios work correctly" do Application.put_env(:ash_typescript, :output_field_formatter, :camel_case) result = FilterTypes.generate_filter_type(Post) + utility_result = AshTypescript.Codegen.UtilityTypes.generate_utility_types() # Verify various field types are all formatted consistently # string field - assert String.contains?(result, "title?: {") + assert String.contains?(result, "title?: StringFilter;") # boolean field - assert String.contains?(result, "published?: {") + assert String.contains?(result, "published?: BooleanFilter;") # integer field - assert String.contains?(result, "viewCount?: {") + assert String.contains?(result, "viewCount?: NumberFilter;") # datetime field - assert String.contains?(result, "publishedAt?: {") + assert String.contains?(result, "publishedAt?: DateFilter;") # decimal field - assert String.contains?(result, "rating?: {") - - # All should have properly formatted filter operations - assert String.contains?(result, "eq?: string") - assert String.contains?(result, "eq?: boolean") - assert String.contains?(result, "eq?: number") - assert String.contains?(result, "eq?: UtcDateTime") - - # Verify operation names are formatted consistently across field types - # String field operations - assert String.contains?(result, "notEq?: string") - # Boolean field operations - assert String.contains?(result, "notEq?: boolean") - # Number field operations - assert String.contains?(result, "notEq?: number") - # Number-specific operations - assert String.contains?(result, "greaterThan?: number") - # DateTime-specific operations - assert String.contains?(result, "greaterThan?: UtcDateTime") + assert String.contains?(result, "rating?: NumberFilter;") + + # All should have properly formatted filter operations in utility map + assert String.contains?(utility_result, "eq?: T;") + assert String.contains?(utility_result, "notEq?: T;") + assert String.contains?(utility_result, "greaterThan?: T;") end test "custom formatter function works with FilterInput" do @@ -190,17 +181,18 @@ defmodule AshTypescript.FilterFormattingTest do ) result = FilterTypes.generate_filter_type(Post) + utility_result = AshTypescript.Codegen.UtilityTypes.generate_utility_types() # Check that the custom formatter is applied to attribute field names - assert String.contains?(result, "prefix_title?: {") - assert String.contains?(result, "prefix_published?: {") - assert String.contains?(result, "prefix_view_count?: {") - - # Check that the custom formatter is applied to filter operation names - assert String.contains?(result, " prefix_eq?: ") - assert String.contains?(result, " prefix_not_eq?: ") - assert String.contains?(result, " prefix_greater_than?: ") - assert String.contains?(result, " prefix_in?: ") + assert String.contains?(result, "prefix_title?: StringFilter;") + assert String.contains?(result, "prefix_published?: BooleanFilter;") + assert String.contains?(result, "prefix_view_count?: NumberFilter;") + + # Check that the custom formatter is applied to filter operation names in utility + assert String.contains?(utility_result, "prefix_eq?: ") + assert String.contains?(utility_result, "prefix_not_eq?: ") + assert String.contains?(utility_result, "prefix_greater_than?: ") + assert String.contains?(utility_result, "prefix_in?: ") end end end diff --git a/test/ash_typescript/filter_mapped_fields_test.exs b/test/ash_typescript/filter_mapped_fields_test.exs index a239c28c..c455a4a9 100644 --- a/test/ash_typescript/filter_mapped_fields_test.exs +++ b/test/ash_typescript/filter_mapped_fields_test.exs @@ -9,7 +9,7 @@ defmodule AshTypescript.FilterMappedFieldsTest do This test module verifies that FilterInput types correctly use mapped field names for TypeScript filter generation. It ensures that: 1. Attribute filters use mapped field names - 2. Filter operations are available on mapped fields + 2. Filter operations are available on mapped fields (via generic types) 3. Aggregate filters with mapped names work correctly 4. Generated filter types match TypeScript client expectations @@ -26,29 +26,16 @@ defmodule AshTypescript.FilterMappedFieldsTest do result = FilterTypes.generate_filter_type(Task) # Should contain the mapped name 'isArchived' (from archived?) - assert result =~ "isArchived?: {" + assert result =~ "isArchived?: BooleanFilter;" # Should NOT contain the internal field name refute result =~ "archived?:" end - test "mapped boolean field has correct filter operations" do + test "mapped boolean field uses BooleanFilter generic type" do result = FilterTypes.generate_filter_type(Task) - # Find the isArchived filter section - is_archived_section = - result - |> String.split("isArchived?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - # Boolean fields should have eq and notEq operations - assert is_archived_section =~ "eq?: boolean" - assert is_archived_section =~ "notEq?: boolean" - - # Boolean fields should NOT have comparison operations - refute is_archived_section =~ "greaterThan" - refute is_archived_section =~ "lessThan" + # isArchived should use the BooleanFilter generic type + assert result =~ "isArchived?: BooleanFilter;" # Should not reference the internal field name refute result =~ "archived?:" @@ -58,8 +45,8 @@ defmodule AshTypescript.FilterMappedFieldsTest do result = FilterTypes.generate_filter_type(Task) # 'title' has no mapping and should appear as-is - assert result =~ "title?: {" - assert result =~ "completed?: {" + assert result =~ "title?: StringFilter;" + assert result =~ "completed?: BooleanFilter;" end test "filter type structure is valid TypeScript" do @@ -77,86 +64,29 @@ defmodule AshTypescript.FilterMappedFieldsTest do result = FilterTypes.generate_filter_type(Task) # Verify that archived? -> is_archived mapping is consistently applied - assert result =~ "isArchived?: {" + assert result =~ "isArchived?: BooleanFilter;" refute result =~ "archived?:" - - # Check that the filter is a boolean filter - is_archived_section = - result - |> String.split("isArchived?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert is_archived_section =~ "eq?: boolean" - assert is_archived_section =~ "notEq?: boolean" end - test "filter includes id field with UUID operations" do + test "filter includes id field with UUID type" do result = FilterTypes.generate_filter_type(Task) - # Should have id field - assert result =~ "id?: {" - - # Find the id filter section - id_section = - result - |> String.split("id?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - # UUID fields should have basic operations - assert id_section =~ "eq?: UUID" - assert id_section =~ "notEq?: UUID" - assert id_section =~ "in?: Array" + # Should have id field with GenericFilter + assert result =~ "id?: GenericFilter;" end - test "filter includes string field operations" do + test "filter includes string field type" do result = FilterTypes.generate_filter_type(Task) - # Should have title field - assert result =~ "title?: {" - - # Find the title filter section - title_section = - result - |> String.split("title?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - # String fields should have basic operations - assert title_section =~ "eq?: string" - assert title_section =~ "notEq?: string" - assert title_section =~ "in?: Array" - - # String fields should NOT have comparison operations - refute title_section =~ "greaterThan" - refute title_section =~ "lessThan" + # Should have title field with StringFilter + assert result =~ "title?: StringFilter;" end - test "filter includes boolean field operations" do + test "filter includes boolean field type" do result = FilterTypes.generate_filter_type(Task) - # Should have completed field - assert result =~ "completed?: {" - - # Find the completed filter section - completed_section = - result - |> String.split("completed?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - # Boolean fields should have limited operations - assert completed_section =~ "eq?: boolean" - assert completed_section =~ "notEq?: boolean" - - # Boolean fields should NOT have comparison or array operations - refute completed_section =~ "greaterThan" - refute completed_section =~ "in?: Array" + # Should have completed field with BooleanFilter + assert result =~ "completed?: BooleanFilter;" end end @@ -164,21 +94,8 @@ defmodule AshTypescript.FilterMappedFieldsTest do test "embedded resource field appears in filter" do result = FilterTypes.generate_filter_type(Task) - # Should have metadata field (embedded resource) - assert result =~ "metadata?: {" - - # Find the metadata filter section - metadata_section = - result - |> String.split("metadata?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - # Embedded resource should have basic operations - assert metadata_section =~ "eq?: TaskMetadataResourceSchema" - assert metadata_section =~ "notEq?: TaskMetadataResourceSchema" - assert metadata_section =~ "in?: Array" + # Should have metadata field with GenericFilter + assert result =~ "metadata?: GenericFilter;" end end @@ -186,36 +103,20 @@ defmodule AshTypescript.FilterMappedFieldsTest do test "mapped fields maintain consistent ordering with other fields" do result = FilterTypes.generate_filter_type(Task) - # Extract field names in order (looking for field definitions ending with ?: {) - field_pattern = ~r/(\w+)\?:\s*\{/ - fields = Regex.scan(field_pattern, result) |> Enum.map(fn [_, field] -> field end) - - # Should contain mapped field name, not internal name - assert "isArchived" in fields - refute "archived?" in fields + # Verify mapped field names appear in the output + assert result =~ "isArchived?:" + refute result =~ "archived?:" # Should also contain unmapped fields - assert "title" in fields - assert "completed" in fields + assert result =~ "title?:" + assert result =~ "completed?:" end - test "each field has proper closing brace" do + test "each field has proper type ending with semicolon" do result = FilterTypes.generate_filter_type(Task) - # Count opening and closing braces for isArchived - is_archived_full = - result - |> String.split("isArchived?: {") - |> Enum.at(1) - |> String.split("\n\n") - |> Enum.at(0) - - # Should have balanced braces - _opening_braces = String.graphemes(is_archived_full) |> Enum.count(&(&1 == "{")) - closing_braces = String.graphemes(is_archived_full) |> Enum.count(&(&1 == "}")) - - # One opening brace for the field definition, should have matching closing - assert closing_braces > 0 + # isArchived should be properly terminated + assert result =~ "isArchived?: BooleanFilter;" end end @@ -224,16 +125,16 @@ defmodule AshTypescript.FilterMappedFieldsTest do result = FilterTypes.generate_filter_type(Task) # Standard fields (unmapped) - assert result =~ "id?: {" - assert result =~ "title?: {" - assert result =~ "completed?: {" + assert result =~ "id?: GenericFilter;" + assert result =~ "title?: StringFilter;" + assert result =~ "completed?: BooleanFilter;" # Mapped field - assert result =~ "isArchived?: {" + assert result =~ "isArchived?: BooleanFilter;" refute result =~ "archived?:" # Embedded resource field - assert result =~ "metadata?: {" + assert result =~ "metadata?: GenericFilter;" end test "logical operators are present in filter type" do @@ -246,15 +147,16 @@ defmodule AshTypescript.FilterMappedFieldsTest do end test "filter operations use camelCase formatting" do - result = FilterTypes.generate_filter_type(Task) + # Operations are in the generic filter types (UtilityTypes), not inline + utility_result = AshTypescript.Codegen.UtilityTypes.generate_utility_types() - # Check that filter operations are formatted - assert result =~ "eq?:" - assert result =~ "notEq?:" - assert result =~ "in?: Array" + # Check that operations are formatted + assert utility_result =~ "eq?:" + assert utility_result =~ "notEq?:" + assert utility_result =~ "in?:" # Should not have snake_case operation names - refute result =~ "not_eq?:" + refute utility_result =~ "not_eq?:" end end @@ -276,69 +178,35 @@ defmodule AshTypescript.FilterMappedFieldsTest do embedded_resource = AshTypescript.Test.TaskMetadata result = FilterTypes.generate_filter_type(embedded_resource) - # Should contain mapped field names - assert result =~ "createdBy?: {" + # Should contain mapped field names with generic filter types + assert result =~ "createdBy?: StringFilter;" refute result =~ "created_by?:" - assert result =~ "isPublic?: {" + assert result =~ "isPublic?: BooleanFilter;" refute result =~ "is_public?:" # Should also have unmapped fields - assert result =~ "notes?: {" - assert result =~ "priorityLevel?: {" + assert result =~ "notes?: StringFilter;" + assert result =~ "priorityLevel?: NumberFilter;" end - test "embedded resource mapped fields have correct filter operations" do + test "embedded resource mapped fields have correct filter types" do embedded_resource = AshTypescript.Test.TaskMetadata result = FilterTypes.generate_filter_type(embedded_resource) - # Find the createdBy filter section (string field) - created_by_section = - result - |> String.split("createdBy?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert created_by_section =~ "eq?: string" - assert created_by_section =~ "notEq?: string" - assert created_by_section =~ "in?: Array" - refute created_by_section =~ "greaterThan" - - # Find the isPublic filter section (boolean field) - is_public_section = - result - |> String.split("isPublic?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert is_public_section =~ "eq?: boolean" - assert is_public_section =~ "notEq?: boolean" - refute is_public_section =~ "in?: Array" - refute is_public_section =~ "greaterThan" + # createdBy (string field) -> StringFilter + assert result =~ "createdBy?: StringFilter;" + + # isPublic (boolean field) -> BooleanFilter + assert result =~ "isPublic?: BooleanFilter;" end - test "embedded resource integer field has comparison operations" do + test "embedded resource integer field has NumberFilter" do embedded_resource = AshTypescript.Test.TaskMetadata result = FilterTypes.generate_filter_type(embedded_resource) - # Find the priorityLevel filter section (integer field) - priority_section = - result - |> String.split("priorityLevel?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - # Integer fields should have full comparison operations - assert priority_section =~ "eq?: number" - assert priority_section =~ "notEq?: number" - assert priority_section =~ "greaterThan?: number" - assert priority_section =~ "greaterThanOrEqual?: number" - assert priority_section =~ "lessThan?: number" - assert priority_section =~ "lessThanOrEqual?: number" - assert priority_section =~ "in?: Array" + # priorityLevel (integer field) -> NumberFilter + assert result =~ "priorityLevel?: NumberFilter;" end end @@ -347,12 +215,8 @@ defmodule AshTypescript.FilterMappedFieldsTest do result = FilterTypes.generate_filter_type(Task) # TypeScript client sends filter with mapped names - assert result =~ "isArchived?: {" + assert result =~ "isArchived?: BooleanFilter;" refute result =~ "archived?:" - - # Filter operations should be camelCase - assert result =~ "notEq?:" - assert result =~ "greaterThan?:" or result =~ "eq?:" end test "nested filter structures work with mapped names" do @@ -362,7 +226,7 @@ defmodule AshTypescript.FilterMappedFieldsTest do assert result =~ "and?: Array;" # This allows nested filters like: { and: [{ isArchived: { eq: true } }] } - assert result =~ "isArchived?: {" + assert result =~ "isArchived?: BooleanFilter;" refute result =~ "archived?:" end end diff --git a/test/ash_typescript/non_field_calculation_test.exs b/test/ash_typescript/non_field_calculation_test.exs index 9d4291e5..bbceee46 100644 --- a/test/ash_typescript/non_field_calculation_test.exs +++ b/test/ash_typescript/non_field_calculation_test.exs @@ -65,7 +65,7 @@ defmodule AshTypescript.NonFieldCalculationTest do end defp extract_primitive_fields(schema_string) do - case Regex.run(~r/__primitiveFields: (.+?);/, schema_string) do + case Regex.run(~r/export const .*?PrimitiveFields = \[(.+?)\] as const;/, schema_string) do [_, fields] -> fields _ -> "" end diff --git a/test/ash_typescript/relationship_field_formatting_test.exs b/test/ash_typescript/relationship_field_formatting_test.exs index de0a824b..ab915cd4 100644 --- a/test/ash_typescript/relationship_field_formatting_test.exs +++ b/test/ash_typescript/relationship_field_formatting_test.exs @@ -126,11 +126,10 @@ defmodule AshTypescript.RelationshipFieldFormattingTest do assert String.contains?(typescript_output, "FilterConfig") || String.contains?(typescript_output, "Filter") + # Filter fields now use generic filter type references filter_field_found = - typescript_output - |> String.contains?("IsSuperAdmin?: {") || - typescript_output - |> String.contains?("CommentCount?: {") + String.contains?(typescript_output, "IsSuperAdmin?: BooleanFilter;") || + String.contains?(typescript_output, "CommentCount?: NumberFilter;") assert filter_field_found end diff --git a/test/ash_typescript/resource_schema_mapped_fields_test.exs b/test/ash_typescript/resource_schema_mapped_fields_test.exs index 8acb352d..cad535e0 100644 --- a/test/ash_typescript/resource_schema_mapped_fields_test.exs +++ b/test/ash_typescript/resource_schema_mapped_fields_test.exs @@ -47,16 +47,19 @@ defmodule AshTypescript.ResourceSchemaMappedFieldsTest do test "__primitiveFields union includes mapped field names" do result = Codegen.generate_unified_resource_schema(Task, []) - # Find the __primitiveFields line - primitive_fields_line = + # __primitiveFields now references a typeof extracted const array + assert result =~ "__primitiveFields: (typeof taskResourcePrimitiveFields)[number];" + + # Find the const array definition for the actual field names + primitive_array_line = result |> String.split("\n") - |> Enum.find(&String.contains?(&1, "__primitiveFields:")) + |> Enum.find(&String.contains?(&1, "taskResourcePrimitiveFields")) - # Should contain mapped field name in the union - assert primitive_fields_line =~ "\"isArchived\"" + # Should contain mapped field name in the array + assert primitive_array_line =~ "\"isArchived\"" # Should NOT contain internal field name - refute primitive_fields_line =~ "\"archived?\"" + refute primitive_array_line =~ "\"archived?\"" end test "unmapped fields appear correctly in schema" do @@ -79,18 +82,18 @@ defmodule AshTypescript.ResourceSchemaMappedFieldsTest do test "all primitive fields are present in __primitiveFields union" do result = Codegen.generate_unified_resource_schema(Task, []) - # Extract the __primitiveFields line - primitive_fields_line = + # Find the const array that holds the primitive field names + primitive_array_line = result |> String.split("\n") - |> Enum.find(&String.contains?(&1, "__primitiveFields:")) + |> Enum.find(&String.contains?(&1, "taskResourcePrimitiveFields")) # Should contain all primitive field names - assert primitive_fields_line =~ "\"id\"" - assert primitive_fields_line =~ "\"title\"" - assert primitive_fields_line =~ "\"completed\"" - assert primitive_fields_line =~ "\"isArchived\"" - refute primitive_fields_line =~ "\"archived?\"" + assert primitive_array_line =~ "\"id\"" + assert primitive_array_line =~ "\"title\"" + assert primitive_array_line =~ "\"completed\"" + assert primitive_array_line =~ "\"isArchived\"" + refute primitive_array_line =~ "\"archived?\"" end test "primitive fields exclude embedded resources when not in allowed list" do @@ -178,22 +181,22 @@ defmodule AshTypescript.ResourceSchemaMappedFieldsTest do embedded_resource = AshTypescript.Test.TaskMetadata result = Codegen.generate_unified_resource_schema(embedded_resource, []) - # Extract the __primitiveFields line - primitive_fields_line = + # Find the const array that holds the primitive field names + primitive_array_line = result |> String.split("\n") - |> Enum.find(&String.contains?(&1, "__primitiveFields:")) + |> Enum.find(&String.contains?(&1, "taskMetadataResourcePrimitiveFields")) # Should contain mapped field names - assert primitive_fields_line =~ "\"createdBy\"" - refute primitive_fields_line =~ "\"created_by?\"" + assert primitive_array_line =~ "\"createdBy\"" + refute primitive_array_line =~ "\"created_by?\"" - assert primitive_fields_line =~ "\"isPublic\"" - refute primitive_fields_line =~ "\"is_public?\"" + assert primitive_array_line =~ "\"isPublic\"" + refute primitive_array_line =~ "\"is_public?\"" # Should also contain unmapped fields - assert primitive_fields_line =~ "\"notes\"" - assert primitive_fields_line =~ "\"priorityLevel\"" + assert primitive_array_line =~ "\"notes\"" + assert primitive_array_line =~ "\"priorityLevel\"" end test "embedded resource non-nullable mapped fields are not marked nullable" do @@ -336,18 +339,20 @@ defmodule AshTypescript.ResourceSchemaMappedFieldsTest do assert result =~ "__primitiveFields:" end - test "__primitiveFields is a union of string literals" do + test "__primitiveFields references extracted const array" do result = Codegen.generate_unified_resource_schema(Task, []) - # Extract the __primitiveFields line - primitive_fields_line = + # __primitiveFields should reference the typeof the const array + assert result =~ "__primitiveFields: (typeof taskResourcePrimitiveFields)[number];" + + # The const array should exist with quoted string literals + primitive_array_line = result |> String.split("\n") - |> Enum.find(&String.contains?(&1, "__primitiveFields:")) + |> Enum.find(&String.contains?(&1, "taskResourcePrimitiveFields = [")) - # Should be a union of quoted strings - assert primitive_fields_line =~ "\"" - assert primitive_fields_line =~ "|" + assert primitive_array_line != nil + assert primitive_array_line =~ "\"" end test "schema contains only primitive fields when allowed_resources is empty" do diff --git a/test/ash_typescript/rpc/sort_and_filter_types_test.exs b/test/ash_typescript/rpc/sort_and_filter_types_test.exs index 2922d9c7..bc6a4499 100644 --- a/test/ash_typescript/rpc/sort_and_filter_types_test.exs +++ b/test/ash_typescript/rpc/sort_and_filter_types_test.exs @@ -267,80 +267,36 @@ defmodule AshTypescript.Rpc.SortAndFilterTypesTest do {:ok, ts_output: ts_output} end - test "string fields include isNil", %{ts_output: ts_output} do - # Find PostFilterInput section - post_filter_section = - ts_output - |> String.split("export type PostFilterInput") - |> Enum.at(1) - |> String.split("export type") - |> Enum.at(0) - - assert post_filter_section != nil - assert post_filter_section =~ "isNil?: boolean" + test "string fields include isNil in utility types", %{ts_output: ts_output} do + # isNil is now part of the generic StringFilter type in utility types + assert ts_output =~ "isNil?: boolean" end - test "numeric fields include isNil" do - result = FilterTypes.generate_filter_type(AshTypescript.Test.Post) - - # viewCount is an integer - view_count_section = - result - |> String.split("viewCount?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert view_count_section =~ "isNil?: boolean" - assert view_count_section =~ "greaterThan?: number" + test "numeric fields use NumberFilter", %{ts_output: ts_output} do + # Post filter - viewCount uses NumberFilter + assert ts_output =~ "NumberFilter" end - test "datetime fields include isNil" do - result = FilterTypes.generate_filter_type(AshTypescript.Test.Post) - - published_at_section = - result - |> String.split("publishedAt?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert published_at_section =~ "isNil?: boolean" - assert published_at_section =~ "greaterThan?: UtcDateTime" + test "datetime fields use DateFilter", %{ts_output: ts_output} do + # Post filter - publishedAt uses DateFilter + assert ts_output =~ "DateFilter" end - test "boolean fields include isNil" do - result = FilterTypes.generate_filter_type(AshTypescript.Test.Post) - - published_section = - result - |> String.split("published?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert published_section =~ "isNil?: boolean" - assert published_section =~ "eq?: boolean" + test "boolean fields use BooleanFilter", %{ts_output: ts_output} do + # Post filter - published uses BooleanFilter + assert ts_output =~ "BooleanFilter" end - test "atom/enum fields include isNil" do - result = FilterTypes.generate_filter_type(AshTypescript.Test.Post) - - status_section = - result - |> String.split("status?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert status_section =~ "isNil?: boolean" + test "atom/enum fields use GenericFilter", %{ts_output: ts_output} do + # Post filter - status uses GenericFilter + assert ts_output =~ "GenericFilter<" end - test "isNil type is boolean, not the field type" do - result = FilterTypes.generate_filter_type(AshTypescript.Test.Post) + test "isNil type is boolean in utility types" do + utility_result = AshTypescript.Codegen.UtilityTypes.generate_utility_types() - # Verify isNil is always boolean, never the field's base type - is_nil_matches = Regex.scan(~r/isNil\?: (\w+);/, result) + # Verify isNil is always boolean in the generic filter types + is_nil_matches = Regex.scan(~r/isNil\?: (\w+);/, utility_result) for [_full, type] <- is_nil_matches do assert type == "boolean", "isNil should always be boolean, got: #{type}" @@ -580,109 +536,44 @@ defmodule AshTypescript.Rpc.SortAndFilterTypesTest do test ":exists aggregate generates boolean filter" do result = FilterTypes.generate_filter_type(AshTypescript.Test.Todo) - has_comments_section = - result - |> String.split("hasComments?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert has_comments_section =~ "eq?: boolean" - assert has_comments_section =~ "notEq?: boolean" - assert has_comments_section =~ "isNil?: boolean" - refute has_comments_section =~ "greaterThan" + assert result =~ "hasComments?: BooleanFilter;" end test ":max aggregate generates numeric filter with comparisons" do result = FilterTypes.generate_filter_type(AshTypescript.Test.Todo) - highest_rating_section = - result - |> String.split("highestRating?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert highest_rating_section =~ "eq?: number" - assert highest_rating_section =~ "greaterThan?: number" - assert highest_rating_section =~ "lessThan?: number" - assert highest_rating_section =~ "isNil?: boolean" + assert result =~ "highestRating?: NumberFilter;" end test ":avg aggregate generates numeric filter" do result = FilterTypes.generate_filter_type(AshTypescript.Test.Todo) - avg_section = - result - |> String.split("averageRating?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert avg_section =~ "eq?: number" - assert avg_section =~ "greaterThanOrEqual?: number" - assert avg_section =~ "isNil?: boolean" + assert result =~ "averageRating?: NumberFilter;" end test ":first aggregate generates typed filter based on source field" do result = FilterTypes.generate_filter_type(AshTypescript.Test.Todo) # latestCommentContent is a :first on :content (string) - content_section = - result - |> String.split("latestCommentContent?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert content_section =~ "eq?: string" - assert content_section =~ "notEq?: string" - assert content_section =~ "isNil?: boolean" + assert result =~ "latestCommentContent?: StringFilter;" end test ":list aggregate generates array filter" do result = FilterTypes.generate_filter_type(AshTypescript.Test.Todo) - list_section = - result - |> String.split("commentAuthors?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert list_section =~ "eq?: Array" - assert list_section =~ "notEq?: Array" - assert list_section =~ "isNil?: boolean" + assert result =~ "commentAuthors?: GenericFilter>;" end test ":sum aggregate generates numeric filter" do result = FilterTypes.generate_filter_type(AshTypescript.Test.Todo) - sum_section = - result - |> String.split("totalWeightedScore?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert sum_section =~ "eq?: number" - assert sum_section =~ "greaterThan?: number" - assert sum_section =~ "isNil?: boolean" + assert result =~ "totalWeightedScore?: NumberFilter;" end test ":count aggregate generates integer filter" do result = FilterTypes.generate_filter_type(AshTypescript.Test.Todo) - count_section = - result - |> String.split("commentCount?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert count_section =~ "eq?: number" - assert count_section =~ "greaterThan?: number" - assert count_section =~ "isNil?: boolean" + assert result =~ "commentCount?: NumberFilter;" end end end diff --git a/test/ash_typescript/typescript_filter_test.exs b/test/ash_typescript/typescript_filter_test.exs index d25b0f87..9e01978d 100644 --- a/test/ash_typescript/typescript_filter_test.exs +++ b/test/ash_typescript/typescript_filter_test.exs @@ -22,82 +22,52 @@ defmodule AshTypescript.FilterTest do test "includes string attribute filters" do result = FilterTypes.generate_filter_type(Post) - assert String.contains?(result, "title?: {") - assert String.contains?(result, "eq?: string") - # formatted with default :camel_case - assert String.contains?(result, "notEq?: string") - assert String.contains?(result, "in?: Array") - assert String.contains?(result, "isNil?: boolean") + assert String.contains?(result, "title?: StringFilter;") end test "includes boolean attribute filters" do result = FilterTypes.generate_filter_type(Post) - assert String.contains?(result, "published?: {") - assert String.contains?(result, "eq?: boolean") - # formatted with default :camel_case - assert String.contains?(result, "notEq?: boolean") - assert String.contains?(result, "isNil?: boolean") - # Boolean should not have comparison operators - # formatted with default :camel_case - refute String.contains?(result, "greaterThan?: boolean") + assert String.contains?(result, "published?: BooleanFilter;") end test "includes integer attribute filters with comparison operations" do result = FilterTypes.generate_filter_type(Post) - assert String.contains?(result, "viewCount?: {") - assert String.contains?(result, "eq?: number") - # formatted with default :camel_case - assert String.contains?(result, "greaterThan?: number") - # formatted with default :camel_case - assert String.contains?(result, "lessThan?: number") - assert String.contains?(result, "in?: Array") + assert String.contains?(result, "viewCount?: NumberFilter;") end test "includes decimal attribute filters with comparison operations" do result = FilterTypes.generate_filter_type(Post) - assert String.contains?(result, "rating?: {") - assert String.contains?(result, "eq?: Decimal") - # formatted with default :camel_case - assert String.contains?(result, "greaterThanOrEqual?: Decimal") - # formatted with default :camel_case - assert String.contains?(result, "lessThanOrEqual?: Decimal") + assert String.contains?(result, "rating?: NumberFilter;") end test "includes datetime attribute filters with comparison operations" do result = FilterTypes.generate_filter_type(Post) - assert String.contains?(result, "publishedAt?: {") - assert String.contains?(result, "eq?: UtcDateTime") - # formatted with default :camel_case - assert String.contains?(result, "greaterThan?: UtcDateTime") - # formatted with default :camel_case - assert String.contains?(result, "lessThan?: UtcDateTime") + assert String.contains?(result, "publishedAt?: DateFilter;") end test "includes constrained atom attribute filters" do result = FilterTypes.generate_filter_type(Post) - assert String.contains?(result, "status?: {") - assert String.contains?(result, "eq?: \"draft\" | \"published\" | \"archived\"") - assert String.contains?(result, "in?: Array<\"draft\" | \"published\" | \"archived\">") + assert String.contains?( + result, + "status?: GenericFilter<\"draft\" | \"published\" | \"archived\">;" + ) end test "includes array attribute filters" do result = FilterTypes.generate_filter_type(Post) - assert String.contains?(result, "tags?: {") - assert String.contains?(result, "eq?: Array") - assert String.contains?(result, "in?: Array>") + assert String.contains?(result, "tags?: GenericFilter>;") end test "includes map attribute filters" do result = FilterTypes.generate_filter_type(Post) - assert String.contains?(result, "metadata?: {") - assert String.contains?(result, "eq?: Record") + assert String.contains?(result, "metadata?: GenericFilter>;") end test "includes relationship filters" do @@ -108,80 +78,6 @@ defmodule AshTypescript.FilterTest do end end - describe "get_applicable_operations/2" do - # Testing through generate_filter_type since get_applicable_operations is private - - test "string types get basic operations, isNil only when allow_nil?" do - result = FilterTypes.generate_filter_type(Post) - - # title has allow_nil?: false — should NOT have isNil - title_section = - result - |> String.split("title?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert String.contains?(title_section, "eq?: string") - assert String.contains?(title_section, "notEq?: string") - assert String.contains?(title_section, "in?: Array") - refute String.contains?(title_section, "isNil") - refute String.contains?(title_section, "greaterThan") - - # content has allow_nil?: true — should have isNil - content_section = - result - |> String.split("content?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert String.contains?(content_section, "eq?: string") - assert String.contains?(content_section, "isNil?: boolean") - end - - test "numeric types get comparison operations plus isNil" do - result = FilterTypes.generate_filter_type(Post) - - # Find the view_count field in the result - view_count_section = - result - |> String.split("viewCount?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert String.contains?(view_count_section, "eq?: number") - # formatted with default :camel_case - assert String.contains?(view_count_section, "greaterThan?: number") - # formatted with default :camel_case - assert String.contains?(view_count_section, "lessThan?: number") - assert String.contains?(view_count_section, "in?: Array") - assert String.contains?(view_count_section, "isNil?: boolean") - end - - test "boolean types get limited operations plus isNil" do - result = FilterTypes.generate_filter_type(Post) - - # Find the published field in the result - published_section = - result - |> String.split("published?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert String.contains?(published_section, "eq?: boolean") - # formatted with default :camel_case - assert String.contains?(published_section, "notEq?: boolean") - assert String.contains?(published_section, "isNil?: boolean") - # formatted with default :camel_case - refute String.contains?(published_section, "greaterThan") - # formatted with default :camel_case - refute String.contains?(published_section, "lessThan") - end - end - describe "generate_all_filter_types/1" do # This would require setting up a full domain with resources # For now, we'll test the concept with a mock @@ -213,103 +109,15 @@ defmodule AshTypescript.FilterTest do result = FilterTypes.generate_filter_type(NoRelationshipsResource) assert String.contains?(result, "NoRelationshipsResourceFilterInput") - assert String.contains?(result, "name?: {") + assert String.contains?(result, "name?: StringFilter;") end end describe "aggregate filter types" do test "generates filter type for sum aggregate over a calculation field" do - # Todo has a :total_weighted_score sum aggregate that references - # the :weighted_score calculation on TodoComment (not an attribute) - result = FilterTypes.generate_filter_type(AshTypescript.Test.Todo) - - # Should generate filter type for the sum aggregate over calculation - assert String.contains?(result, "totalWeightedScore?: {") - # Sum aggregates over integer calculations should have numeric operations - assert String.contains?(result, "eq?: number") - assert String.contains?(result, "greaterThan?: number") - assert String.contains?(result, "lessThan?: number") - end - end - - describe "isNil respects allow_nil?" do - test "non-nullable attribute does NOT get isNil" do - result = FilterTypes.generate_filter_type(Post) - - # Post.id has allow_nil?: false - id_section = - result - |> String.split("id?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - refute String.contains?(id_section, "isNil"), - "id (allow_nil?: false) should not have isNil" - - # Post.title has allow_nil?: false - title_section = - result - |> String.split("title?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - refute String.contains?(title_section, "isNil"), - "title (allow_nil?: false) should not have isNil" - end - - test "nullable attribute DOES get isNil" do - result = FilterTypes.generate_filter_type(Post) - - # Post.content has allow_nil?: true - content_section = - result - |> String.split("content?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert String.contains?(content_section, "isNil?: boolean"), - "content (allow_nil?: true) should have isNil" - - # Post.published has allow_nil?: true - published_section = - result - |> String.split("published?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert String.contains?(published_section, "isNil?: boolean"), - "published (allow_nil?: true) should have isNil" - end - - test "aggregates always get isNil regardless of source field allow_nil?" do result = FilterTypes.generate_filter_type(AshTypescript.Test.Todo) - # latestCommentContent is a :first aggregate on TodoComment.content - # which has allow_nil?: false, but aggregate results are always nullable - content_section = - result - |> String.split("latestCommentContent?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert String.contains?(content_section, "isNil?: boolean"), - "aggregate should always have isNil even when source field has allow_nil?: false" - - # commentCount is a :count aggregate (always nullable) - count_section = - result - |> String.split("commentCount?: {") - |> Enum.at(1) - |> String.split("};") - |> Enum.at(0) - - assert String.contains?(count_section, "isNil?: boolean"), - "count aggregate should have isNil" + assert String.contains?(result, "totalWeightedScore?: NumberFilter;") end end end