Bombadil is a tiny wrapper around some PostgreSQL search capabilities.
It supports exact match through PostgreSQL tsvector(s) and fuzzy search inside jsonb fields.
The package can be installed by adding bombadil to your list of dependencies in mix.exs:
defdepsdo[{:bombadil,"~> 0.2"}]endDocumentation is at https://hexdocs.pm/bombadil
Assuming that you have generated a migration with a "search_index" table
aliasBombadil.Ecto.Schema.SearchIndexiex>YourRepo.insert_or_update(Bombadil.index(SearchIndex,payload: %{"book"=>"Lord of the Rings"}))# Raw SQL: INSERT INTO "search_index" ("payload") VALUES ('{"book": "Lord of the Rings"}'){:ok,struct}# Full string providediex>YourRepo.all(Bombadil.search(SearchIndex,"Lord of the Rings"))# Raw SQL: SELECT s0."id", s0."test", s0."item_id", s0."payload" FROM "search_index" AS s0 WHERE ((payload::text) ~~* $1) ["%Lord of the Rings%"][%Bombadil.Ecto.Schema.SearchIndex{__meta__: #Ecto.Schema.Metadata<:loaded, "search_index">,payload: %{"book"=>"Lord of the Rings"},id: 1}]# One word provided (treated as case-insensitive)iex>YourRepo.all(Bombadil.search(SearchIndex,"lord"))# Raw SQL: SELECT s0."id", s0."test", s0."item_id", s0."payload" FROM "search_index" AS s0 WHERE ((payload::text) ~~* $1) ["%lord%"][%Bombadil.Ecto.Schema.SearchIndex{__meta__: #Ecto.Schema.Metadata<:loaded, "search_index">,payload: %{"book"=>"Lord of the Rings"},id: 1}]# No resultsiex>YourRepo.all(Bombadil.search(SearchIndex,"lordz"))# Raw SQL: SELECT s0."id", s0."test", s0."item_id", s0."payload" FROM "search_index" AS s0 WHERE ((payload::text) ~~* $1) ["%lordz%"][]iex>YourRepo.all(Bombadil.fuzzy_search(SearchIndex,"lord of the ringz"))# Raw SQL: SELECT s0."id", s0."payload" FROM "search_index" AS s0 WHERE (payload::text %> 'lord of the ringz')[%Bombadil.Ecto.Schema.SearchIndex{__meta__: #Ecto.Schema.Metadata<:loaded, "search_index">,payload: %{"book"=>"Lord of the Rings"},id: 1}]# No resultsiex>YourRepo.all(Bombadil.fuzzy_search(SearchIndex,"lard of the ringz asdf"))# Raw SQL: SELECT s0."id", s0."payload" FROM "search_index" AS s0 WHERE (payload::text %> 'lord of the ringz asdf')[]iex>YourRepo.insert_or_update(Bombadil.index(SearchIndex,payload: %{"character"=>"Tom Bombadil"})){:ok,struct}iex>YourRepo.all(Bombadil.search(SearchIndex,[%{"book"=>"rings"}]))# Raw SQL: SELECT s0."id", s0."test", s0."item_id", s0."payload" FROM "search_index" AS s0 WHERE (FALSE OR (payload->$1)::text ~~* $2)[%Bombadil.Ecto.Schema.SearchIndex{__meta__: #Ecto.Schema.Metadata<:loaded, "search_index">,payload: %{"book"=>"Lord of the Rings"},id: 1}]iex>YourRepo.all(Bombadil.search(SearchIndex,[%{"character"=>"bombadil"}]))# Raw SQL: SELECT s0."id", s0."test", s0."item_id", s0."payload" FROM "search_index" AS s0 WHERE (FALSE OR (payload->$1)::text ~~* $2)[%Bombadil.Ecto.Schema.SearchIndex{__meta__: #Ecto.Schema.Metadata<:loaded, "search_index">,payload: %{"character"=>"Tom Bombadil"},id: 3}]iex>YourRepo.all(Bombadil.fuzzy_search(SearchIndex,[%{"book"=>"lard"}]))# Raw SQL: SELECT s0."id", s0."payload" FROM "search_index" AS s0 WHERE (FALSE OR (payload->'book')::text %> 'lard')[%Bombadil.Ecto.Schema.SearchIndex{__meta__: #Ecto.Schema.Metadata<:loaded, "search_index">,payload: %{"book"=>"Lord of the Rings"},id: 1}]iex>YourRepo.all(Bombadil.fuzzy_search(SearchIndex,[%{"character"=>"tom bomba"}]))# Raw SQL: SELECT s0."id", s0."payload" FROM "search_index" AS s0 WHERE (FALSE OR (payload->'character')::text %> 'tom bomba')[%Bombadil.Ecto.Schema.SearchIndex{__meta__: #Ecto.Schema.Metadata<:loaded, "search_index">,payload: %{"character"=>"Tom Bombadil"},id: 3}]iex>YourRepo.all(Bombadil.fuzzy_search(SearchIndex,[%{"book"=>"tom"}]))# Raw SQL: SELECT s0."id", s0."payload" FROM "search_index" AS s0 WHERE (FALSE OR (payload->'book')::text %> 'tom bomba')[]Given a search_index that has also an item_id field as an additional context
for filtering, let's suppose you index also the item_id and you might have the
same item_id for another entry with different payload field like in this snippet:
iex>YourRepo.insert_or_update(Bombadil.index(SearchIndex,item_id: 42,payload: %{"ask"=>"lord of the rings"}))iex>YourRepo.insert_or_update(Bombadil.index(SearchIndex,item_id: 42,payload: %{"ask"=>"I am hiding with the same id, don't find me!"}))You can apply a search and a context to filter a specific id, in this case item_id is 42
and the payload that is "hiding" is filtered out.
iex>YourRepo.all(Bombadil.fuzzy_search(SearchIndex,"lord of the ringz",context: %{item_id: 42}))[%Bombadil.Ecto.Schema.SearchIndex{__meta__: #Ecto.Schema.Metadata<:loaded, "search_index">,payload: %{"ask"=>"lord of the rings"},id: 6,item_id: 42}]iex>Bombadil.search(SearchIndex,"rings")|>Jason.encode!()"[{\"payload\":{\"book\":\"Lord of the Rings\"}}]"defmoduleYourApp.Ecto.Schema.SearchIndexdouseEcto.Schema@fields[:field1,:field2,:map]@derive{Jason.Encoder,only: @fields}schema"your_table"dofield(:payload,:map)field(:field1,:string)field(:field2,:string)endendIf you want to use a different name for the payload field, you can define your schema in this way:
defmoduleYourApp.Ecto.Schema.SearchIndexdouseEcto.Schemaschema"search_index"dofield(:data,:map,source: :payload)field(:item_id,:integer)endendNotice how the:
field(:data,:map,source: :payload)specifies via the field macro another name for the :payload column
mix ecto.gen.migration create_search_index # For exampledefmoduleYourApp.Repo.Migrations.CreateSearchIndexdouseEcto.Migrationdefchangedocreatetable("search_index")doadd(:payload,:jsonb)add(:field1,:string)add(:field2,:string)endendendconfig:bombadil,Bombadil.Repo,database: "your_database",username: "postgres",password: "postgres",hostname: "localhost"config:bombadil,table_name: "search_index",additional_fields: [{:your_special_id,:string},[...]]mix ecto.gen.migration create_search_index # For exampledefmoduleYourApp.Repo.Migrations.CreateSearchIndexdouseEcto.MigrationrequireBombadil.Ecto.DynamicMigrationdefchangedoBombadil.Ecto.DynamicMigration.run()endendmixecto.migrateAnd implement indexing and search for your use case by using the
Bombadil.fuzzy_search/2 and Bombadil.index/2 functions with your Ecto.Repo
[ ] Support other fields, rather than jsonb (?)
Many thanks to Taxfix for giving me the possibility to develop and release this library.🚀
Thanks to the https://github.com/textacular/textacular ruby library for having kicked my butt to create something similar for Elixir ☯
