I am experiencing some weird behavior with the Elasticsearch wrapper Ruby gem Searchkick, and I'd love your help!
The Issue
The expected behavior is that I should be able to search for a full name with a space in it. This works in my test, but not in browser.
The Code
account.rb
class Account
searchkick word_start: [
:first_name,
:last_name,
:name, # <======================== key line
:email,
:phone,
:company,
], callbacks: :async
def self.index_search(query:)
search(
query || '*',
fields: [
:first_name,
:last_name,
:name, # <======================== key line
:email,
:phone,
:company,
],
match: :word_start,
where: { test_account: false },
order: { created_at: :desc },
limit: 20,
misspellings: false,
)
end
def search_data
{
first_name: first_name,
last_name: last_name,
name: "#{first_name} #{last_name}", # <======================== key line
created_at: created_at,
email: email,
phone: phone,
test_account: test_account,
company: company
}
end
end
account_test.rb - ALL OF THESE PASS
class AccountTest < ActiveSupport::TestCase
describe "::index_search" do
let(:account_0) do
FactoryGirl.create(:account, company: "Xyz Consulting") # name: "Testy McTesterson"
end
let(:account_1) do
FactoryGirl.create(:account, first_name: "Bob") # name: "Bob McTesterson"
end
let(:search) do
Account.index_search(query: query)
end
before :all do
account_0 and account_1 # instantiate
Searchkick.disable_callbacks
Account.reindex
end
describe "when there are spaces in the string" do
let(:query) { "xyz con" }
it "finds all and only the appropriate records" do
assert_equal [account_0.id], search.map(&:id)
end
end
describe "when the query matches the full name" do
let(:query) { "bob mct" }
it "finds all and only the appropriate records" do
assert_equal [account_1.id], search.map(&:id)
end
end
end
end
Note: when I comment out the three 'key lines' from account.rb, the second of those tests fails. So the name: in the #search_data method seems to be working.
search_controller.rb (essentially)
class SearchController < ApplicationController
def index
puts "params[:query]: #{params[:query]}"
puts "custom_search.map(&:first_name): #{custom_search.map(&:first_name)}"
puts "custom_search.map(&:last_name): #{custom_search.map(&:last_name)}"
end
private
def custom_search
Account.index_search(query: params[:query])
end
end
The Complication
Nonetheless, in the browser, it doesn't seem to be working. With the above code, searching via the browser, I can't reproduce the success.
When I search for "bob ", the output in my server console is
params[:query]: bob
custom_search.map(&:first_name): ["Bob"]
custom_search.map(&:last_name): ["McTesterdorff"]
but as soon as I search for "bob m", "bob mct", or "bob mctesterdorff", I get empty results (respecitvely):
params[:query]: bob m
custom_search.map(&:first_name): []
custom_search.map(&:last_name): []
params[:query]: bob mct
custom_search.map(&:first_name): []
custom_search.map(&:last_name): []
params[:query]: bob mctesterdorff
custom_search.map(&:first_name): []
custom_search.map(&:last_name): []
Do you all have any idea what the issue might be?
You need to call Account.reindex once in a while to index/reindex new items. Or you could also add Account.reindex to the according Model within an after_commit callback. This way your new data will be indexed after each insert/commit.
Related
I'm trying to write my test to ensure creating a new book with genres assigned to it works.
I am using Active Model Serializer with the JSON_API structure (http://jsonapi.org/)
Book Model File
class Book < ApplicationRecord
belongs_to :author, class_name: "User"
has_and_belongs_to_many :genres
end
Genre Model File
class Genre < ApplicationRecord
has_and_belongs_to_many :books
end
Book Serializer file
class BookSerializer < ActiveModel::Serializer
attributes :id, :title, :adult_content, :published
belongs_to :author
has_many :genres
end
Test Sample Data
def setup
...
#fantasy = genres(:fantasy)
#newbook = {
title: "Three Little Pigs",
adult_content: false,
author_id: #jim.id,
published: false,
genres: [{title: 'Fantasy'}]
}
end
Test Method
test "book create - should create a new book" do
post books_path, params: #newbook, headers: user_authenticated_header(#jim)
assert_response :created
json = JSON.parse(response.body)
puts "json = #{json}"
assert_equal "Three Little Pigs", json['data']['attributes']['title']
genre_data = json['data']['relationships']['genres']['data']
puts "genre_data = #{genre_data.count}"
assert_equal "Fantasy", genre_data
end
Book Strong Params
def book_params
params.permit(:title, :adult_content, :published, :author_id, :genres)
end
Test Result (console response)
# Running:
......................................................json = {"data"=>{"id"=>"1018350796", "type"=>"books", "attributes"=>{"title"=>"Three Little Pigs", "adult-content"=>false, "published"=>false}, "relationships"=>{"author"=>{"data"=>{"id"=>"1027431151", "type"=>"users"}}, "genres"=>{"data"=>[]}}}}
genre_data = 0
F
Failure:
BooksControllerTest#test_book_create_-_should_create_a_new_book [/Users/warlock/App_Projects/Raven Quill/Source Code/Rails/raven-quill-api/test/controllers/books_controller_test.rb:60]:
Expected: "Fantasy"
Actual: []
bin/rails test test/controllers/books_controller_test.rb:51
Finished in 1.071044s, 51.3518 runs/s, 65.3568 assertions/s.
55 runs, 70 assertions, 1 failures, 0 errors, 0 skips
As you can see from my JSON console log, it appears my genres are not being set(need to scroll to the right in the test output above).
Please ignore this line:
assert_equal "Fantasy", genre_data
I know that's wrong. At the moment, the json is showing genre => {data: []} (empty array), that's the thing I'm trying to solve at the moment.
How do I go about creating a book with genres in this case, any ideas? :D
This is just sad...third time this week, I am answering my own question.
I finally found the answer from this Stackoverflow question:
HABTM association with Strong Parameters is not saving user in Rails 4
Turns out my strong parameters need to be:
def book_params
params.permit(:title, :adult_content, :published, :author_id, {:genre_ids => []})
end
Then my test data can be:
#fantasy = genres(:fantasy)
#newbook = {
title: "Three Little Pigs",
adult_content: false,
author_id: #jim.id,
published: false,
genre_ids: [#fantasy.id]
}
Update my test method to:
test "book create - should create a new book" do
post books_path, params: #newbook, headers: user_authenticated_header(#jim)
assert_response :created
json = JSON.parse(response.body)
assert_equal "Three Little Pigs", json['data']['attributes']['title']
genre = json['data']['relationships']['genres']['data'].first['title']
assert_equal "Fantasy", genre
end
Now my test passes.
I'm relatively new to Rails, and this is my first usage of ActiveModel:Serializer. I'm just trying to set up a simple serializer and test it in the rails console before continuing. It seems to be using the serializer, but not returning valid JSON format. From the instructions I'm working off of (I'm a student), it seems I should be receiving JSON. I've been researching for the past few hours, but everything seems unrelated or goes over my head.
My User model:
class User < ActiveRecord::Base
# User has attributes: first_name, last_name, email, password
has_many :lists
def full_name
first_name + " " + last_name
end
end
My UserSerializer:
class UserSerializer < ActiveModel::Serializer
attributes :id, :full_name, :email
def full_name
object.full_name
end
end
Commands in the Rails console:
>> User.create(first_name: "Jane", last_name: "Doe", email: "test#fake.com")
>> UserSerializer.new(User.first).as_json
Console returns:
=> {"user"=>{:id=>1, :full_name=>"Jane Doe", :email=>"test#fake.com"}}
as_json constructs a Ruby Hash that can subsequently be "serialized" (converted to a string) by a serialization library.
Try calling to_json instead. This method will return a string that can be parsed.
>> User.create(first_name: "Jane", last_name: "Doe", email: "test#fake.com")
>> UserSerializer.new(User.first).to_json
=> "{\"user\":{\"id\":1,\"full_name\":\"Jane Doe\",\"email\":\"test#fake.com\"}}"
Use JSON.parse to deserialize the string back to a hash:
>> JSON.parse({"user"=>{:id=>1, :full_name=>"Jane Doe", :email=>"test#fake.com"}}.to_json)
=> {"user"=>{"id"=>1, "full_name"=>"Jane Doe", "email"=>"test#fake.com"}}
I'm using elasticsearch_autocomplete gem for autocomplete feature.
I have a problem with special characters ñ and accents áéíóú.
Model:
class Car
ac_field :name, :description, :city, :skip_settings => true
def self.ac_search(params, options={})
tire.search load: true, page: params[:page], per_page: 9 do
query do
boolean do
must { string params[:query], default_operator: "AND" } if params[:query].present?
must { term :city, params[:city] } if params[:city].present?
end
end
filter :term, city: params[:city] if params[:city].present?
facet "city" do
terms :city
end
end
end
end
This version works fine with special characters.
e.g.: Query with Martin I get all results with Martín, martín, martin, Martin
With this approach this is the problem:
Now what results is individual words. e.g. A city tagged ["San Francisco", "Madrid"] will end up having three separate tags. Similarly, if I do a query to search on "san francisco" (must { term 'city', params[:city] }), that will fail, while a query on "San" or "Francisco" will succeed. The desired behaviour here is that the tag should be atomic, so only a "San Francisco" (or "Madrid") tag search should succeed.
To fix this problem I create my custom mapping:
model = self
settings ElasticsearchAutocomplete::Analyzers::AC_BASE do
mapping _source: {enabled: true, includes: %w(name description city)} do
indexes :name, model.ac_index_config(:name)
indexes :description, model.ac_index_config(:description)
indexes :city, :type => 'string', :index => :not_analyzed
end
end
With this mapping the problem with multi-words is fixed, and now facets with city field works fine:
Instead of getting the type facets San and Francisco Now I get San Francisco
Now, the problem is that with this mapping inside of the model the search doesn't find results with special characters
e.g.: Query with Martin I get only results with Martin martin
I'm using mongoid instead active record.
How can I fix this problem? I think that the problem is with asciifolding tokenfilter.
I fixed the problem with:
class User
include Mongoid::Document
field :city, :type => String
has_one: car
end
class Car
ac_field :name, :description, :user_city, :skip_settings => true
def self.ac_search(params, options={})
tire.search load: true, page: params[:page], per_page: 9 do
query do
boolean do
must { term :user_city, params[:user_city] } if params[:user_city].present?
end
end
facet "cities" do
terms :user_city
end
end
end
model = self
settings ElasticsearchAutocomplete::Analyzers::AC_BASE do
mapping _source: {enabled: true, includes: %w(car_city name description)} do
indexes :car_city, :type => 'string', :index => :not_analyzed
end
end
def to_indexed_json
to_json(methods: [:user_city])
end
def user_city
user.city
end
end
I'm looking for some help on how to take an attribute and process it through a method to return something different. But I've never done this before and I' not sure where to start. I thought trying to change a name:string attribute from "George Washington" or "John Quincy Adams" into first names only "George" and "John".
I thought maybe a helper method would be best, such as
users_helper.rb
def first_name
end
and then call #user.name.first_name, would this be initially how it would work? Can someone explain where I'd go next to be able to pass #user.name into the method? I've seen things like this but don't quite understand it the parenthesis...
def first_name(name)
puts name
end
Could someone breakdown how rails/ruby does this type of thing? Thanks a lot!
Some people have more than two names, such as "John Clark Smith". You can choose to treat them as:
(1) first_name: "John", last_name: "Smith"
def first_name
if name.split.count > 1
name.split.first
else
name
end
end
def last_name
if name.split.count > 1
name.split.last
end
end
(2) first_name: "John Clark", last_name: "Smith"
def first_name
if name.split.count > 1
name.split[0..-2].join(' ')
else
name
end
end
def last_name
if name.split.count > 1
name.split.last
end
end
(3) first_name: "John", last_name: "Clark Smith"
def first_name
name.split.first
end
def last_name
if name.split.count > 1
name.split[1..-1].join(' ')
end
end
The above examples assume that if the name contains less than 2 words then it is a first name.
The parentheses (which are optional) enclose the parameter list.
def first_name(full_name)
full_name.split(" ")[0]
end
This assumes the parameter is not nil.
> puts first_name "Jimmy McKickems"
Jimmy
> puts first_name "Jeezy"
Jeezy
But this is not a string method, as your assumption is now:
#user.full_name.first_name # Bzzt.
Instead:
first_name #user.name
This could be wrapped up in the model class itself:
class User < ActiveRecord
# Extra stuff elided
def first_name
self.full_name.blank? ? "" : self.full_name.split(" ")[0]
end
end
The extra code checks to see if the name is nil or whitespace (blank? comes from Rails). If it is, it returns an empty string. If it isn't, it splits it on spaces and returns the first item in the resulting array.
In case you are looking to split only once and provide both parts this one liner will work:
last_name, first_name = *name.reverse.split(/\s+/, 2).collect(&:reverse)
Makes the last word the last name and everything else the first name. So if there is a prefix, "Dr.", or a middle name that will be included with the first name. Obviously for last names that have separate words, "Charles de Gaulle" it won't work but handling that is much harder (if not impossible).
Use Ruby's Array#pop
For my needs I needed to take full names that had 1, 2, 3 or more "names" in them, like "AUSTIN" or "AUSTIN J GILLEY".
The Helper Method
def split_full_name_into_first_name_and_last_name( full_name )
name_array = full_name.split(' ') # ["AUSTIN", "J", "GILLEY"]
if name_array.count > 1
last_name = name_array.pop # "GILLEY"
first_name = name_array.join(' ') # "AUSTIN J"
else
first_name = name_array.first
last_name = nil
end
return [ first_name, last_name ] # ["AUSTIN J", "GILLEY"]
end
Using It
split_full_name_into_first_name_and_last_name( "AUSTIN J GILLEY" )
# => ["AUSTIN J", "GILLEY"]
split_full_name_into_first_name_and_last_name( "AUSTIN" )
# => ["AUSTIN", nil]
And you can easily assign the first_name and last_name with:
first_name, last_name = split_full_name_into_first_name_and_last_name( "AUSTIN J GILLEY" )
first_name
# => "AUSTIN J"
last_name
# => "GILLEY"
You can modify from there based on what you need or want to do with it.
For the syntax you're asking for (#user.name.first_name) Rails does a lot of this sort of extension by adding methods to base types, in your example you could do this through defining methods on the String class.
class String
def given; self.split(' ').first end
def surname; self.split(' ').last end
end
"Phileas Fog".surname # 'fog'
Another way to do something like this is to wrap the type you whish to extend, that way you can add all the crazy syntax you wish without polluting more base types like string.
class ProperName < String
def given; self.split(' ').first end
def surname; self.split(' ').last end
end
class User
def name
ProperName.new(self.read_attribute(:name))
end
end
u = User.new(:name => 'Phileas Fog')
u.name # 'Phileas Fog'
u.name.given # 'Phileas'
u.name.surname # 'Fog'
Just as complement of great Dave Newton's answer, here is what would be the "last name" version:
def last_name
self.full_name.blank? ? "" : self.full_name.split(" ")[-1]
end
making it simple
class User < ActiveRecord::Base
def first_name
self.name.split(" ")[0..-2].join(" ")
end
def last_name
self.name.split(" ").last
end
end
User.create name: "John M. Smith"
User.first.first_name
# => "John M."
User.first.last_name
# => "Smith"
Thanks
def test_one(name)
puts "#{name.inspect} => #{yield(name).inspect}"
end
def tests(&block)
test_one nil, &block
test_one "", &block
test_one "First", &block
test_one "First Last", &block
test_one "First Middle Last", &block
test_one "First Middle Middle2 Last", &block
end
puts "First name tests"
tests do |name|
name.blank? ? "" : name.split(" ").tap{|a| a.pop if a.length > 1 }.join(" ")
end
puts "Last name tests"
tests do |name|
name.blank? ? "" : (name.split(" ").tap{|a| a.shift }.last || "")
end
Output:
First name tests
nil => ""
"" => ""
"First" => "First"
"First Last" => "First"
"First Middle Last" => "First Middle"
"First Middle Middle2 Last" => "First Middle Middle2"
Last name tests
nil => ""
"" => ""
"First" => ""
"First Last" => "Last"
"First Middle Last" => "Last"
"First Middle Middle2 Last" => "Last"
I have problems to get rspec running properly to test validates_inclusion_of my migration looks like this:
class CreateCategories < ActiveRecord::Migration
def self.up
create_table :categories do |t|
t.string :name
t.integer :parent_id
t.timestamps
end
end
def self.down
drop_table :categories
end
end
my model looks like this:
class Category < ActiveRecord::Base
acts_as_tree
validates_presence_of :name
validates_uniqueness_of :name
validates_inclusion_of :parent_id, :in => Category.all.map(&:id), :unless => Proc.new { |c| c.parent_id.blank? }
end
my factories:
Factory.define :category do |c|
c.name "Category One"
end
Factory.define :category_2, :class => Category do |c|
c.name "Category Two"
end
my model spec looks like this:
require 'spec_helper'
describe Category do
before(:each) do
#valid_attributes = {
:name => "Category"
}
end
it "should create a new instance given valid attributes" do
Category.create!(#valid_attributes)
end
it "should have a name and it shouldn't be empty" do
c = Category.new :name => nil
c.should be_invalid
c.name = ""
c.should be_invalid
end
it "should not create a duplicate names" do
Category.create!(#valid_attributes)
Category.new(#valid_attributes).should be_invalid
end
it "should not save with invalid parent" do
parent = Factory(:category)
child = Category.new #valid_attributes
child.parent_id = parent.id + 100
child.should be_invalid
end
it "should save with valid parent" do
child = Factory.build(:category_2)
child.parent = Factory(:category)
# FIXME: make it pass, it works on cosole, but I don't know why the test is failing
child.should be_valid
end
end
I get the following error:
'Category should save with valid
parent' FAILED Expected #<Category id:
nil, name: "Category Two", parent_id:
5, created_at: nil, updated_at: nil>
to be valid, but it was not Errors:
Parent is missing
On console everything seems to be fine and work as expected:
c1 = Category.new :name => "Parent Category"
c1.valid? #=> true
c1.save #=> true
c1.id #=> 1
c2 = Category.new :name => "Child Category"
c2.valid? #=> true
c2.parent_id = 100
c2.valid? #=> false
c2.parent_id = 1
c2.valid? #=> true
I'm running rails 2.3.5, rspec 1.3.0 and rspec-rails 1.3.2
Anybody, any idea?
The problem is that you can't put a call to Category.all.map(&:id) inside the called to validates_inclusion_of.
The first indication that this is the case will be apparent when you try to run
rake db:migrate:down VERSION=<n>
rake db:migrate:up VERSOIN=<n>
where <n> is the version number of the migration that creates the Category model.
You will get something like:
in /Users/sseefried/tmp/so)
== CreateCategories: reverting ===============================================
-- drop_table(:categories)
-> 0.0032s
== CreateCategories: reverted (0.0034s) ======================================
(in /Users/sseefried/tmp/so)
rake aborted!
SQLite3::SQLException: no such table: categories: SELECT * FROM "categories"
(See full trace by running task with --trace)
This is because rake tries to load app/models/category.rb before running the migration. Because the Category model does not exist it fails.
Another way to see the problem is to do tail -f log/development.log and then try to open a console with script/console. You will see an SQL query of the form:
SELECT * FROM "categories"
in the output. This corresponds to the call to Category.all.map(:&id). However, once you start typing commands like:
c1 = Category.new, :name => "Category 1"
you will see that the query SELECT * from "categories" does not reappear in the log. The moral of the story is only constants can appear in calls to validations_inclusion_of because the code in there will only be evaluated once..
The only reason your console code worked was because, in a previous console session, you had created a Category object with id=1
You can write a custom validation that does what you want with:
validate :parent_exists
protected
def parent_exists
ids = Category.all.map(&:id)
if !parent_id.blank? && !ids.member?(parent_id)
errors.add(:parent_id, "does not point to a valid parent record")
end
end
Your rspec tests will pass once you have added this code.
Actually, you can defer enumerable calculation by simply putting Category.all.map(&:id) into a proc/lambda.
Writing
validates_inclusion_of :parent_id,
in: proc{ Category.all.map(&:id) },
unless: proc{ |c| c.parent_id.blank? }
will fetch categories' ids at the time of validation, not at the time of class declaration.