Repository: jazzband/tablib
Pull request #619 (2025-04-26 16:20:11)

Add sql export format
Hi! I've added .sql to the export formats and added tests

This is useful especially with the records library https://pypi.org/project/records/ which uses this library to create exports.

examples:
```python
import tablib

with open('tests/files/founders.xlsx', 'rb') as f:
    data = tablib.Dataset().load(f, 'xlsx')

sql_output = data.export('sql', table='founders')
print(sql_output) 

"""
INSERT INTO "founders" VALUES ('John', 'Adams', 90);
INSERT INTO "founders" VALUES ('George', 'Washington', 67);
INSERT INTO "founders" VALUES ('Thomas', 'Jefferson', 50);
"""

ds = tablib.Dataset(title='tbl')
ds.headers = ['col_date', 'col_timestamp']
ds.append([
    dt.date(2020, 1, 2),
    dt.datetime(2020, 1, 2, 3, 4, 5)
])
sql = ds.export('sql')
print(sql)

"""
INSERT INTO "tbl" VALUES (DATE '2020-01-02', TIMESTAMP '2020-01-02 03:04:05');
"""
```


claudep: @hugovk Could you quickly make a final review?

Patch:
diff --git a/.flake8 b/.flake8
deleted file mode 100644
index d6f9d5b..0000000
--- a/.flake8
+++ /dev/null
@@ -1,5 +0,0 @@
-[flake8]
-max_line_length = 99
-exclude =
-    .tox
-    *env/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1d900ae..de842fd 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,19 +1,9 @@
 repos:
-  - repo: https://github.com/asottile/pyupgrade
-    rev: v3.19.1
+  - repo: https://github.com/astral-sh/ruff-pre-commit
+    rev: v0.11.6
     hooks:
-      - id: pyupgrade
-        args: [--py39-plus]
-
-  - repo: https://github.com/PyCQA/isort
-    rev: 6.0.1
-    hooks:
-      - id: isort
-
-  - repo: https://github.com/PyCQA/flake8
-    rev: 7.2.0
-    hooks:
-      - id: flake8
+      - id: ruff
+        args: [--exit-non-zero-on-fix]
 
   - repo: https://github.com/isidentical/teyit
     rev: 0.4.3
@@ -23,7 +13,6 @@ repos:
   - repo: https://github.com/pre-commit/pygrep-hooks
     rev: v1.10.0
     hooks:
-      - id: python-check-blanket-noqa
       - id: rst-backticks
 
   - repo: https://github.com/pre-commit/pre-commit-hooks
diff --git a/AUTHORS b/AUTHORS
index d6e7c3c..45aa172 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -33,6 +33,7 @@ Here is a list of past and present much-appreciated contributors:
     Mike Waldner
     Peyman Salehi
     Rabin Nankhwa
+    Tak Hogan
     Tommy Anthony
     Tsuyoshi Hombashi
     Tushar Makkar
diff --git a/README.md b/README.md
index c57fe57..f85770f 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@ Output formats supported:
 - ODS (Sets)
 - CSV (Sets)
 - DBF (Sets)
+- SQL (Sets)
 
 Note that tablib *purposefully* excludes XML support. It always will. (Note: This is a
 joke. Pull requests are welcome.)
diff --git a/docs/formats.rst b/docs/formats.rst
index ce546dd..a9bd8e5 100644
--- a/docs/formats.rst
+++ b/docs/formats.rst
@@ -300,3 +300,41 @@ This format is optional, install Tablib with ``pip install "tablib[yaml]"`` to
 make the format available.
 
 .. _YAML: https://yaml.org
+
+sql
+===
+
+.. versionadded:: 3.9.0
+
+The ``sql`` format is export-only. It produces SQL INSERT statements (one per row)
+assuming the target table already exists with the same columns.
+The table name can be passed as an argument or will be taken from the dataset's title (or defaults to ``export_table``).
+Columns can be passed as an argument or will be taken from the dataset's headers.
+Values are rendered as ANSI SQL literals.
+Additionally the argument ``commit`` can be passed to add a ``COMMIT;`` statement at the end.
+
+- ``NULL`` for null values
+- ``TRUE``/``FALSE`` for booleans
+- ``DATE 'YYYY-MM-DD'`` for date values
+- ``TIMESTAMP 'YYYY-MM-DD HH:MM:SS'`` for timestamp values
+- Numeric literals for ints/floats/decimals
+- Single-quoted strings with embedded quotes escaped
+
+Example::
+
+    import datetime
+    from tablib import Dataset
+
+    data = Dataset(title='users')
+    data.headers = ['id', 'name', 'joined']
+    data.append([1, 'Alice', datetime.date(2021,1,1)])
+
+    print(data.export('sql'))
+    print(data.export('sql', table='\"User_Updates\"', columns=['id', 'username', 'update_date'], commit=True))
+
+Output::
+
+    INSERT INTO users (id,name,joined) VALUES (1, 'Alice', DATE '2021-01-01');
+
+    INSERT INTO "User_Updates" (id,username,update_date) VALUES (1, 'Alice', DATE '2021-01-01');
+    COMMIT;
diff --git a/pyproject.toml b/pyproject.toml
index c50880d..1f442c6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,6 +29,7 @@ classifiers = [
     "Programming Language :: Python :: 3.11",
     "Programming Language :: Python :: 3.12",
     "Programming Language :: Python :: 3.13",
+    "Programming Language :: Python :: 3.14",
 ]
 dynamic = ["version"]
 
@@ -59,5 +60,31 @@ changelog = "https://github.com/jazzband/tablib/blob/master/HISTORY.md"
 [tool.setuptools_scm]
 write_to = "src/tablib/_version.py"
 
-[tool.isort]
-profile = "black"
+[tool.ruff]
+fix = true
+line-length = 99
+
+lint.select = [
+  "C4",     # flake8-comprehensions
+  "E",      # pycodestyle errors
+  "F",      # pyflakes
+  "I",      # isort
+  "ISC",    # flake8-implicit-str-concat
+  "PGH",    # pygrep-hooks
+  "PIE",    # flake8-pie
+  "RUF022", # unsorted-dunder-all
+  "RUF100", # unused noqa (yesqa)
+  "UP",     # pyupgrade
+  "W",      # pycodestyle warnings
+  "YTT",    # flake8-2020
+]
+lint.ignore = [
+  "E203",   # Whitespace before ':'
+  "E221",   # Multiple spaces before operator
+  "E226",   # Missing whitespace around arithmetic operator
+  "E241",   # Multiple spaces after ','
+  "E741",   # Ambiguous variable name
+  "PIE790", # Unnecessary pass statement
+  "UP038",  # Makes code slower and more verbose
+]
+lint.isort.known-first-party = [ "tablib" ]
diff --git a/src/tablib/_vendor/dbfpy/dbf.py b/src/tablib/_vendor/dbfpy/dbf.py
index abf7209..197a185 100644
--- a/src/tablib/_vendor/dbfpy/dbf.py
+++ b/src/tablib/_vendor/dbfpy/dbf.py
@@ -233,7 +233,7 @@ class Dbf:
     # 'magic' methods (representation and sequence interface)
 
     def __repr__(self):
-        return "Dbf stream '%s'\n" % self.stream + repr(self.header)
+        return f"Dbf stream '{self.stream}'\n" + repr(self.header)
 
     def __len__(self):
         """Return number of records."""
diff --git a/src/tablib/_vendor/dbfpy/fields.py b/src/tablib/_vendor/dbfpy/fields.py
index 9726d95..d91f050 100644
--- a/src/tablib/_vendor/dbfpy/fields.py
+++ b/src/tablib/_vendor/dbfpy/fields.py
@@ -81,14 +81,14 @@ class DbfFieldDef:
         assert self.defaultValue is not None, "Default value must be overridden"
         # fix arguments
         if len(name) > 10:
-            raise ValueError("Field name \"%s\" is too long" % name)
+            raise ValueError(f"Field name \"{name}\" is too long")
         name = str(name).upper()
         if self.__class__.length is None:
             if length is None:
-                raise ValueError("[%s] Length isn't specified" % name)
+                raise ValueError(f"[{name}] Length isn't specified")
             length = int(length)
             if length <= 0:
-                raise ValueError("[%s] Length must be a positive integer" % name)
+                raise ValueError(f"[{name}] Length must be a positive integer")
         else:
             length = self.length
         if decimalCount is None:
@@ -154,7 +154,7 @@ class DbfFieldDef:
         )
 
     def __repr__(self):
-        return "%-10s %1s %3d %3d" % self.fieldInfo()
+        return "%-10s %1s %3d %3d" % self.fieldInfo()  # noqa: UP031
 
     def fieldInfo(self):
         """Return field information.
@@ -250,7 +250,7 @@ class DbfNumericFieldDef(DbfFieldDef):
 
     def encodeValue(self, value):
         """Return string containing encoded ``value``."""
-        _rv = ("%*.*f" % (self.length, self.decimalCount, value))
+        _rv = ("%*.*f" % (self.length, self.decimalCount, value))  # noqa: UP031
         if len(_rv) > self.length:
             _ppos = _rv.find(".")
             if 0 <= _ppos <= self.length:
diff --git a/src/tablib/_vendor/dbfpy/header.py b/src/tablib/_vendor/dbfpy/header.py
index 3da49b8..4032918 100644
--- a/src/tablib/_vendor/dbfpy/header.py
+++ b/src/tablib/_vendor/dbfpy/header.py
@@ -169,7 +169,7 @@ Version (signature): 0x%02x
         _rv = _rv % (self.signature, self.lastUpdate, self.headerLength,
                      self.recordLength, self.recordCount)
         _rv += "\n".join(
-            ["%10s %4s %3s %3s" % _fld.fieldInfo() for _fld in self.fields]
+            ["%10s %4s %3s %3s" % _fld.fieldInfo() for _fld in self.fields]  # noqa: UP031
         )
         return _rv
 
diff --git a/src/tablib/_vendor/dbfpy/record.py b/src/tablib/_vendor/dbfpy/record.py
index 2d6e820..9ac6c40 100644
--- a/src/tablib/_vendor/dbfpy/record.py
+++ b/src/tablib/_vendor/dbfpy/record.py
@@ -147,7 +147,7 @@ class DbfRecord:
     # object representation
 
     def __repr__(self):
-        _template = "%%%ds: %%s (%%s)" % max(len(_fld) for _fld in self.dbf.fieldNames)
+        _template = "%%%ds: %%s (%%s)" % max(len(_fld) for _fld in self.dbf.fieldNames)  # noqa: UP031
         _rv = []
         for _fld in self.dbf.fieldNames:
             _val = self[_fld]
@@ -193,10 +193,11 @@ class DbfRecord:
             if not allowUndefined:
                 raise ValueError("Index is undefined")
         elif self.index < 0:
-            raise ValueError("Index can't be negative (%s)" % self.index)
+            raise ValueError(f"Index can't be negative ({self.index})")
         elif checkRange and self.index <= self.dbf.header.recordCount:
-            raise ValueError("There are only %d records in the DBF" %
-                             self.dbf.header.recordCount)
+            raise ValueError(
+                f"There are only {self.dbf.header.recordCount} records in the DBF"
+            )
 
     # interface methods
 
@@ -242,7 +243,7 @@ class DbfRecord:
             real values stored in this object.
 
         """
-        return dict([_i for _i in zip(self.dbf.fieldNames, self.fieldData)])
+        return dict(list(zip(self.dbf.fieldNames, self.fieldData)))
 
     def __getitem__(self, key):
         """Return value by field name or field index."""
diff --git a/src/tablib/_vendor/dbfpy/utils.py b/src/tablib/_vendor/dbfpy/utils.py
index 46b626a..c20cec1 100644
--- a/src/tablib/_vendor/dbfpy/utils.py
+++ b/src/tablib/_vendor/dbfpy/utils.py
@@ -144,7 +144,7 @@ class _InvalidValue:
         return not other
 
     def __ne__(self, other):
-        return not (other is self)
+        return other is not self
 
     def __bool__(self):
         return False
diff --git a/src/tablib/core.py b/src/tablib/core.py
index 33655e4..3e13b1f 100644
--- a/src/tablib/core.py
+++ b/src/tablib/core.py
@@ -148,7 +148,7 @@ class Dataset:
     """
 
     def __init__(self, *args, **kwargs):
-        self._data = list(Row(arg) for arg in args)
+        self._data = [Row(arg) for arg in args]
         self.__headers = None
 
         # ('title', index) tuples
@@ -206,7 +206,7 @@ class Dataset:
 
     def __repr__(self):
         try:
-            return '<%s dataset>' % (self.title.lower())
+            return f'<{self.title.lower()} dataset>'
         except AttributeError:
             return '<dataset object>'
 
@@ -227,7 +227,7 @@ class Dataset:
         if self.__headers:
             result.insert(1, ['-' * length for length in field_lens])
 
-        format_string = '|'.join('{%s:%s}' % item for item in enumerate(field_lens))
+        format_string = '|'.join('{{{}:{}}}'.format(*item) for item in enumerate(field_lens))
 
         return '\n'.join(format_string.format(*row) for row in result)
 
@@ -712,7 +712,7 @@ class Dataset:
         _dset = Dataset()
 
         # Add columns as rows in new instance
-        for index in range(0, len(self._data[0])):
+        for index in range(len(self._data[0])):
             row_data = self.get_col(index)
             _dset.append(row=row_data)
 
@@ -747,8 +747,8 @@ class Dataset:
         # Copy the source data
         _dset = copy(self)
 
-        rows_to_stack = [row for row in _dset._data]
-        other_rows = [row for row in other._data]
+        rows_to_stack = list(_dset._data)
+        other_rows = list(other._data)
 
         rows_to_stack.extend(other_rows)
         _dset._data = rows_to_stack
@@ -798,7 +798,7 @@ class Dataset:
 
     def wipe(self):
         """Removes all content and headers from the :class:`Dataset` object."""
-        self._data = list()
+        self._data = []
         self.__headers = None
 
     def subset(self, rows=None, cols=None):
@@ -850,7 +850,7 @@ class Databook:
 
     def __repr__(self):
         try:
-            return '<%s databook>' % (self.title.lower())
+            return f'<{self.title.lower()} databook>'
         except AttributeError:
             return '<databook object>'
 
diff --git a/src/tablib/formats/__init__.py b/src/tablib/formats/__init__.py
index 501d771..ebcc64f 100644
--- a/src/tablib/formats/__init__.py
+++ b/src/tablib/formats/__init__.py
@@ -76,10 +76,10 @@ class Registry:
         # and Dataset.get_<format>/set_<format> methods.
         setattr(Dataset, key, ImportExportSetDescriptor(key, format_or_path))
         try:
-            setattr(Dataset, 'get_%s' % key, partialmethod(Dataset._get_in_format, key))
-            setattr(Dataset, 'set_%s' % key, partialmethod(Dataset._set_in_format, key))
+            setattr(Dataset, f'get_{key}', partialmethod(Dataset._get_in_format, key))
+            setattr(Dataset, f'set_{key}', partialmethod(Dataset._set_in_format, key))
         except AttributeError:
-            setattr(Dataset, 'get_%s' % key, partialmethod(Dataset._get_in_format, key))
+            setattr(Dataset, f'get_{key}', partialmethod(Dataset._get_in_format, key))
 
         self._formats[key] = format_or_path
 
@@ -104,6 +104,7 @@ class Registry:
         if find_spec('pandas'):
             self.register('df', 'tablib.formats._df.DataFrameFormat')
         self.register('rst', 'tablib.formats._rst.ReSTFormat')
+        self.register('sql', 'tablib.formats._sql.SQLFormat')
         if find_spec('tabulate'):
             self.register('cli', 'tablib.formats._cli.CLIFormat')
 
@@ -122,7 +123,7 @@ class Registry:
                         **uninstalled_format_messages[key], key=key
                     )
                 )
-            raise UnsupportedFormat("Tablib has no format '%s' or it is not registered." % key)
+            raise UnsupportedFormat(f"Tablib has no format '{key}' or it is not registered.")
         if isinstance(self._formats[key], str):
             self._formats[key] = load_format_class(self._formats[key])
         return self._formats[key]
diff --git a/src/tablib/formats/_latex.py b/src/tablib/formats/_latex.py
index 9b16fbe..67b5b00 100644
--- a/src/tablib/formats/_latex.py
+++ b/src/tablib/formats/_latex.py
@@ -25,21 +25,21 @@ class LATEXFormat:
 \\end{table}
 """
 
-    TEX_RESERVED_SYMBOLS_MAP = dict([
-        ('\\', '\\textbackslash{}'),
-        ('{', '\\{'),
-        ('}', '\\}'),
-        ('$', '\\$'),
-        ('&', '\\&'),
-        ('#', '\\#'),
-        ('^', '\\textasciicircum{}'),
-        ('_', '\\_'),
-        ('~', '\\textasciitilde{}'),
-        ('%', '\\%'),
-    ])
+    TEX_RESERVED_SYMBOLS_MAP = {
+        '\\': '\\textbackslash{}',
+        '{': '\\{',
+        '}': '\\}',
+        '$': '\\$',
+        '&': '\\&',
+        '#': '\\#',
+        '^': '\\textasciicircum{}',
+        '_': '\\_',
+        '~': '\\textasciitilde{}',
+        '%': '\\%',
+    }
 
     TEX_RESERVED_SYMBOLS_RE = re.compile(
-        '(%s)' % '|'.join(map(re.escape, TEX_RESERVED_SYMBOLS_MAP.keys())))
+        '({})'.format('|'.join(map(re.escape, TEX_RESERVED_SYMBOLS_MAP.keys()))))
 
     @classmethod
     def export_set(cls, dataset):
@@ -49,13 +49,13 @@ class LATEXFormat:
         :type dataset: tablib.core.Dataset
         """
 
-        caption = '\\caption{%s}' % dataset.title if dataset.title else '%'
+        caption = f'\\caption{{{dataset.title}}}' if dataset.title else '%'
         colspec = cls._colspec(dataset.width)
         header = cls._serialize_row(dataset.headers) if dataset.headers else ''
         midrule = cls._midrule(dataset.width)
         body = '\n'.join([cls._serialize_row(row) for row in dataset])
-        return cls.TABLE_TEMPLATE % dict(CAPTION=caption, COLSPEC=colspec,
-                                         HEADER=header, MIDRULE=midrule, BODY=body)
+        return cls.TABLE_TEMPLATE % {'CAPTION': caption, 'COLSPEC': colspec,
+                                     'HEADER': header, 'MIDRULE': midrule, 'BODY': body}
 
     @classmethod
     def _colspec(cls, dataset_width):
diff --git a/src/tablib/formats/_sql.py b/src/tablib/formats/_sql.py
new file mode 100644
index 0000000..3bf4d5f
--- /dev/null
+++ b/src/tablib/formats/_sql.py
@@ -0,0 +1,70 @@
+"""Tablib - SQL INSERT Export Support."""
+import datetime
+import decimal
+
+from ..exceptions import UnsupportedFormat
+
+
+class SQLFormat:
+    """Export Dataset rows as SQL INSERT statements."""
+    title = 'sql'
+    extensions = ('sql',)
+
+    @staticmethod
+    def _render_literal(value):
+        """Render a Python value as an SQL literal."""
+        if value is None:
+            return 'NULL'
+        if isinstance(value, bool):
+            return 'TRUE' if value else 'FALSE'
+        if isinstance(value, (int, decimal.Decimal)):
+            return str(value)
+        if isinstance(value, float):
+            # Represent finite floats; non-finite as NULL
+            try:
+                if value == value and value not in (float('inf'), -float('inf')):
+                    return repr(value)
+            except Exception:
+                pass
+            return 'NULL'
+        if isinstance(value, datetime.datetime):
+            # ANSI SQL timestamp literal
+            return f"TIMESTAMP '{value.isoformat(sep=' ')}'"
+        if isinstance(value, datetime.date):
+            # ANSI SQL date literal
+            return f"DATE '{value.isoformat()}'"
+        # Fallback for strings and others
+        text = str(value).replace("'", "''")
+        return f"'{text}'"
+
+    @classmethod
+    def export_set(cls, dataset, table=None, columns=None, commit=False):
+        """
+        Return SQL INSERT statements for Dataset rows.
+        :param table: optional table name; defaults to dataset.title or 'data'
+        """
+        tbl = table or getattr(dataset, 'title', None) or 'export_table'
+        tbl_ident = str(tbl)
+        columns_headers = (','.join(
+                columns if columns is not None else
+                dataset.headers if dataset.headers is not None else []
+            )
+        )
+        columns_headers = f' ({columns_headers})' if columns_headers else ''
+        statements = []
+        for row in dataset._data:
+            values = ', '.join(cls._render_literal(v) for v in row)
+            statements.append(
+                f'INSERT INTO {tbl_ident}{columns_headers} VALUES ({values});'
+            )
+        return '\n'.join(statements) + '\n' + ('COMMIT;\n' if commit else '')
+
+    @classmethod
+    def import_set(cls, dataset, in_stream, **kwargs):
+        """Importing SQL is not supported."""
+        raise UnsupportedFormat('SQL import is not supported.')
+
+    @classmethod
+    def detect(cls, stream):
+        """Always return False: no autodetect for SQL."""
+        return False
diff --git a/tox.ini b/tox.ini
index 3a1edc3..f74c086 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,7 +4,7 @@ minversion = 2.4
 envlist =
     docs
     lint
-    py{39,310,311,312,313}
+    py{39,310,311,312,313,314}
 
 [testenv]
 deps =


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6a4a92c..5aad772 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -15,7 +15,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
+        python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
         os: [ubuntu-latest, macOS-latest, windows-latest]
 
     steps:
diff --git a/tests/test_tablib.py b/tests/test_tablib.py
index 7d7b304..6a6dff6 100755
--- a/tests/test_tablib.py
+++ b/tests/test_tablib.py
@@ -141,7 +141,7 @@ class TablibTestCase(BaseTestCase):
 
         data.append_col(new_col)
 
-        self.assertEqual(data[0], tuple([new_col[0]]))
+        self.assertEqual(data[0], (new_col[0],))
         self.assertEqual(data.width, 1)
         self.assertEqual(data.height, len(new_col))
 
@@ -154,7 +154,7 @@ class TablibTestCase(BaseTestCase):
 
         data.append_col(new_col, header='first_name')
 
-        self.assertEqual(data[0], tuple([new_col[0]]))
+        self.assertEqual(data[0], (new_col[0],))
         self.assertEqual(data.width, 1)
         self.assertEqual(data.height, len(new_col))
         self.assertEqual(data.headers, None)
@@ -324,7 +324,7 @@ class TablibTestCase(BaseTestCase):
 
     def test_str_no_columns(self):
         d = tablib.Dataset(['a', 1], ['b', 2], ['c', 3])
-        output = '%s' % d
+        output = f'{d}'
 
         self.assertEqual(output.splitlines(), [
             'a|1',
@@ -426,7 +426,7 @@ class TablibTestCase(BaseTestCase):
         self.assertEqual(tablib.detect_format(_tsv), 'tsv')
 
         _bunk = StringIO(
-            '¡¡¡¡¡¡---///\n\n\n' +
+            '¡¡¡¡¡¡---///\n\n\n'
             '¡¡£™∞¢£§∞§¶•¶ª∞¶•ªº••ª–º§•†•§º¶•†¥ª–º•§ƒø¥¨©πƒø†ˆ¥ç©¨√øˆ¥≈†ƒ¥ç©ø¨çˆ¥ƒçø¶'
         )
         self.assertEqual(tablib.detect_format(_bunk), None)
@@ -1108,7 +1108,7 @@ class CSVTests(BaseTestCase):
 
         expected = 'first_name;last_name;gpa\nJohn;Adams;90\nGeorge;Washington;67\n'
 
-        kwargs = dict(delimiter=';', lineterminator='\n')
+        kwargs = {'delimiter': ';', 'lineterminator': '\n'}
         _csv = data.export('csv', **kwargs)
         self.assertEqual(expected, _csv)
 
@@ -1318,12 +1318,12 @@ class XLSTests(BaseTestCase):
             data = tablib.Dataset().load(fh.read())
         self.assertEqual(
             data.dict[0],
-            dict([
-                ('div by 0', '#DIV/0!'),
-                ('name unknown', '#NAME?'),
-                ('not available (formula)', '#N/A'),
-                ('not available (static)', '#N/A')
-            ])
+            {
+                'div by 0': '#DIV/0!',
+                'name unknown': '#NAME?',
+                'not available (formula)': '#N/A',
+                'not available (static)': '#N/A',
+            }
         )
 
     def test_book_import_from_stream(self):
@@ -1590,8 +1590,8 @@ class JSONTests(BaseTestCase):
 
         expected_json = (
             '[{"first_name": "John", "last_name": "Adams", "gpa": 90, '
-            '"address_id": "%s"}, {"first_name": "名字", "last_name": "李", '
-            '"gpa": 60, "address_id": ""}]' % str(address_id)
+            f'"address_id": "{str(address_id)}"}}, {{"first_name": "名字", "last_name": "李", '
+            '"gpa": 60, "address_id": ""}]'
         )
 
         self.assertEqual(founders_json, expected_json)
@@ -1743,8 +1743,9 @@ class DBFTests(BaseTestCase):
             for reg_char, data_char in zip(_dbf, data.dbf):
                 so_far += chr(data_char)
                 if reg_char != data_char and index not in [1, 2, 3]:
-                    raise AssertionError('Failing at char {}: {} vs {} {}'.format(
-                        index, reg_char, data_char, so_far))
+                    raise AssertionError(
+                        f'Failing at char {index}: {reg_char} vs {data_char} {so_far}'
+                    )
                 index += 1
 
     def test_dbf_export_set(self):
@@ -1786,8 +1787,9 @@ class DBFTests(BaseTestCase):
                 # found_so_far += chr(data_char)
                 if reg_char != data_char and index not in [1, 2, 3]:
                     raise AssertionError(
-                        'Failing at char {}: {} vs {} (found {})'.format(
-                            index, reg_char, data_char, found_so_far))
+                        f'Failing at char {index}: '
+                        f'{reg_char} vs {data_char} (found {found_so_far})'
+                    )
                 index += 1
 
     def test_dbf_format_detect(self):
@@ -1873,3 +1875,86 @@ class CliTests(BaseTestCase):
             '+---+---+---+\n| a | b | c |\n+---+---+---+',
             tablib.Dataset(['a', 'b', 'c']).export('cli', tablefmt='grid')
         )
+
+
+class SQLFormatTests(unittest.TestCase):
+    def test_sql_date_and_timestamp_literals(self):
+        # ANSI SQL date and timestamp literals
+        ds = tablib.Dataset(title='tbl')
+        ds.headers = ['col_date', 'col_timestamp']
+        ds.append([
+            dt.date(2020, 1, 2),
+            dt.datetime(2020, 1, 2, 3, 4, 5)
+        ])
+        sql = ds.export('sql')
+        expected = (
+            "INSERT INTO tbl (col_date,col_timestamp) VALUES "
+            "(DATE '2020-01-02', TIMESTAMP '2020-01-02 03:04:05');\n"
+        )
+        self.assertEqual(sql, expected)
+
+    def test_sql_microseconds_and_default_table(self):
+        # Full microsecond precision and default table name 'data'
+        ds = tablib.Dataset()
+        ds.headers = ['ts']
+        ds.append([dt.datetime(2021, 12, 31, 23, 59, 59, 123456)])
+        sql = ds.export('sql')
+        expected = (
+            "INSERT INTO export_table (ts) VALUES "
+            "(TIMESTAMP '2021-12-31 23:59:59.123456');\n"
+        )
+        self.assertEqual(sql, expected)
+
+    def test_sql_regular_literals(self):
+        # Test int, quoted string, decimal, bool, NULL, and multiline string
+        ds = tablib.Dataset(title='t')
+        ds.headers = ['i', 's', 'd', 'b', 'n', 'm', 'ml']
+        ds.append([
+            1,
+            "O'Reilly",
+            Decimal('3.14'),
+            5.1,
+            False,
+            None,
+            'Line1\nLine2'
+        ])
+        sql = ds.export('sql')
+        expected = (
+            "INSERT INTO t (i,s,d,b,n,m,ml) VALUES (1, 'O''Reilly', 3.14, 5.1, "
+            "FALSE, NULL, 'Line1\nLine2');\n"
+        )
+        self.assertEqual(sql, expected)
+
+    def test_sql_no_headers(self):
+        # Test SQL export without headers
+        ds = tablib.Dataset(title='t')
+        ds.append([
+            1,
+            "O'Reilly",
+            Decimal('3.14'),
+            5.1,
+            False,
+            None,
+            'Line1\nLine2'
+        ])
+        sql = ds.export('sql')
+        expected = (
+            "INSERT INTO t VALUES (1, 'O''Reilly', 3.14, 5.1, "
+            "FALSE, NULL, 'Line1\nLine2');\n"
+        )
+        self.assertEqual(sql, expected)
+
+        # Test with default table name
+        ds = tablib.Dataset()
+        ds.append([1, 'test'])
+        sql = ds.export('sql')
+        expected = "INSERT INTO export_table VALUES (1, 'test');\n"
+        self.assertEqual(sql, expected)
+
+        ds = tablib.Dataset()
+        ds.append([1, 'test'])
+        sql = ds.export('sql', table='schema_name.custom_table',
+                        columns=['col1', 'col2'], commit=True)
+        expected = ("INSERT INTO schema_name.custom_table (col1,col2)"
+                    " VALUES (1, 'test');\nCOMMIT;\n")
+        self.assertEqual(sql, expected)