Files
Bridge-and-Join-s/internal/lkgateway/fancyfb.go
T
fontvielle 1d8bb0c032 DEPO Firebird: путь к базе — и строкой подключения Fancy целиком (база на другом компьютере)
Поле пути в мастере и в «Таблицах обмена» принимает строку подключения из настройки Fancy: «сервер:путь»,
«сервер/порт:путь», «\\сервер\D:\путь», «\\сервер\D$\путь», «inet://сервер:порт/путь» — адрес и порт
bj берёт из неё (в мастере поля заполняются сразу при вставке). Сетевая папка «\\сервер\папка\…» — ошибка с
подсказкой: Firebird открывает базу только на своём диске, адрес — компьютер с сервером Firebird.
Проверено мастером на копии базы: пустой адрес + «127.0.0.1/3050:путь» — подготовка и BJ_PING.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-27 19:15:46 +03:00

518 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}