Skip to content

Commit 7cadf17

Browse files
authored
feat: indexer option to skip constraints creation (#1691)
- Open Data Editor needs a way to create [flexible database tables](okfn/opendataeditor#552) that can have some constraint violations for further fixing. So here is a new `Indexer.without_constraints` flag to achieve this goal. --- @pierrecamilleri can you please take a look?
1 parent 80f03c6 commit 7cadf17

File tree

4 files changed

+62
-22
lines changed

4 files changed

+62
-22
lines changed

frictionless/formats/sql/__spec__/test_mapper.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,15 @@ def test_sql_mapper_write_field():
4242
column2 = mapper.write_field(field2, table_name="table")
4343
assert isinstance(column1.type, sa.Integer)
4444
assert isinstance(column2.type, sa.Text)
45+
46+
47+
def test_sql_mapper_write_field_ignore_constraints():
48+
mapper = formats.sql.SqlMapper("sqlite")
49+
schema = Schema.describe("data/table.csv")
50+
field1, field2 = schema.fields
51+
field1.constraints = {"required": True}
52+
field2.constraints = {"required": True}
53+
column1 = mapper.write_field(field1, table_name="table")
54+
column2 = mapper.write_field(field2, table_name="table", ignore_constraints=True)
55+
assert column1.nullable is False
56+
assert column2.nullable is True

frictionless/formats/sql/adapter.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ def write_schema(
122122
table_name: str,
123123
force: bool = False,
124124
with_metadata: bool = False,
125+
ignore_constraints: bool = False,
125126
) -> None:
126127
with self.engine.begin() as conn:
127128
if force:
@@ -130,7 +131,10 @@ def write_schema(
130131
self.metadata.drop_all(conn, tables=[existing_table])
131132
self.metadata.remove(existing_table)
132133
table = self.mapper.write_schema(
133-
schema, table_name=table_name, with_metadata=with_metadata
134+
schema,
135+
table_name=table_name,
136+
with_metadata=with_metadata,
137+
ignore_constraints=ignore_constraints,
134138
)
135139
table = table.to_metadata(self.metadata)
136140
self.metadata.create_all(conn, tables=[table])

frictionless/formats/sql/mapper.py

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,19 @@ def read_type(self, column_type: str) -> str:
153153
# Write
154154

155155
def write_schema( # type: ignore
156-
self, schema: Schema, *, table_name: str, with_metadata: bool = False
156+
self,
157+
schema: Schema,
158+
*,
159+
table_name: str,
160+
with_metadata: bool = False,
161+
ignore_constraints: bool = False,
157162
) -> Table:
158163
"""Convert frictionless schema to sqlalchemy table"""
159164
sa = platform.sqlalchemy
160165
columns: List[Column] = [] # type: ignore
161166
constraints: List[Constraint] = []
162167

163-
# Fields
168+
# Metadata
164169
if with_metadata:
165170
columns.append( # type: ignore
166171
sa.Column(
@@ -171,16 +176,19 @@ def write_schema( # type: ignore
171176
)
172177
)
173178
columns.append(sa.Column(settings.ROW_VALID_IDENTIFIER, sa.Boolean)) # type: ignore
179+
180+
# Fields
174181
for field in schema.fields:
175-
column = self.write_field(field, table_name=table_name) # type: ignore
182+
column = self.write_field( # type: ignore
183+
field, table_name=table_name, ignore_constraints=ignore_constraints
184+
)
176185
columns.append(column) # type: ignore
177186

178187
# Primary key
179188
if schema.primary_key:
180189
Class = sa.UniqueConstraint if with_metadata else sa.PrimaryKeyConstraint
181-
if not with_metadata:
182-
constraint = Class(*schema.primary_key)
183-
constraints.append(constraint)
190+
constraint = Class(*schema.primary_key)
191+
constraints.append(constraint)
184192

185193
# Foreign keys
186194
for fk in schema.foreign_keys:
@@ -192,11 +200,18 @@ def write_schema( # type: ignore
192200
constraint = sa.ForeignKeyConstraint(fields, foreign_fields)
193201
constraints.append(constraint)
194202

195-
# Table
196-
table = sa.Table(table_name, sa.MetaData(), *(columns + constraints))
203+
# Prepare table
204+
table_args = [table_name, sa.MetaData(), *columns] # type: ignore
205+
if not ignore_constraints:
206+
table_args += constraints # type: ignore
207+
208+
# Create table
209+
table = sa.Table(*table_args)
197210
return table
198211

199-
def write_field(self, field: Field, *, table_name: str) -> Column: # type: ignore
212+
def write_field( # type: ignore
213+
self, field: Field, *, table_name: str, ignore_constraints: bool = False
214+
) -> Column: # type: ignore
200215
"""Convert frictionless Field to sqlalchemy Column"""
201216
sa = platform.sqlalchemy
202217
quote = self.dialect.identifier_preparer.quote # type: ignore
@@ -206,8 +221,17 @@ def write_field(self, field: Field, *, table_name: str) -> Column: # type: igno
206221
# General properties
207222
quoted_name = quote(field.name)
208223
column_type = self.write_type(field.type) # type: ignore
224+
225+
# Required constraint
209226
nullable = not field.required
210227

228+
# Unique constraint
229+
unique = field.constraints.get("unique", False)
230+
if self.dialect.name == "mysql":
231+
# MySQL requires keys to have an explicit maximum length
232+
# https://stackoverflow.com/questions/1827063/mysql-error-key-specification-without-a-key-length
233+
unique = unique and column_type is not sa.Text
234+
211235
# Length constraints
212236
if field.type == "string":
213237
min_length = field.constraints.get("minLength", None)
@@ -227,13 +251,6 @@ def write_field(self, field: Field, *, table_name: str) -> Column: # type: igno
227251
if not isinstance(column_type, sa.CHAR) or self.dialect.name == "sqlite":
228252
checks.append(Check("LENGTH(%s) >= %s" % (quoted_name, min_length)))
229253

230-
# Unique constraint
231-
unique = field.constraints.get("unique", False)
232-
if self.dialect.name == "mysql":
233-
# MySQL requires keys to have an explicit maximum length
234-
# https://stackoverflow.com/questions/1827063/mysql-error-key-specification-without-a-key-length
235-
unique = unique and column_type is not sa.Text
236-
237254
# Others constraints
238255
for const, value in field.constraints.items():
239256
if const == "minimum":
@@ -252,15 +269,20 @@ def write_field(self, field: Field, *, table_name: str) -> Column: # type: igno
252269
enum_name = "%s_%s_enum" % (table_name, field.name)
253270
column_type = sa.Enum(*value, name=enum_name)
254271

255-
# Create column
256-
column_args = [field.name, column_type] + checks # type: ignore
272+
# Prepare column
257273
# TODO: shall it use "autoincrement=False"
258274
# https://github.com/Mause/duckdb_engine/issues/595#issuecomment-1495408566
259-
column_kwargs = {"nullable": nullable, "unique": unique}
275+
column_args = [field.name, column_type] # type: ignore
276+
column_kwargs = {}
260277
if field.description:
261278
column_kwargs["comment"] = field.description
262-
column = sa.Column(*column_args, **column_kwargs)
279+
if not ignore_constraints:
280+
column_args += checks # type: ignore
281+
column_kwargs["nullable"] = nullable
282+
column_kwargs["unique"] = unique
263283

284+
# Create column
285+
column = sa.Column(*column_args, **column_kwargs)
264286
return column
265287

266288
def write_type(self, field_type: str) -> Type[TypeEngine]: # type: ignore

frictionless/indexer/indexer.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class Indexer:
2828
qsv_path: Optional[str] = None
2929
use_fallback: bool = False
3030
with_metadata: bool = False
31+
ignore_constraints: bool = False
3132
on_row: Optional[types.IOnRow] = None
3233
on_progress: Optional[types.IOnProgress] = None
3334
adapter: SqlAdapter = attrs.field(init=False)
@@ -72,6 +73,7 @@ def create_table(self):
7273
table_name=self.table_name,
7374
force=True,
7475
with_metadata=self.with_metadata,
76+
ignore_constraints=self.ignore_constraints,
7577
)
7678

7779
def populate_table(self) -> Optional[Report]:
@@ -117,7 +119,7 @@ def populate_table_fast_sqlite(self):
117119

118120
def populate_table_fast_postgresql(self):
119121
database_url = self.adapter.engine.url.render_as_string(hide_password=False)
120-
with platform.psycopg.connect(database_url) as connection:
122+
with platform.psycopg.connect(database_url) as connection: # type: ignore
121123
with connection.cursor() as cursor:
122124
query = 'COPY "%s" FROM STDIN CSV HEADER' % self.table_name
123125
with cursor.copy(query) as copy: # type: ignore

0 commit comments

Comments
 (0)