package lkgateway // Fancy DEPO на Firebird (27.10.2026). Fancy работает и на Firebird 2.5 — те же таблицы и процедуры ядра, что в // MSSQL-версии (MSSQL-версия Fancy — перевод Firebird-версии). Весь SQL bj к DEPO написан на T-SQL; чтобы не // держать второй текст в каждом месте вызова, подключение к Firebird переводит запросы само (fbConn): // - точный текст Firebird — для запросов, которые механически не переводятся (fsql.FB, ключ — текст T-SQL); // - остальное механически, вне строк и комментариев: без dbo., TOP n — FIRST n, ISNULL — COALESCE, // GETDATE() — CURRENT_TIMESTAMP, LEN — CHAR_LENGTH, N'…' — '…', «exec имя» — EXECUTE PROCEDURE имя; // - именованные параметры @имя (sql.Named) — позиционные ? в порядке появления в тексте; // - GUID в параметрах — в верхнем регистре (в таблицах обмена Firebird GUID — строка в верхнем регистре, // uniqueidentifier MSSQL сравнивается без учёта регистра); время «2006-01-02T15:04:05» — с пробелом вместо T. // База Fancy на Firebird обычно в диалекте 1 и кодировке NONE с данными в WIN1251 — подключение в WIN1251. // Таблицы, процедуры и права для Firebird ставит depo_setup_fb.go; DDL сюда не попадает. import ( "context" "database/sql/driver" "errors" "fmt" "log" "net/url" "regexp" "strconv" "strings" "sync" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql" "github.com/nakagami/firebirdsql" ) // fbDSN — строка подключения драйвера firebirdsql: путь к файлу базы на сервере Firebird или псевдоним. func fbDSN(s FancyMSSQLSettings, db string) string { q := url.Values{} q.Set("charset", "WIN1251") u := &url.URL{ Scheme: "firebird", User: url.UserPassword(s.User, s.Password), Host: fmt.Sprintf("%s:%d", s.Server, s.DefaultPort()), Path: "/" + db, RawQuery: q.Encode(), } return u.String() } // validFBPath — путь к файлу базы Firebird на сервере (D:\Fancy\DEPO.GDB, /var/db/depo.fdb) или псевдоним // из aliases.conf: без кавычек, точки с запятой и управляющих символов (путь уходит в строку подключения). func validFBPath(s string) bool { if strings.TrimSpace(s) == "" || len(s) > 255 { return false } for _, c := range s { if c < ' ' || strings.ContainsRune(`'";?#`, c) { return false } } return true } // fbIsLoginFailure — Firebird отверг логин или пароль. func fbIsLoginFailure(err error) bool { var fe *firebirdsql.FbError if !errors.As(err, &fe) { return false } for _, c := range fe.GDSCodes { if c == firebirdsql.ISCLogin || c == firebirdsql.ISCLoginError { return true } } return false } // fbConnector — подключения к Firebird с переводом запросов. type fbConnector struct{ base driver.Connector } func (c fbConnector) Driver() driver.Driver { return c.base.Driver() } func (c fbConnector) Connect(ctx context.Context) (driver.Conn, error) { conn, err := c.base.Connect(ctx) if err != nil { return nil, err } return &fbConn{c: conn}, nil } // fbConn — подключение драйвера firebirdsql, которому database/sql отдаёт T-SQL bj: текст и параметры // переводятся в fbRewrite. Остальные методы — как у драйвера. type fbConn struct{ c driver.Conn } func (f *fbConn) Prepare(query string) (driver.Stmt, error) { return f.PrepareContext(context.Background(), query) } func (f *fbConn) PrepareContext(ctx context.Context, query string) (driver.Stmt, error) { q, names := query, []string(nil) if !fbNative(query) { q, names = fbParams(fbSQL(query)) } var st driver.Stmt var err error if pc, ok := f.c.(driver.ConnPrepareContext); ok { st, err = pc.PrepareContext(ctx, q) } else { st, err = f.c.Prepare(q) } if err != nil { return nil, fbErr(query, q, err) } return &fbStmt{st: st, names: names}, nil } func (f *fbConn) Close() error { return f.c.Close() } func (f *fbConn) Begin() (driver.Tx, error) { return f.BeginTx(context.Background(), driver.TxOptions{}) } func (f *fbConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) { if bt, ok := f.c.(driver.ConnBeginTx); ok { return bt.BeginTx(ctx, opts) } return f.c.Begin() //nolint:staticcheck // у драйвера без ConnBeginTx другого способа нет } func (f *fbConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { ex, ok := f.c.(driver.ExecerContext) if !ok { return nil, driver.ErrSkip } q, a, err := fbRewrite(query, args) if err != nil { return nil, err } res, err := ex.ExecContext(ctx, q, a) return res, fbErr(query, q, err) } func (f *fbConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { qr, ok := f.c.(driver.QueryerContext) if !ok { return nil, driver.ErrSkip } q, a, err := fbRewrite(query, args) if err != nil { return nil, err } rows, err := qr.QueryContext(ctx, q, a) return rows, fbErr(query, q, err) } func (f *fbConn) Ping(ctx context.Context) error { if p, ok := f.c.(driver.Pinger); ok { return p.Ping(ctx) } return nil } func (f *fbConn) CheckNamedValue(nv *driver.NamedValue) error { if c, ok := f.c.(driver.NamedValueChecker); ok { return c.CheckNamedValue(nv) } return driver.ErrSkip } func (f *fbConn) IsValid() bool { if v, ok := f.c.(driver.Validator); ok { return v.IsValid() } return true } func (f *fbConn) ResetSession(ctx context.Context) error { if r, ok := f.c.(driver.SessionResetter); ok { return r.ResetSession(ctx) } return nil } // fbStmt — подготовленный запрос: параметры при выполнении расставляются по именам из текста. type fbStmt struct { st driver.Stmt names []string } func (s *fbStmt) Close() error { return s.st.Close() } func (s *fbStmt) NumInput() int { return -1 } func (s *fbStmt) Exec(args []driver.Value) (driver.Result, error) { return s.ExecContext(context.Background(), fbValues(args)) } func (s *fbStmt) Query(args []driver.Value) (driver.Rows, error) { return s.QueryContext(context.Background(), fbValues(args)) } func (s *fbStmt) ExecContext(ctx context.Context, args []driver.NamedValue) (driver.Result, error) { a, err := fbArgs(s.names, args) if err != nil { return nil, err } if ex, ok := s.st.(driver.StmtExecContext); ok { return ex.ExecContext(ctx, a) } return s.st.Exec(fbPlain(a)) //nolint:staticcheck // драйвер без StmtExecContext } func (s *fbStmt) QueryContext(ctx context.Context, args []driver.NamedValue) (driver.Rows, error) { a, err := fbArgs(s.names, args) if err != nil { return nil, err } if q, ok := s.st.(driver.StmtQueryContext); ok { return q.QueryContext(ctx, a) } return s.st.Query(fbPlain(a)) //nolint:staticcheck // драйвер без StmtQueryContext } func fbValues(v []driver.Value) []driver.NamedValue { out := make([]driver.NamedValue, len(v)) for i, x := range v { out[i] = driver.NamedValue{Ordinal: i + 1, Value: x} } return out } func fbPlain(a []driver.NamedValue) []driver.Value { out := make([]driver.Value, len(a)) for i, x := range a { out[i] = x.Value } return out } // fbRewrite — текст и параметры запроса T-SQL для Firebird. DDL и блоки PSQL (установка, depo_setup_fb.go) // написаны для Firebird — уходят как есть. func fbRewrite(query string, args []driver.NamedValue) (string, []driver.NamedValue, error) { if fbNative(query) { return query, args, nil } q, names := fbParams(fbSQL(query)) a, err := fbArgs(names, args) return q, a, err } var fbNativeRe = regexp.MustCompile(`(?i)^\s*(CREATE|ALTER|RECREATE|DROP|GRANT|REVOKE|EXECUTE\s+BLOCK|COMMENT\s+ON)\b`) // fbNative — команда уже на Firebird (установка таблиц, процедур, прав). func fbNative(q string) bool { return fbNativeRe.MatchString(fbStripComments(q)) } var ( fbExecRe = regexp.MustCompile(`(?is)^\s*exec(?:ute)?\s+(?:\[?dbo\]?\.)?\[?([A-Za-z0-9_]+)\]?\s*;?\s*$`) fbObjectIDRe = regexp.MustCompile(`(?i)^SELECT OBJECT_ID\('(?:dbo\.)?([A-Za-z0-9_]+)'\)$`) fbDboRe = regexp.MustCompile(`(?i)\[?\bdbo\]?\.`) fbIsNullRe = regexp.MustCompile(`(?i)\bISNULL\s*\(`) fbGetDateRe = regexp.MustCompile(`(?i)\bGETDATE\s*\(\s*\)`) fbLenRe = regexp.MustCompile(`(?i)\bLEN\s*\(`) fbTopRe = regexp.MustCompile(`(?i)\bSELECT(\s+DISTINCT)?\s+TOP\s*(\(\s*@?\w+\s*\)|\d+)`) fbGUIDRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) fbISOTimeRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?$`) ) // fbSQL — текст запроса для Firebird: точный перевод из fsql.FB, иначе механический. func fbSQL(q string) string { if t, ok := fsql.FB[q]; ok { return t } if m := fbExecRe.FindStringSubmatch(q); m != nil { return "EXECUTE PROCEDURE " + m[1] } if m := fbObjectIDRe.FindStringSubmatch(q); m != nil { // depoTableExists: NULL — таблицы нет return "SELECT (SELECT FIRST 1 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = '" + strings.ToUpper(m[1]) + "') FROM RDB$DATABASE" } return fbOutsideLiterals(q, func(code string) string { code = fbDboRe.ReplaceAllString(code, "") code = fbIsNullRe.ReplaceAllString(code, "COALESCE(") code = fbGetDateRe.ReplaceAllString(code, "CURRENT_TIMESTAMP") code = fbLenRe.ReplaceAllString(code, "CHAR_LENGTH(") return fbTopRe.ReplaceAllStringFunc(code, func(s string) string { m := fbTopRe.FindStringSubmatch(s) return "SELECT FIRST " + m[2] + m[1] // в Firebird FIRST — до DISTINCT }) }) } // fbOutsideLiterals — замены только в коде: строки, имена в кавычках и комментарии не трогаются; N перед // строкой (N'…' — национальная строка T-SQL) убирается. func fbOutsideLiterals(q string, fix func(string) string) string { var b, code strings.Builder emit := func(lit string) { c := code.String() code.Reset() if strings.HasPrefix(lit, "'") && (strings.HasSuffix(c, "N") || strings.HasSuffix(c, "n")) && (len(c) == 1 || !fbIdentChar(c[len(c)-2])) { c = c[:len(c)-1] } b.WriteString(fix(c)) b.WriteString(lit) } for i := 0; i < len(q); i++ { c := q[i] switch { case c == '\'' || c == '"': j := i + 1 for j < len(q) { if q[j] == c { if j+1 < len(q) && q[j+1] == c { j += 2 continue } break } j++ } if j >= len(q) { j = len(q) - 1 } emit(q[i : j+1]) i = j case c == '-' && i+1 < len(q) && q[i+1] == '-': j := strings.IndexByte(q[i:], '\n') if j < 0 { j = len(q) - i } emit(q[i : i+j]) i += j - 1 case c == '/' && i+1 < len(q) && q[i+1] == '*': j := strings.Index(q[i+2:], "*/") end := len(q) if j >= 0 { end = i + 2 + j + 2 } emit(q[i:end]) i = end - 1 default: code.WriteByte(c) } } b.WriteString(fix(code.String())) return b.String() } // fbParams — @имя вне строк и комментариев заменяются на ?; возвращает имена по порядку (повторы — повторно). func fbParams(q string) (string, []string) { if !strings.Contains(q, "@") { return q, nil } var b strings.Builder var names []string for i := 0; i < len(q); i++ { c := q[i] switch { case c == '\'' || c == '"': // строка или имя в кавычках — как есть до закрывающей j := i + 1 for j < len(q) { if q[j] == c { if j+1 < len(q) && q[j+1] == c { j += 2 continue } break } j++ } if j >= len(q) { j = len(q) - 1 } b.WriteString(q[i : j+1]) i = j case c == '-' && i+1 < len(q) && q[i+1] == '-': j := strings.IndexByte(q[i:], '\n') if j < 0 { j = len(q) - i } b.WriteString(q[i : i+j]) i += j - 1 case c == '/' && i+1 < len(q) && q[i+1] == '*': j := strings.Index(q[i+2:], "*/") end := len(q) if j >= 0 { end = i + 2 + j + 2 } b.WriteString(q[i:end]) i = end - 1 case c == '@' && i+1 < len(q) && fbIdentStart(q[i+1]): j := i + 1 for j < len(q) && fbIdentChar(q[j]) { j++ } names = append(names, q[i+1:j]) b.WriteByte('?') i = j - 1 default: b.WriteByte(c) } } return b.String(), names } func fbIdentStart(c byte) bool { return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' } func fbIdentChar(c byte) bool { return fbIdentStart(c) || c >= '0' && c <= '9' } // fbArgs — значения для ? по именам из текста: sql.Named — по имени, @p1…@pN без имени — по номеру. // Без имён в тексте параметры передаются как есть (запрос уже с ?). func fbArgs(names []string, args []driver.NamedValue) ([]driver.NamedValue, error) { if len(names) == 0 { out := make([]driver.NamedValue, len(args)) for i, a := range args { out[i] = driver.NamedValue{Ordinal: i + 1, Value: fbValue(a.Value)} } return out, nil } out := make([]driver.NamedValue, 0, len(names)) for _, n := range names { v, ok := fbArg(n, args) if !ok { return nil, fmt.Errorf("параметр @%s не передан", n) } out = append(out, driver.NamedValue{Ordinal: len(out) + 1, Value: fbValue(v)}) } return out, nil } func fbArg(name string, args []driver.NamedValue) (driver.Value, bool) { for _, a := range args { if a.Name != "" && strings.EqualFold(a.Name, name) { return a.Value, true } } if len(name) > 1 && (name[0] == 'p' || name[0] == 'P') { if n, err := strconv.Atoi(name[1:]); err == nil && n >= 1 { for _, a := range args { if a.Name == "" && a.Ordinal == n { return a.Value, true } } } } return nil, false } // fbValue — GUID в верхнем регистре, время ISO 8601 — с пробелом вместо T (Firebird «T» не разбирает). func fbValue(v driver.Value) driver.Value { s, ok := v.(string) if !ok { return v } switch { case fbGUIDRe.MatchString(s): return strings.ToUpper(s) case fbISOTimeRe.MatchString(s): return strings.Replace(s, "T", " ", 1) } return v } var fbErrLogged sync.Map // fbErr — ошибка Firebird; для разбора в журнал (раз на запрос) — исходный текст T-SQL и что ушло в Firebird. func fbErr(orig, sent string, err error) error { if err == nil { return nil } var fe *firebirdsql.FbError if errors.As(err, &fe) && fe.SQLCode == -104 { // синтаксис: перевод запроса не подошёл if _, seen := fbErrLogged.LoadOrStore(orig, true); !seen { log.Printf("depo-fb: запрос не принят Firebird: %v\n T-SQL: %s\n Firebird: %s", err, depoStmtHead(orig), sent) } } return err } var ( fbDriveRe = regexp.MustCompile(`^[A-Za-z]:[\\/]`) fbHostRe = regexp.MustCompile(`^([A-Za-z0-9._\-]{2,}|\[[0-9A-Fa-f:]+\])(?:/(\d{1,5}))?:(.+)$`) fbUNCRe = regexp.MustCompile(`^\\\\([^\\/]+)[\\/](.+)$`) fbAdmShRe = regexp.MustCompile(`^([A-Za-z])\$[\\/](.*)$`) fbInetURLRe = regexp.MustCompile(`(?i)^inet[46]?://([^/:]+)(?::(\d{1,5}))?/(.+)$`) ) // fbSplitConn — строка подключения Fancy к базе Firebird целиком: «сервер:путь», «сервер/порт:путь», // «\\сервер\D:\путь», «\\сервер\D$\путь», «inet://сервер:порт/путь». База обычно на другом компьютере — на // том, где работает сервер Firebird; bj подключается к нему по сети, а путь — на диске этого компьютера. // host="" — указан только путь (сервер — в отдельном поле). Сетевую папку (\\сервер\папка\…) Firebird не // открывает — ошибка с подсказкой. func fbSplitConn(s string) (host string, port int, path string, err error) { s = strings.TrimSpace(s) switch { case fbDriveRe.MatchString(s) || strings.HasPrefix(s, "/") || !strings.ContainsAny(s, `:\/`): return "", 0, s, nil // путь на сервере или псевдоним case fbInetURLRe.MatchString(s): m := fbInetURLRe.FindStringSubmatch(s) port, _ = strconv.Atoi(m[2]) path = m[3] if !fbDriveRe.MatchString(path) && !strings.ContainsAny(path, `\/`) { // inet://host/alias return m[1], port, path, nil } if !fbDriveRe.MatchString(path) { path = "/" + path } return m[1], port, path, nil case fbUNCRe.MatchString(s): m := fbUNCRe.FindStringSubmatch(s) rest := m[2] if fbDriveRe.MatchString(rest) { // \\сервер\D:\путь — так Fancy пишет подключение по именованным каналам return m[1], 0, rest, nil } if a := fbAdmShRe.FindStringSubmatch(rest); a != nil { // \\сервер\D$\путь — административный общий ресурс return m[1], 0, strings.ToUpper(a[1]) + `:\` + a[2], nil } return "", 0, "", fmt.Errorf("%s — сетевая папка: Firebird не открывает базы по сети как файл. Укажите компьютер %s в поле «Адрес сервера» (на нём должен работать сервер Firebird), а путь — на его диске, например D:\\Fancy\\DEPO.GDB, — как в подключении Fancy", s, m[1]) case fbHostRe.MatchString(s): m := fbHostRe.FindStringSubmatch(s) port, _ = strconv.Atoi(m[2]) return strings.Trim(m[1], "[]"), port, m[3], nil } return "", 0, s, nil }