*/ public function tokens(string $query): array { $normalized = mb_strtolower(Str::ascii($query)); $tokens = array_values(array_filter(array_unique(array_map( fn ($word) => trim($word, ".,;:!?\"'()[]{}"), preg_split('/\s+/u', trim($normalized), -1, PREG_SPLIT_NO_EMPTY) ?: [] )), fn ($word) => $word !== '')); if (count($tokens) > 1) { $meaningful = array_values(array_diff($tokens, ['a', 'as', 'o', 'os', 'de', 'do', 'da', 'dos', 'das', 'em', 'no', 'na', 'nos', 'nas', 'para', 'por', 'e', 'como', 'qual', 'quais', 'um', 'uma'])); return $meaningful === [] ? $tokens : $meaningful; } return $tokens; } /** Known Portuguese spelling and number variants; do not infer semantic equivalence. */ public function variants(string $token): array { foreach ([['sector', 'sectores', 'setor', 'setores'], ['projecto', 'projectos', 'projeto', 'projetos'], ['investir', 'investimento', 'investimentos'], ['investidor', 'investidores'], ['oportunidade', 'oportunidades'], ['documento', 'documentos'], ['empresa', 'empresas'], ['parceria', 'parcerias'], ['zona', 'zonas'], ['formulario', 'formularios'], ['provincia', 'provincias'], ['corredor', 'corredores'], ['cluster', 'clusters'], ['imposto', 'impostos'], ['licenca', 'licencas']] as $group) { if (in_array($token, $group, true)) { return $group; } } return [$token]; } public function pattern(string $token): string { return '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $token).'%'; } /** Columns are trusted application constants, never user input. */ public function orFields($query, array $columns, string $token): void { foreach ($columns as $column) { foreach ($this->variants($token) as $variant) { $query->orWhereRaw($this->normalizedColumn($column)." LIKE ? ESCAPE '!'", [$this->pattern($variant)]); } } } public function constrain($query, array $columns, array $tokens): void { foreach ($tokens as $token) { $query->where(fn ($match) => $this->orFields($match, $columns, $token)); } } public function matches(string $text, array $tokens): bool { $text = mb_strtolower(Str::ascii($text)); foreach ($tokens as $token) { if (! array_filter($this->variants($token), fn ($variant) => str_contains($text, $variant))) { return false; } } return true; } private function normalizedColumn(string $column): string { return "translate(lower(coalesce({$column}::text, '')), 'áàâãäéèêëíìîïóòôõöúùûüçñ', 'aaaaaeeeeiiiiooooouuuucn')"; } public function score(string $title, string $phrase, array $tokens): int { $title = mb_strtolower(Str::ascii($title)); $phrase = mb_strtolower(Str::ascii(trim($phrase))); $score = $title === $phrase ? 1000 : (str_contains($title, $phrase) ? 400 : 0); foreach ($tokens as $token) { if ($this->matches($title, [$token])) { $score += 20; } } return $score; } /** The same title score used for in-memory pages, with bound search terms. */ public function orderByRelevance($query, string $titleColumn, string $phrase, array $tokens, string $tieColumn): void { $column = $this->normalizedColumn($titleColumn); $phrase = mb_strtolower(Str::ascii(trim($phrase))); $sql = "CASE WHEN {$column} = ? THEN 1000 WHEN {$column} LIKE ? ESCAPE '!' THEN 400 ELSE 0 END"; $bindings = [$phrase, $this->pattern($phrase)]; foreach ($tokens as $token) { $conditions = []; foreach ($this->variants($token) as $variant) { $conditions[] = "{$column} LIKE ? ESCAPE '!'"; $bindings[] = $this->pattern($variant); } $sql .= ' + CASE WHEN ('.implode(' OR ', $conditions).') THEN 20 ELSE 0 END'; } $query->orderByRaw('('.$sql.') DESC', $bindings)->orderBy($titleColumn)->orderBy($tieColumn); } }