Skip to content

Commit b7d0c0b

Browse files
committed
allow specifying null values for strings as well
1 parent 6134b7a commit b7d0c0b

2 files changed

Lines changed: 81 additions & 36 deletions

File tree

src/sqlutilpy/sqlutil.py

Lines changed: 67 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -129,49 +129,73 @@ def getCursor(conn, driver=None, preamb=None, notNamed=False):
129129
return cur
130130

131131

132-
def __fromrecords(recList, dtype=None, intNullVal=None):
132+
def __fromrecords(recList, dtype=None, intNullVal=None, strNullVal='None'):
133133
"""
134134
This function was taken from np.core.records and updated to
135-
support conversion null integers to intNullVal
135+
support conversion null integers to intNullVal and strings to strNullVal
136136
"""
137137

138-
shape = None
139138
descr = np.dtype((np.record, dtype))
139+
140+
# Check if we must avoid fast path for strings to prevent None -> 'None' conversion
141+
# Note: numpy's default conversion for None to string is 'None', so if
142+
# strNullVal is 'None', we can still use the fast path.
143+
force_object_strings = False
144+
if strNullVal is not None and strNullVal != 'None':
145+
if any(dtype.fields[n][0].kind in ('S', 'U') for n in dtype.names):
146+
force_object_strings = True
147+
148+
if not force_object_strings:
149+
try:
150+
retval = np.array(recList, dtype=descr)
151+
return retval.view(numpy.recarray)
152+
except (TypeError, ValueError):
153+
# Failed (likely due to None in int col). Fall through to object path.
154+
pass
155+
156+
# Vectorized fallback path using object arrays
157+
names = dtype.names
158+
new_formats = []
159+
converters = {}
160+
161+
for name in names:
162+
dt = dtype.fields[name][0]
163+
if dt.kind in ('i', 'u'):
164+
new_formats.append(object)
165+
converters[name] = intNullVal
166+
elif dt.kind in ('S', 'U') and strNullVal is not None:
167+
new_formats.append(object)
168+
converters[name] = strNullVal
169+
else:
170+
new_formats.append(dt)
171+
172+
temp_dtype = np.dtype({'names': names, 'formats': new_formats})
173+
174+
if len(recList) == 0:
175+
return np.recarray((0,), dtype=descr)
176+
140177
try:
141-
retval = np.array(recList, dtype=descr)
142-
except TypeError: # list of lists instead of list of tuples
178+
arr = np.array(recList, dtype=temp_dtype)
179+
except (TypeError, ValueError):
180+
# Handle cases where np.array fails (e.g. ragged lists)
181+
# by manually filling the object array
143182
shape = (len(recList), )
144-
_array = np.recarray(shape, descr)
145-
try:
146-
for k in range(_array.size):
147-
_array[k] = tuple(recList[k])
148-
except TypeError:
149-
convs = []
150-
ncols = len(dtype.fields)
151-
for _k in dtype.names:
152-
_v = dtype.fields[_k]
153-
if _v[0] in [np.int16, np.int32, np.int64]:
154-
convs.append(lambda x: intNullVal if x is None else x)
155-
else:
156-
convs.append(lambda x: x)
157-
convs = tuple(convs)
158-
159-
def convF(x):
160-
return [convs[_](x[_]) for _ in range(ncols)]
161-
162-
for k in range(k, _array.size):
163-
try:
164-
_array[k] = tuple(recList[k])
165-
except TypeError:
166-
_array[k] = tuple(convF(recList[k]))
167-
return _array
168-
else:
169-
if shape is not None and retval.shape != shape:
170-
retval.shape = shape
183+
arr = np.recarray(shape, temp_dtype)
184+
for k in range(len(recList)):
185+
arr[k] = tuple(recList[k])
171186

172-
res = retval.view(numpy.recarray)
187+
# Apply conversions
188+
for name, val in converters.items():
189+
col = arr[name]
190+
# Check for None efficiently
191+
mask = (col == None)
192+
if np.any(mask):
193+
if val is not None:
194+
col[mask] = val
173195

174-
return res
196+
# Cast to final dtype
197+
retval = arr.astype(descr)
198+
return retval.view(numpy.recarray)
175199

176200

177201
def __getDType(row, typeCodes, strLength):
@@ -269,6 +293,7 @@ def get(query,
269293
batched=True,
270294
asDict=False,
271295
intNullVal=-9999,
296+
strNullVal='None',
272297
nthreads=1):
273298
'''
274299
Executes the sql query and returns the tuple or dictionary
@@ -292,6 +317,9 @@ def get(query,
292317
intNullVal : integer, optional
293318
All the integer columns with nulls will have null replaced by
294319
this value
320+
strNullVal : string, optional
321+
All the string columns with nulls will have null replaced by
322+
this value
295323
db : string
296324
The name of the database
297325
driver : string, optional
@@ -401,7 +429,8 @@ def process_batch(batch):
401429
dtype = __getDType(first_row, type_codes, strLength)
402430
return __fromrecords(batch,
403431
dtype=dtype,
404-
intNullVal=intNullVal)
432+
intNullVal=intNullVal,
433+
strNullVal=strNullVal)
405434

406435
def batch_iter(first):
407436
yield first
@@ -786,6 +815,7 @@ def local_join(query,
786815
timeout=None,
787816
strLength=STRLEN_DEFAULT,
788817
intNullVal=-9999,
818+
strNullVal='None',
789819
asDict=False):
790820
"""
791821
Join your local data in python with the data in the database
@@ -838,7 +868,8 @@ def local_join(query,
838868
preamb=preamb,
839869
strLength=strLength,
840870
asDict=asDict,
841-
intNullVal=intNullVal)
871+
intNullVal=intNullVal,
872+
strNullVal=strNullVal)
842873
except BaseException:
843874
failure_cleanup(conn, connSupplied)
844875
raise

tests/test_pg.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,20 @@ def test_StringFirstNull(setup, batched):
199199
assert (a[1] == 'abcdef')
200200

201201

202+
@pytest.mark.parametrize("batched", [True, False])
203+
def test_StringNull(setup, batched):
204+
kw, conn = setup
205+
a, = sqlutil.get(''' values('abc'), (NULL)''', batched=batched, strNullVal='MISSING', **kw)
206+
assert (len(a) == 2)
207+
assert (a[0] == 'abc')
208+
assert (a[1] == 'MISSING')
209+
210+
# Test fallback if strNullVal is not set (should remain None in object array or 'None' string depending on implementation,
211+
# but based on my change, if strNullVal is None, we use fast path if possible.
212+
# If using fast path with numpy array of strings, None becomes 'None' string usually or empty string?
213+
# Actually in postgres -> python None is None. Numpy array of strings converts None to 'None'.
214+
# But let's verify strNullVal functionality primarily.)
215+
202216
@pytest.mark.parametrize("batched", [True, False])
203217
def test_get(setup, batched):
204218
kw, conn = setup

0 commit comments

Comments
 (0)