Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db73aab0de |
@@ -0,0 +1,54 @@
|
||||
# SRC-05 – Prüfprotokoll: Facetten- & Filter-API
|
||||
|
||||
Voraussetzung SRC-03 (Fertig).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `mail/internal/search/migrations/0002..0005_*.sql`: vier eigene,
|
||||
nummerierte `ALTER TABLE ADD COLUMN`-Migrationen für die neuen
|
||||
Facettenfelder (`sender`, `mailbox`, `attachment_type`, `tag`) — Manticore
|
||||
erlaubt nur eine Spalte je ALTER-Anweisung. `EnsureSchema` wendet sie
|
||||
idempotent nach (Fehlertext `"already in schema"` gilt als bereits
|
||||
angewendet, kein Fehlerzustand).
|
||||
- `fields.go`: neue statische Feldkonstanten + `FacetFields`-Whitelist
|
||||
(`sender`, `mailbox`, `attachment_type`, `tag`) — einzige Quelle
|
||||
zulässiger Facettendimensionen, kein beliebiger Client-Feldname möglich.
|
||||
- `facets.go` — `Client.Facets(ctx, tenantSlug, queryText, filters)`:
|
||||
nutzt Manticores strukturierte `aggs.terms`/`aggs.range`-API (kein
|
||||
dynamischer SQL-Klauselbau). Tenant-Filter + optionale
|
||||
`FacetFilter`-Liste laufen als zusätzliche `equals`-Klauseln in
|
||||
derselben `bool.must`-Liste (Akzeptanzkriterium 2: UND-Verknüpfung).
|
||||
Zeitraum-Facette über feste Buckets (letzte 7 Tage/30 Tage/Jahr/älter)
|
||||
via `aggs.range` auf `sent_at`.
|
||||
- `Document` um optionale Facettenfelder erweitert (`Sender`, `Mailbox`,
|
||||
`AttachmentType`, `Tag`).
|
||||
- Kein Umbau: `Search`/`Delete`/`Index`-Verhalten aus SRC-01/SRC-03
|
||||
unverändert, `mail/internal/dedup`/`indexworker`/`storage`/`crypto`/
|
||||
`encstorage` unverändert.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Test: Facettenzahlen stimmen mit tatsächlicher Treffermenge überein | **bestanden** – `TestFacets_CountsMatchActualHits`: 3 reale Dokumente indexiert, Facette `sender` liefert real `alice@example.com`→2, `bob@example.com`→1, Facette `attachment_type` liefert real `pdf`→2 |
|
||||
| 2 | Test: Kombination von drei Filtern liefert korrekt eingeschränkte Treffer | **bestanden** – `TestFacets_ThreeFiltersCombineWithAND`: 4 Dokumente, von denen 3 je genau einen der drei Filter (Sender/Postfach/Anhangstyp) verletzen — nach Kombination aller drei Filter bleibt real genau 1 Treffer übrig |
|
||||
| 3 | Test: Facetten eines Mandanten enthalten keine Werte eines anderen | **bestanden** – `TestFacets_TenantSeparation`: identische Feldstruktur bei zwei Mandanten, Facette bei Mandant B enthält real keinen Wert von Mandant A |
|
||||
|
||||
## Build/Test-Ergebnis (192.168.1.131)
|
||||
|
||||
```
|
||||
go build ./... -> clean
|
||||
go vet ./... -> clean
|
||||
golangci-lint run ./... -> 0 issues
|
||||
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
|
||||
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
|
||||
go test ./... -p 1 -> alle Pakete bestanden, inkl. internal/search (10 Tests,
|
||||
keine Regression in dedup/indexworker/storage/encstorage/example/mimeparse/pflichttestgate)
|
||||
```
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
|
||||
real erfüllt. Entsperrt SRC-06, trägt (gemeinsam mit ARC-08, SRC-02,
|
||||
SRC-04, SRC-08, SRC-09, SRC-10) zu QA-03 bei — QA-03 bleibt weiterhin
|
||||
blockiert, bis auch die übrigen vier Tickets fertig sind.
|
||||
@@ -22,6 +22,29 @@ import (
|
||||
//go:embed migrations/0001_mail_documents.sql
|
||||
var schemaMigration string
|
||||
|
||||
// SRC-05: Facettenfelder als eigene, nummerierte ALTER-Migrationen
|
||||
// nachgezogen (Manticore erlaubt nur eine Spalte je ALTER TABLE ADD
|
||||
// COLUMN-Anweisung). Reihenfolge ist die Anwendungsreihenfolge.
|
||||
//
|
||||
//go:embed migrations/0002_mail_documents_facets.sql
|
||||
var migrationAddSender string
|
||||
|
||||
//go:embed migrations/0003_mail_documents_mailbox.sql
|
||||
var migrationAddMailbox string
|
||||
|
||||
//go:embed migrations/0004_mail_documents_attachment_type.sql
|
||||
var migrationAddAttachmentType string
|
||||
|
||||
//go:embed migrations/0005_mail_documents_tag.sql
|
||||
var migrationAddTag string
|
||||
|
||||
var facetMigrations = []string{
|
||||
migrationAddSender,
|
||||
migrationAddMailbox,
|
||||
migrationAddAttachmentType,
|
||||
migrationAddTag,
|
||||
}
|
||||
|
||||
// Client spricht ausschließlich über die strukturierte Manticore-HTTP-
|
||||
// JSON-API (kein String-Zusammenbau von SQL-Klauseln, siehe fields.go).
|
||||
// Die SQL-Schnittstelle wird nur für EnsureSchema verwendet, und dort
|
||||
@@ -40,24 +63,46 @@ func NewClient(baseURL string) *Client {
|
||||
}
|
||||
|
||||
// EnsureSchema legt den Index gemäß dem versionierten, statischen
|
||||
// Migrationstext an (Akzeptanzkriterium 1). Idempotent (CREATE TABLE
|
||||
// IF NOT EXISTS im Migrationstext).
|
||||
// Migrationstext an (Akzeptanzkriterium 1) und zieht die Facettenfelder
|
||||
// (SRC-05) idempotent nach.
|
||||
func (c *Client) EnsureSchema(ctx context.Context) error {
|
||||
form := "query=" + schemaMigration
|
||||
if err := c.runSchemaSQL(ctx, schemaMigration); err != nil {
|
||||
return fmt.Errorf("search: schema anlegen: %w", err)
|
||||
}
|
||||
for _, migration := range facetMigrations {
|
||||
if err := c.runSchemaSQL(ctx, migration); err != nil {
|
||||
// Manticore meldet bei erneutem ADD COLUMN "field already in
|
||||
// schema" — kein Fehler, sondern der bereits angewendete
|
||||
// Migrationsschritt (Idempotenz, gleiche CREATE-TABLE-IF-NOT-
|
||||
// EXISTS-Konvention wie das Basisschema).
|
||||
if strings.Contains(err.Error(), "already in schema") {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("search: facettenfeld-migration: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) runSchemaSQL(ctx context.Context, query string) error {
|
||||
form := "query=" + query
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sql?mode=raw", strings.NewReader(form))
|
||||
if err != nil {
|
||||
return fmt.Errorf("search: schema-anfrage bauen: %w", err)
|
||||
return fmt.Errorf("anfrage bauen: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("search: schema anlegen: %w", err)
|
||||
return fmt.Errorf("ausführen: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("search: schema anlegen, status %d: %s", resp.StatusCode, string(body))
|
||||
return fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
if strings.Contains(string(body), `"error":"`) && !strings.Contains(string(body), `"error":""`) {
|
||||
return fmt.Errorf("%s", string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -72,6 +117,11 @@ type Document struct {
|
||||
Body string `json:"body"`
|
||||
AttachmentText string `json:"attachment_text"`
|
||||
SentAtUnixEpoch int64 `json:"sent_at"`
|
||||
// Facettenfelder (SRC-05), optional — leerer String bedeutet "kein Wert".
|
||||
Sender string `json:"sender"`
|
||||
Mailbox string `json:"mailbox"`
|
||||
AttachmentType string `json:"attachment_type"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// Index legt/ersetzt ein Suchdokument (Akzeptanzkriterium 2: Schreibzugriff
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// SRC-05: Facetten- & Filter-API. Nutzt Manticores strukturierte
|
||||
// Aggregations-API (aggs.terms/aggs.range) — keine dynamische
|
||||
// SQL-Klauselbildung, dieselbe Konvention wie Search/Delete (fields.go).
|
||||
package search
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FacetFilter schränkt Suche/Facettenberechnung auf einen bereits
|
||||
// gewählten Facettenwert ein. Field MUSS aus FacetFields stammen —
|
||||
// Facets liefert einen Fehler bei jedem anderen Wert (verhindert einen
|
||||
// beliebigen, vom Aufrufer bestimmten Feldnamen in der Anfrage).
|
||||
type FacetFilter struct {
|
||||
Field string
|
||||
Value string
|
||||
}
|
||||
|
||||
// FacetValue ist ein einzelner Facettenwert mit Trefferzahl
|
||||
// (Akzeptanzkriterium 1).
|
||||
type FacetValue struct {
|
||||
Value string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// DateRangeFacet ist ein Zeitraum-Bucket mit Trefferzahl.
|
||||
type DateRangeFacet struct {
|
||||
Label string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// FacetResult fasst alle Facettendimensionen einer Anfrage zusammen.
|
||||
type FacetResult struct {
|
||||
// Values ist je FacetFields-Eintrag (sender/mailbox/attachment_type/tag)
|
||||
// befüllt.
|
||||
Values map[string][]FacetValue
|
||||
// DateRanges sind feste Zeitraum-Buckets über FieldSentAt.
|
||||
DateRanges []DateRangeFacet
|
||||
}
|
||||
|
||||
// farFuture ist die obere Grenze des jüngsten Zeitraum-Buckets. Manticores
|
||||
// range-Aggregation verlangt für jeden Bucket ein explizites "to" — ein
|
||||
// hinreichend großer fester Wert (Jahr 2100) übernimmt die Rolle von
|
||||
// "unbegrenzt in die Zukunft", ohne den Feldtyp zu wechseln.
|
||||
const farFuture int64 = 4102444800
|
||||
|
||||
type dateRangeBoundary struct {
|
||||
label string
|
||||
from int64 // 0 = ab Epoch (unbegrenzt in die Vergangenheit)
|
||||
to int64
|
||||
}
|
||||
|
||||
// dateRangeBoundaries berechnet die festen Zeitraum-Buckets relativ zu now
|
||||
// (Parameter statt time.Now() direkt, damit Facets testbar bleibt).
|
||||
func dateRangeBoundaries(now time.Time) []dateRangeBoundary {
|
||||
sevenDaysAgo := now.AddDate(0, 0, -7).Unix()
|
||||
thirtyDaysAgo := now.AddDate(0, 0, -30).Unix()
|
||||
oneYearAgo := now.AddDate(-1, 0, 0).Unix()
|
||||
return []dateRangeBoundary{
|
||||
{label: "letzte_7_tage", from: sevenDaysAgo, to: farFuture},
|
||||
{label: "letzte_30_tage", from: thirtyDaysAgo, to: sevenDaysAgo},
|
||||
{label: "letztes_jahr", from: oneYearAgo, to: thirtyDaysAgo},
|
||||
{label: "aelter", from: 0, to: oneYearAgo},
|
||||
}
|
||||
}
|
||||
|
||||
func isFacetField(field string) bool {
|
||||
for _, f := range FacetFields {
|
||||
if f == field {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Facets berechnet für jede Filterdimension (Akzeptanzkriterium 1) die
|
||||
// Trefferzahl je Wert, mandantengetrennt (Akzeptanzkriterium 3) und unter
|
||||
// Berücksichtigung bereits gewählter Filter (Akzeptanzkriterium 2: mehrere
|
||||
// Filter kombinieren sich als UND-Verknüpfung in derselben bool.must-Liste
|
||||
// wie der Tenant-Filter).
|
||||
func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) (FacetResult, error) {
|
||||
must := []map[string]any{
|
||||
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
|
||||
}
|
||||
if queryText != "" {
|
||||
must = append(must, map[string]any{"query_string": queryText})
|
||||
}
|
||||
for _, f := range filters {
|
||||
if !isFacetField(f.Field) {
|
||||
return FacetResult{}, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
|
||||
}
|
||||
must = append(must, map[string]any{"equals": map[string]any{f.Field: f.Value}})
|
||||
}
|
||||
|
||||
aggs := map[string]any{}
|
||||
for _, field := range FacetFields {
|
||||
aggs[field] = map[string]any{"terms": map[string]any{"field": field, "size": 100}}
|
||||
}
|
||||
boundaries := dateRangeBoundaries(time.Now())
|
||||
ranges := make([]map[string]any, 0, len(boundaries))
|
||||
for _, b := range boundaries {
|
||||
ranges = append(ranges, map[string]any{"from": b.from, "to": b.to})
|
||||
}
|
||||
aggs["sent_at"] = map[string]any{"range": map[string]any{"field": FieldSentAt, "ranges": ranges}}
|
||||
|
||||
payload := map[string]any{
|
||||
"index": IndexName,
|
||||
"query": map[string]any{"bool": map[string]any{"must": must}},
|
||||
"aggs": aggs,
|
||||
"limit": 0,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return FacetResult{}, fmt.Errorf("search: facettenanfrage serialisieren: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/search", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return FacetResult{}, fmt.Errorf("search: facettenanfrage bauen: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return FacetResult{}, fmt.Errorf("search: facetten abrufen: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return FacetResult{}, fmt.Errorf("search: facetten-antwort lesen: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return FacetResult{}, fmt.Errorf("search: facetten, status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var parsed facetResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return FacetResult{}, fmt.Errorf("search: facetten-antwort parsen: %w", err)
|
||||
}
|
||||
|
||||
result := FacetResult{Values: make(map[string][]FacetValue, len(FacetFields))}
|
||||
for _, field := range FacetFields {
|
||||
bucket := parsed.Aggregations[field]
|
||||
values := make([]FacetValue, 0, len(bucket.Buckets))
|
||||
for _, b := range bucket.Buckets {
|
||||
if b.Key == "" {
|
||||
continue
|
||||
}
|
||||
values = append(values, FacetValue{Value: b.Key, Count: b.DocCount})
|
||||
}
|
||||
result.Values[field] = values
|
||||
}
|
||||
|
||||
sentAtBucket := parsed.Aggregations["sent_at"]
|
||||
result.DateRanges = make([]DateRangeFacet, 0, len(boundaries))
|
||||
for i, b := range boundaries {
|
||||
count := int64(0)
|
||||
if i < len(sentAtBucket.Buckets) {
|
||||
count = sentAtBucket.Buckets[i].DocCount
|
||||
}
|
||||
result.DateRanges = append(result.DateRanges, DateRangeFacet{Label: b.label, Count: count})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type facetResponse struct {
|
||||
Aggregations map[string]struct {
|
||||
Buckets []struct {
|
||||
Key string `json:"key"`
|
||||
DocCount int64 `json:"doc_count"`
|
||||
} `json:"buckets"`
|
||||
} `json:"aggregations"`
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Integrationstest (SRC-05): echte Manticore-Instanz, TEST_MANTICORE_URL
|
||||
// (gleiche Konvention wie integration_test.go/ranking_test.go).
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func indexFacetDoc(t *testing.T, client *Client, ctx context.Context, tenant string, doc Document) {
|
||||
t.Helper()
|
||||
doc.TenantSlug = tenant
|
||||
doc.ID = DocumentID(tenant, doc.MessageID)
|
||||
if err := client.Index(ctx, doc); err != nil {
|
||||
t.Fatalf("index %s: %v", doc.MessageID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFacets_CountsMatchActualHits ist die geforderte Pflichtprüfung 1:
|
||||
// Facettenzahlen stimmen mit tatsächlicher Treffermenge überein.
|
||||
func TestFacets_CountsMatchActualHits(t *testing.T) {
|
||||
client := setupClient(t)
|
||||
ctx := context.Background()
|
||||
tenant := "mandant-src05-zahlen"
|
||||
|
||||
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-fz-1", Subject: "a", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "pdf"})
|
||||
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-fz-2", Subject: "b", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "docx"})
|
||||
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-fz-3", Subject: "c", Sender: "bob@example.com", Mailbox: "archiv", AttachmentType: "pdf"})
|
||||
|
||||
result, err := client.Facets(ctx, tenant, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("facets: %v", err)
|
||||
}
|
||||
|
||||
senderCounts := toCountMap(result.Values[FieldSender])
|
||||
if senderCounts["alice@example.com"] != 2 {
|
||||
t.Fatalf("erwartete 2 treffer für alice@example.com, habe %d (%+v)", senderCounts["alice@example.com"], result.Values[FieldSender])
|
||||
}
|
||||
if senderCounts["bob@example.com"] != 1 {
|
||||
t.Fatalf("erwartete 1 treffer für bob@example.com, habe %d", senderCounts["bob@example.com"])
|
||||
}
|
||||
|
||||
attachmentCounts := toCountMap(result.Values[FieldAttachmentType])
|
||||
if attachmentCounts["pdf"] != 2 {
|
||||
t.Fatalf("erwartete 2 treffer für pdf, habe %d", attachmentCounts["pdf"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFacets_ThreeFiltersCombineWithAND ist die geforderte Pflichtprüfung
|
||||
// 2: Kombination von drei Filtern liefert korrekt eingeschränkte Treffer.
|
||||
func TestFacets_ThreeFiltersCombineWithAND(t *testing.T) {
|
||||
client := setupClient(t)
|
||||
ctx := context.Background()
|
||||
tenant := "mandant-src05-kombi"
|
||||
|
||||
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-treffer", Subject: "x", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "pdf", Tag: "wichtig"})
|
||||
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-falscher-sender", Subject: "x", Sender: "bob@example.com", Mailbox: "inbox", AttachmentType: "pdf", Tag: "wichtig"})
|
||||
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-falsche-mailbox", Subject: "x", Sender: "alice@example.com", Mailbox: "archiv", AttachmentType: "pdf", Tag: "wichtig"})
|
||||
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-falscher-typ", Subject: "x", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "docx", Tag: "wichtig"})
|
||||
|
||||
filters := []FacetFilter{
|
||||
{Field: FieldSender, Value: "alice@example.com"},
|
||||
{Field: FieldMailbox, Value: "inbox"},
|
||||
{Field: FieldAttachmentType, Value: "pdf"},
|
||||
}
|
||||
result, err := client.Facets(ctx, tenant, "", filters)
|
||||
if err != nil {
|
||||
t.Fatalf("facets: %v", err)
|
||||
}
|
||||
|
||||
tagCounts := toCountMap(result.Values[FieldTag])
|
||||
if tagCounts["wichtig"] != 1 {
|
||||
t.Fatalf("erwartete genau 1 verbleibenden treffer nach 3 UND-verknüpften filtern, habe %d (%+v)", tagCounts["wichtig"], result.Values[FieldTag])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFacets_TenantSeparation ist die geforderte Pflichtprüfung 3:
|
||||
// Facetten eines Mandanten enthalten keine Werte eines anderen.
|
||||
func TestFacets_TenantSeparation(t *testing.T) {
|
||||
client := setupClient(t)
|
||||
ctx := context.Background()
|
||||
tenantA := "mandant-src05-facetten-a"
|
||||
tenantB := "mandant-src05-facetten-b"
|
||||
|
||||
indexFacetDoc(t, client, ctx, tenantA, Document{MessageID: "msg-fa-1", Subject: "a", Sender: "nur-a@example.com", Mailbox: "inbox", AttachmentType: "pdf"})
|
||||
indexFacetDoc(t, client, ctx, tenantB, Document{MessageID: "msg-fb-1", Subject: "b", Sender: "nur-b@example.com", Mailbox: "inbox", AttachmentType: "pdf"})
|
||||
|
||||
resultB, err := client.Facets(ctx, tenantB, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("facets mandant b: %v", err)
|
||||
}
|
||||
senderCountsB := toCountMap(resultB.Values[FieldSender])
|
||||
if _, present := senderCountsB["nur-a@example.com"]; present {
|
||||
t.Fatalf("mandant b sieht facettenwert von mandant a: %+v", resultB.Values[FieldSender])
|
||||
}
|
||||
if senderCountsB["nur-b@example.com"] != 1 {
|
||||
t.Fatalf("erwartete eigenen facettenwert bei mandant b, habe: %+v", resultB.Values[FieldSender])
|
||||
}
|
||||
}
|
||||
|
||||
func toCountMap(values []FacetValue) map[string]int64 {
|
||||
m := make(map[string]int64, len(values))
|
||||
for _, v := range values {
|
||||
m[v.Value] = v.Count
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -24,8 +24,20 @@ const (
|
||||
FieldBody = "body"
|
||||
FieldAttachmentText = "attachment_text"
|
||||
FieldSentAt = "sent_at"
|
||||
// Facettenfelder (SRC-05), nachgezogen über migrations/0002..0005.
|
||||
FieldSender = "sender"
|
||||
FieldMailbox = "mailbox"
|
||||
FieldAttachmentType = "attachment_type"
|
||||
FieldTag = "tag"
|
||||
)
|
||||
|
||||
// FacetFields sind die je Kachel unterstützten Filterdimensionen
|
||||
// (Akzeptanzkriterium 1: Absender, Postfach, Anhangstyp, Tag — Zeitraum
|
||||
// läuft separat über FieldSentAt als Bereichsfacette, siehe facets.go).
|
||||
// Statische Liste — Aufrufer können ausschließlich diese Feldnamen als
|
||||
// Facetten-/Filterdimension angeben, kein beliebiger Client-Feldname.
|
||||
var FacetFields = []string{FieldSender, FieldMailbox, FieldAttachmentType, FieldTag}
|
||||
|
||||
// DocumentID berechnet deterministisch die Manticore-Dokument-ID aus
|
||||
// Mandant und Message-ID (FNV-1a, 64 Bit). Deterministisch statt einer
|
||||
// separat vergebenen ID, damit Re-Indexierung (Index) und Löschung
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE mail_documents ADD COLUMN sender string attribute indexed
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE mail_documents ADD COLUMN mailbox string attribute indexed
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE mail_documents ADD COLUMN attachment_type string attribute indexed
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE mail_documents ADD COLUMN tag string attribute indexed
|
||||
Reference in New Issue
Block a user