SqlTemplate constructor

SqlTemplate(
  1. String source, {
  2. required SqlDialect dialect,
})

Tokenizes a trusted SELECT, WITH, or VALUES source without database access.

Named placeholders bind values; they do not substitute identifiers or arbitrary SQL. Validate SQL and result metadata against the target database before shipping a generated query.

Implementation

factory SqlTemplate(String source, {required SqlDialect dialect}) {
  checkSqlText(source);
  final mysql = dialect == SqlDialect.mysql || dialect == SqlDialect.mariadb;
  final parts = <Object>[], names = <String>{};
  final text = StringBuffer();
  var i = 0, ended = false;
  String? firstWord;
  bool identifier(int code) =>
      code >= 65 && code <= 90 || code >= 97 && code <= 122 || code == 95;
  bool continuation(int code) => identifier(code) || code >= 48 && code <= 57;
  Never fail(String message) =>
      throw OrmException('SQL.TEMPLATE', '$message at offset $i.');
  while (i < source.length) {
    final c = source[i];
    if (c.trim().isEmpty) {
      text.write(c);
      i++;
      continue;
    }
    if (source.startsWith('--', i) &&
            (!mysql ||
                i + 2 == source.length ||
                source.codeUnitAt(i + 2) <= 32) ||
        mysql && c == '#') {
      final end = source.indexOf('\n', i);
      text.write(source.substring(i, end < 0 ? source.length : end));
      i = end < 0 ? source.length : end;
      continue;
    }
    if (source.startsWith('/*', i)) {
      if (mysql &&
          (source.startsWith('/*!', i) || source.startsWith('/*M!', i))) {
        fail('Executable comments are not allowed in named SQL');
      }
      final start = i;
      i += 2;
      var depth = 1;
      while (i < source.length && depth > 0) {
        if (dialect == SqlDialect.postgres && source.startsWith('/*', i)) {
          depth++;
          i += 2;
        } else if (source.startsWith('*/', i)) {
          depth--;
          i += 2;
        } else {
          i++;
        }
      }
      if (depth != 0) fail('Unterminated block comment');
      text.write(source.substring(start, i));
      continue;
    }
    if (ended) fail('Only one SQL statement is allowed');
    if (c == ';') {
      ended = true;
      i++;
      continue;
    }
    if (c == "'" ||
        c == '"' ||
        (dialect == SqlDialect.sqlite || mysql) && c == '`' ||
        dialect == SqlDialect.sqlite && c == '[') {
      final start = i, close = c == '[' ? ']' : c;
      final escapes =
          c == "'" &&
          dialect == SqlDialect.postgres &&
          i > 0 &&
          source[i - 1].toUpperCase() == 'E' &&
          (i < 2 || !continuation(source.codeUnitAt(i - 2)));
      i++;
      var closed = false;
      while (i < source.length) {
        if (source[i] == '\\' && c == "'" && dialect == SqlDialect.postgres) {
          if (!escapes) {
            fail("Use E-strings or dollar quotes for PostgreSQL backslashes");
          }
          i += 2;
        } else if (source[i] == close) {
          i++;
          if (close != ']' && i < source.length && source[i] == close) {
            i++;
          } else {
            closed = true;
            break;
          }
        } else {
          i++;
        }
      }
      if (!closed) fail('Unterminated quote');
      text.write(source.substring(start, i));
      continue;
    }
    if (c == r'$') {
      final tag = dialect == SqlDialect.postgres
          ? RegExp(r'^\$(?:[A-Za-z_][A-Za-z_0-9]*)?\$')
                .firstMatch(source.substring(i))
                ?.group(0)
          : null;
      if (tag == null) fail('Use :name parameters');
      final end = source.indexOf(tag, i + tag.length);
      if (end < 0) fail('Unterminated dollar quote');
      text.write(source.substring(i, end + tag.length));
      i = end + tag.length;
      continue;
    }
    if (c == ':' &&
        (i == 0 || source[i - 1] != ':') &&
        i + 1 < source.length &&
        identifier(source.codeUnitAt(i + 1))) {
      var end = i + 2;
      while (end < source.length && continuation(source.codeUnitAt(end))) {
        end++;
      }
      final name = source.substring(i + 1, end);
      parts.add(text.toString());
      text.clear();
      parts.add(_SqlSlot(name));
      names.add(name);
      i = end;
      continue;
    }
    if (c == '?' && (dialect == SqlDialect.sqlite || mysql) ||
        c == '@' && dialect == SqlDialect.sqlite) {
      fail('Use :name parameters');
    }
    if (c == '\u0001' || c == '\u0002') fail('Reserved control character');
    if (identifier(source.codeUnitAt(i))) {
      var end = i + 1;
      while (end < source.length &&
          (continuation(source.codeUnitAt(end)) ||
              dialect == SqlDialect.postgres && source[end] == r'$')) {
        end++;
      }
      firstWord ??= source.substring(i, end).toUpperCase();
      text.write(source.substring(i, end));
      i = end;
      continue;
    }
    text.write(c);
    i++;
  }
  if (!{'SELECT', 'WITH', 'VALUES'}.contains(firstWord)) {
    fail('A named query requires SELECT, WITH or VALUES');
  }
  parts.add(text.toString());
  return SqlTemplate.internal(
    dialect,
    List.unmodifiable(parts),
    Set.unmodifiable(names),
  );
}