Skip to content

Commit 8be6e09

Browse files
committed
feat(dwc): add CacheTableMeta model and cache table infrastructure
Adds the export-app cache infrastructure: - CacheTableMeta model + migration tracking build state per (mapping, collection) - export.models shim: re-exports Caroline's Schemamapping/Exportdataset/ Exportdatasetextension under PascalCase aliases for use throughout the package - cache.py: get_cache_table_name, create_cache_table, drop_cache_table, _build_single_cache, _execute_and_populate, _infer_column_type, build_cache_tables - dwca_utils.py: shared sanitize/build helpers used by cache and archive code - Tests for SchemaMapping, ExportDataSet, ExportDataSetExtension, CacheTableMeta, and cache table operations Fixes #7737. Closes overlap with the cache mechanism part of c381907 on dwc/foundation; remaining cache features (orphan cleanup, signal handlers, build API, progress callbacks) ship in later atomic PRs.
1 parent f9cf756 commit 8be6e09

9 files changed

Lines changed: 740 additions & 16 deletions

File tree

specifyweb/backend/export/cache.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Cache table operations for DwC export pipeline."""
2+
import logging
3+
import re
4+
from django.db import connection
5+
6+
from .dwca_utils import sanitize_column_name
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
def get_cache_table_name(mapping_id, collection_id, prefix='dwc_cache'):
12+
"""Generate a safe cache table name."""
13+
return f'{prefix}_{mapping_id}_{collection_id}'
14+
15+
16+
def create_cache_table(table_name, columns):
17+
"""Create a cache table with the given columns.
18+
19+
columns: list of (column_name, column_type) tuples.
20+
An auto-increment primary key is always added.
21+
"""
22+
safe_name = re.sub(r'[^a-zA-Z0-9_]', '', table_name)
23+
col_defs = ', '.join(
24+
f'`{re.sub(r"[^a-zA-Z0-9_]", "", name)}` {col_type}'
25+
for name, col_type in columns
26+
)
27+
with connection.cursor() as cursor:
28+
cursor.execute(f'DROP TABLE IF EXISTS `{safe_name}`')
29+
cursor.execute(
30+
f'CREATE TABLE `{safe_name}` ('
31+
f'`id` INT AUTO_INCREMENT PRIMARY KEY, {col_defs}'
32+
f') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
33+
)
34+
logger.info('Created cache table %s', safe_name)
35+
36+
37+
def drop_cache_table(table_name):
38+
"""Drop a cache table if it exists."""
39+
safe_name = re.sub(r'[^a-zA-Z0-9_]', '', table_name)
40+
with connection.cursor() as cursor:
41+
cursor.execute(f'DROP TABLE IF EXISTS `{safe_name}`')
42+
logger.info('Dropped cache table %s', safe_name)
43+
44+
45+
def build_cache_tables(export_dataset, user=None, progress_callback=None):
46+
"""Build cache tables for an ExportDataSet's core mapping and all extensions."""
47+
core_mapping = export_dataset.coremapping
48+
collection = export_dataset.collection
49+
50+
_build_single_cache(core_mapping, collection, user=user,
51+
progress_callback=progress_callback)
52+
53+
for ext in export_dataset.extensions.all().order_by('sortorder').iterator(chunk_size=2000):
54+
_build_single_cache(ext.schemamapping, collection,
55+
prefix=f'dwc_cache_ext{ext.sortorder}',
56+
user=user, progress_callback=progress_callback)
57+
58+
59+
def _build_single_cache(mapping, collection, prefix='dwc_cache', user=None,
60+
progress_callback=None):
61+
"""Build a single cache table for one SchemaMapping."""
62+
from .models import CacheTableMeta
63+
from django.utils import timezone
64+
65+
table_name = get_cache_table_name(mapping.id, collection.id, prefix)
66+
67+
meta, _ = CacheTableMeta.objects.update_or_create(
68+
schemamapping=mapping,
69+
defaults={'tablename': table_name, 'buildstatus': 'building'}
70+
)
71+
72+
try:
73+
display_fields = [
74+
f for f in mapping.query.fields.order_by('position')
75+
if getattr(f, 'term', None)
76+
]
77+
78+
columns = [
79+
(sanitize_column_name(f.term), _infer_column_type(f))
80+
for f in display_fields
81+
]
82+
83+
create_cache_table(table_name, columns)
84+
85+
rowcount = _execute_and_populate(
86+
table_name, mapping, collection, user, progress_callback
87+
)
88+
89+
meta.buildstatus = 'idle'
90+
meta.lastbuilt = timezone.now()
91+
meta.rowcount = rowcount
92+
meta.save()
93+
94+
logger.info('Cache table %s built with %d rows', table_name, rowcount)
95+
96+
except Exception:
97+
meta.buildstatus = 'error'
98+
meta.save()
99+
logger.exception('Failed to build cache table %s', table_name)
100+
raise
101+
102+
103+
def _execute_and_populate(table_name, mapping, collection, user, progress_callback=None):
104+
"""Execute a mapping's query and INSERT results into the cache table.
105+
106+
Uses SQLAlchemy build_query() to ensure output matches query_to_csv
107+
(date formatting, null replacement, etc.), then batch-INSERTs rows.
108+
109+
Returns the number of rows inserted.
110+
"""
111+
from specifyweb.backend.stored_queries.execution import (
112+
build_query, BuildQueryProps, set_group_concat_max_len,
113+
apply_special_post_query_processing,
114+
)
115+
from specifyweb.backend.stored_queries.queryfield import QueryField
116+
from specifyweb.backend.stored_queries.models import session_context
117+
from .field_adapter import EphemeralFieldAdapter
118+
119+
query_obj = mapping.query
120+
display_fields = [
121+
f for f in query_obj.fields.order_by('position')
122+
if getattr(f, 'term', None)
123+
]
124+
field_specs = [
125+
QueryField.from_spqueryfield(EphemeralFieldAdapter(f, force_display=True))
126+
for f in display_fields
127+
]
128+
129+
safe_name = re.sub(r'[^a-zA-Z0-9_]', '', table_name)
130+
col_count = len(display_fields)
131+
placeholders = ', '.join(['%s'] * col_count)
132+
col_names = ', '.join(
133+
f'`{sanitize_column_name(f.term)}`'
134+
for f in display_fields
135+
)
136+
insert_sql = f'INSERT INTO `{safe_name}` ({col_names}) VALUES ({placeholders})'
137+
138+
total = 0
139+
BATCH_SIZE = 2000
140+
141+
with session_context() as session:
142+
set_group_concat_max_len(session.connection())
143+
sa_query, _ = build_query(
144+
session, collection, user,
145+
query_obj.contexttableid,
146+
field_specs,
147+
BuildQueryProps(
148+
replace_nulls=True,
149+
date_format_override='%Y-%m-%d',
150+
),
151+
)
152+
sa_query = apply_special_post_query_processing(
153+
sa_query, query_obj.contexttableid, field_specs, collection, user,
154+
should_list_query=False,
155+
)
156+
157+
batch = []
158+
if isinstance(sa_query, list):
159+
iterator = iter(sa_query)
160+
else:
161+
iterator = sa_query.yield_per(BATCH_SIZE)
162+
163+
for row in iterator:
164+
batch.append(tuple(
165+
str(v) if v is not None else '' for v in row[1:]
166+
))
167+
168+
if len(batch) >= BATCH_SIZE:
169+
with connection.cursor() as cursor:
170+
cursor.executemany(insert_sql, batch)
171+
total += len(batch)
172+
batch = []
173+
if progress_callback:
174+
progress_callback(total, None)
175+
176+
if batch:
177+
with connection.cursor() as cursor:
178+
cursor.executemany(insert_sql, batch)
179+
total += len(batch)
180+
181+
if progress_callback:
182+
progress_callback(total, total)
183+
184+
return total
185+
186+
187+
def _infer_column_type(spqueryfield):
188+
"""Infer a MySQL column type from a Specify query field."""
189+
fname = (spqueryfield.fieldname or '').lower()
190+
191+
if 'guid' in fname or 'uuid' in fname:
192+
return 'VARCHAR(256)'
193+
if fname in ('id', 'rankid', 'number1', 'number2', 'countamt',
194+
'sortorder', 'position', 'version'):
195+
return 'INT'
196+
if 'numericyear' in fname or 'numericmonth' in fname or 'numericday' in fname:
197+
return 'INT'
198+
if fname in ('latitude1', 'latitude2', 'longitude1', 'longitude2',
199+
'latlongaccuracy', 'maxelevation', 'minelevation'):
200+
return 'DECIMAL(12,6)'
201+
if fname in ('startdate', 'enddate', 'determineddate', 'catalogeddate',
202+
'timestampcreated', 'timestampmodified'):
203+
return 'VARCHAR(32)'
204+
if fname.startswith('is') or fname.startswith('yes'):
205+
return 'VARCHAR(8)'
206+
if fname in ('catalognumber', 'altcatalognumber', 'barcode', 'fieldnumber',
207+
'code', 'abbreviation', 'datum'):
208+
return 'VARCHAR(256)'
209+
return 'TEXT'
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Shared utilities for DwC archive generation."""
2+
import re
3+
from datetime import date
4+
from uuid import uuid4
5+
from xml.etree import ElementTree as ET
6+
7+
8+
def sanitize_term_name(term_iri):
9+
"""Extract the short name from a DwC term IRI.
10+
11+
'http://rs.tdwg.org/dwc/terms/catalogNumber' -> 'catalogNumber'
12+
'http://purl.org/dc/terms/type' -> 'type'
13+
"""
14+
if '/' in term_iri:
15+
term_iri = term_iri.rsplit('/', 1)[-1]
16+
if '#' in term_iri:
17+
term_iri = term_iri.rsplit('#', 1)[-1]
18+
return term_iri
19+
20+
21+
def sanitize_column_name(name):
22+
"""Sanitize a term IRI into a valid MySQL column name."""
23+
name = sanitize_term_name(name)
24+
name = re.sub(r'[^a-zA-Z0-9_]', '_', name)
25+
return name[:64]
26+
27+
28+
# Known extension rowType URIs
29+
EXTENSION_ROW_TYPES = {
30+
'MeasurementOrFact': 'http://rs.iobis.org/obis/terms/ExtendedMeasurementOrFact',
31+
'ResourceRelationship': 'http://rs.tdwg.org/dwc/terms/ResourceRelationship',
32+
'Identification': 'http://rs.tdwg.org/dwc/terms/Identification',
33+
'Multimedia': 'http://rs.gbif.org/terms/1.0/Multimedia',
34+
}
35+
36+
37+
def build_meta_xml(core_terms, ext_info_list):
38+
"""Build meta.xml describing the DwC archive structure.
39+
40+
core_terms: list of full term IRIs for the core file
41+
ext_info_list: list of dicts with 'filename' and 'terms' (full IRIs)
42+
"""
43+
archive = ET.Element('archive')
44+
archive.set('xmlns', 'http://rs.tdwg.org/dwc/text/')
45+
archive.set('metadata', 'eml.xml')
46+
47+
# Core
48+
core = ET.SubElement(archive, 'core')
49+
core.set('encoding', 'UTF-8')
50+
core.set('fieldsTerminatedBy', ',')
51+
core.set('linesTerminatedBy', '\\n')
52+
core.set('fieldsEnclosedBy', '"')
53+
core.set('ignoreHeaderLines', '1')
54+
core.set('rowType', 'http://rs.tdwg.org/dwc/terms/Occurrence')
55+
56+
files = ET.SubElement(core, 'files')
57+
location = ET.SubElement(files, 'location')
58+
location.text = 'occurrence.csv'
59+
60+
if core_terms:
61+
id_elem = ET.SubElement(core, 'id')
62+
id_elem.set('index', '0')
63+
64+
for idx, term_iri in enumerate(core_terms):
65+
f = ET.SubElement(core, 'field')
66+
f.set('index', str(idx))
67+
f.set('term', term_iri)
68+
69+
# Extensions
70+
for ext in ext_info_list:
71+
extension = ET.SubElement(archive, 'extension')
72+
extension.set('encoding', 'UTF-8')
73+
extension.set('fieldsTerminatedBy', ',')
74+
extension.set('linesTerminatedBy', '\\n')
75+
extension.set('fieldsEnclosedBy', '"')
76+
extension.set('ignoreHeaderLines', '1')
77+
row_type = ext.get('rowType', 'http://rs.tdwg.org/dwc/terms/MeasurementOrFact')
78+
extension.set('rowType', row_type)
79+
80+
files = ET.SubElement(extension, 'files')
81+
location = ET.SubElement(files, 'location')
82+
location.text = ext['filename']
83+
84+
coreid = ET.SubElement(extension, 'coreid')
85+
coreid.set('index', '0')
86+
87+
for idx, term_iri in enumerate(ext['terms']):
88+
f = ET.SubElement(extension, 'field')
89+
f.set('index', str(idx))
90+
f.set('term', term_iri)
91+
92+
return ET.tostring(archive, encoding='unicode', xml_declaration=True)
93+
94+
95+
def build_eml_xml(export_dataset):
96+
"""Build EML metadata. Returns custom EML if uploaded, else generates minimal EML."""
97+
if export_dataset.metadata:
98+
try:
99+
from specifyweb.specify.models import Spappresourcedata
100+
data = Spappresourcedata.objects.filter(
101+
spappresource=export_dataset.metadata
102+
).first()
103+
if data and data.data:
104+
content = data.data
105+
if isinstance(content, bytes):
106+
content = content.decode('utf-8')
107+
return content
108+
except Exception:
109+
pass
110+
111+
eml = ET.Element('eml:eml')
112+
eml.set('xmlns:eml', 'eml://ecoinformatics.org/eml-2.1.1')
113+
eml.set('packageId', str(uuid4()))
114+
eml.set('system', 'http://specify.org')
115+
116+
dataset = ET.SubElement(eml, 'dataset')
117+
title = ET.SubElement(dataset, 'title')
118+
title.text = export_dataset.exportname
119+
120+
creator = ET.SubElement(dataset, 'creator')
121+
org = ET.SubElement(creator, 'organizationName')
122+
org.text = 'Specify Collection'
123+
124+
pubdate = ET.SubElement(dataset, 'pubDate')
125+
pubdate.text = date.today().strftime('%Y-%m-%d')
126+
127+
abstract = ET.SubElement(dataset, 'abstract')
128+
para = ET.SubElement(abstract, 'para')
129+
para.text = f'Darwin Core Archive export: {export_dataset.exportname}'
130+
131+
return ET.tostring(eml, encoding='unicode', xml_declaration=True)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from django.db import migrations, models
2+
import django.db.models.deletion
3+
import django.utils.timezone
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
initial = True
9+
10+
dependencies = [
11+
('specify', '0048_extensions_and_vocabulary'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='CacheTableMeta',
17+
fields=[
18+
('id', models.AutoField(db_column='CacheTableMetaID', primary_key=True, serialize=False)),
19+
('tablename', models.CharField(db_column='TableName', max_length=128, unique=True)),
20+
('lastbuilt', models.DateTimeField(blank=True, db_column='LastBuilt', null=True)),
21+
('rowcount', models.IntegerField(blank=True, db_column='RowCount', null=True)),
22+
('buildstatus', models.CharField(
23+
choices=[('idle', 'idle'), ('building', 'building'), ('error', 'error')],
24+
db_column='BuildStatus', default='idle', max_length=16,
25+
)),
26+
('builderror', models.TextField(blank=True, db_column='BuildError', null=True)),
27+
('timestampcreated', models.DateTimeField(db_column='TimestampCreated', default=django.utils.timezone.now)),
28+
('timestampmodified', models.DateTimeField(db_column='TimestampModified', default=django.utils.timezone.now)),
29+
('collection', models.ForeignKey(
30+
db_column='CollectionID',
31+
on_delete=django.db.models.deletion.CASCADE,
32+
related_name='+', to='specify.collection',
33+
)),
34+
('schemamapping', models.ForeignKey(
35+
db_column='SchemaMappingID',
36+
on_delete=django.db.models.deletion.CASCADE,
37+
related_name='cachetablemetas', to='specify.schemamapping',
38+
)),
39+
],
40+
options={
41+
'db_table': 'cachetablemeta',
42+
'indexes': [models.Index(fields=['schemamapping', 'collection'], name='CacheMetaMappingColIDX')],
43+
},
44+
),
45+
]

specifyweb/backend/export/migrations/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)