Files
Bridge-and-Join-s/internal/lkgateway/fancyfb.go
T
fontvielle 312e4f70f1 ЛК на Fancy с Firebird: все запросы к DEPO, курсы, база SPECTRE
ЛК (bj-lk берёт данные у bj-server) сам работает с той СУБД, что выбрана для DEPO.
- Перевод на Firebird: YEAR/MONTH/DAY(x) — EXTRACT, AS float — AS DOUBLE PRECISION (float T-SQL — 8 байт,
  FLOAT Firebird — 4: точность сумм и цен в ЛК); шаблоны со сложением строк (история операций Hist02, поиск
  клиента Man01) — Firebird-тексты до подстановки (depoSQL); UPPER по кириллице — в WIN1251 (база NONE).
- Курсы валют (Fx01): на Firebird запрос от всех курсов шёл больше 200 с (лимит 15 с — курсов нет) — для
  Firebird от 20 валют, 0,2 с.
- База SPECTRE (архив портфеля и операций ЛК): поле в «Трансферные таблицы DEPO»; у Firebird — путь или строка
  подключения Fancy (может быть на другом компьютере); не задана — ЛК пишет, где указать. На данных SPECTRE
  не проверено: базы нет.
- Тест FBLiveLK: функции ЛК, ручного ввода, сверки переводов и QUIK на копии базы по договору К1 Бубликова —
  все работают, 0,07–0,26 с каждая; FBLivePrepare — 101 из 101 запроса.

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

535 lines
19 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, YEAR(x) — EXTRACT(YEAR FROM x), AS float — AS DOUBLE
// PRECISION (float T-SQL — 8 байт, FLOAT Firebird — 4), N'…' — '…', «exec имя» — EXECUTE PROCEDURE имя;
// - шаблоны (fmt, {ACC}) с тем, что механически не переводится, выбираются до подстановки — depoSQL;
// - именованные параметры @имя (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*\(`)
fbDatePartRe = regexp.MustCompile(`(?i)\b(YEAR|MONTH|DAY)\s*\(\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\)`)
fbFloatRe = regexp.MustCompile(`(?i)\bAS\s+float\b`)
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+)?)?$`)
)
// depoSQL — текст запроса-шаблона для СУБД базы DEPO: для Firebird — перевод из fsql.FB, если он есть (шаблон с
// fmt-подстановкой или {ACC} подключение к Firebird по тексту уже не узнает). Остальное переводит fbConn.
func depoSQL(f FancyMSSQLSettings, q string) string {
if f.IsFirebird() {
if t, ok := fsql.FB[q]; ok {
return t
}
}
return q
}
// 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(")
code = fbDatePartRe.ReplaceAllString(code, "EXTRACT($1 FROM $2)")
code = fbFloatRe.ReplaceAllString(code, "AS DOUBLE PRECISION") // float T-SQL — 8 байт, FLOAT Firebird — 4
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
}